-
Notifications
You must be signed in to change notification settings - Fork 89
/
Copy pathcsrfprotector.php
executable file
·602 lines (531 loc) · 21.7 KB
/
csrfprotector.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
<?php
/**
* This file has implementation for csrfProtector class.
*/
include __DIR__ ."/csrfpCookieConfig.php"; // cookie config class
include __DIR__ ."/csrfpDefaultLogger.php"; // Logger class
include __DIR__ ."/csrfpAction.php"; // Actions enumerator
if (!defined('__CSRF_PROTECTOR__')) {
define('__CSRF_PROTECTOR__', true); // to avoid multiple declaration errors
// Name of HTTP POST variable for authentication
define("CSRFP_TOKEN","CSRFP-Token");
// We insert token name and list of url patterns for which
// GET requests are validated against CSRF as hidden input fields
// these are the names of the input fields
define("CSRFP_FIELD_TOKEN_NAME", "csrfp_hidden_data_token");
define("CSRFP_FIELD_URLS", "csrfp_hidden_data_urls");
/** Indicates configuration file was not found. */
class configFileNotFoundException extends \exception {};
/** Indicates that configuration file is incomplete. */
class incompleteConfigurationException extends \exception {};
/** Indicates that CSRF Protector is already initialized. */
class alreadyInitializedException extends \exception {};
class csrfProtector
{
/*
* Variable: $isSameOrigin
* flag for cross origin/same origin request
* @var bool
*/
private static $isSameOrigin = true;
/*
* Variable: $isValidHTML
* flag to check if output file is a valid HTML or not
* @var bool
*/
private static $isValidHTML = false;
/**
* Variable: $cookieConfig
* Array of parameters for the setcookie method
* @var array<any>
*/
private static $cookieConfig = null;
/**
* Variable: $logger
* Logger class object
* @var LoggerInterface
*/
private static $logger = null;
/**
* Variable: $tokenHeaderKey
* Key value in header array, which contain the token
* @var string
*/
private static $tokenHeaderKey = null;
/*
* Variable: $requestType
* Variable to store whether request type is post or get
* @var string
*/
protected static $requestType = "GET";
/*
* Variable: $config
* config file for CSRFProtector
* @var int Array, length = 6
* Property: #1: failedAuthAction (int) => action to be taken in case
* autherisation fails.
* Property: #3: customErrorMessage (string) => custom error message to
* be sent in case of failed authentication.
* Property: #4: jsFile (string) => location of the CSRFProtector js
* file.
* Property: #5: tokenLength (int) => default length of hash.
* Property: #6: disabledJavascriptMessage (string) => error message if
* client's js is disabled.
*
* TODO(mebjas): this field should be private
*/
public static $config = array();
/*
* Variable: $requiredConfigurations
* Contains list of those parameters that are required to be there
* in config file for csrfp to work
*
* TODO(mebjas): this field should be private
*/
public static $requiredConfigurations = array(
'failedAuthAction', 'jsUrl', 'tokenLength');
/*
* Function: function to initialise the csrfProtector work flow
*
* Parameters:
* $length - (int) length of CSRF_AUTH_TOKEN to be generated.
* $action - (int array), for different actions to be taken in case of
* failed validation.
* $logger - (LoggerInterface) custom logger class object.
*
* Returns:
* void
*
* Throws:
* configFileNotFoundException - when configuration file is not found
* incompleteConfigurationException - when all required fields in config
* file are not available
*/
public static function init($length = null, $action = null, $logger = null)
{
// Check if init has already been called.
if (count(self::$config) > 0) {
throw new alreadyInitializedException("OWASP CSRFProtector: library was already initialized.");
}
// If mod_csrfp already enabled, no extra verification needed.
if (getenv('mod_csrfp_enabled')) {
return;
}
// Start session in case its not, and unit test is not going on
if (session_id() == '' && !defined('__CSRFP_UNIT_TEST__')) {
session_start();
}
// Load configuration file and properties & Check locally for a
// config.php then check for a config/csrf_config.php file in the
// root folder for composer installations
$standard_config_location = __DIR__ ."/../config.php";
$composer_config_location = __DIR__ ."/../../../../../config/csrf_config.php";
if (file_exists($standard_config_location)) {
self::$config = include($standard_config_location);
} elseif (file_exists($composer_config_location)) {
self::$config = include($composer_config_location);
} else {
throw new configFileNotFoundException(
"OWASP CSRFProtector: configuration file not found for CSRFProtector!");
}
// Overriding length property if passed in parameters
if ($length != null) {
self::$config['tokenLength'] = intval($length);
}
// Action that is needed to be taken in case of failed authorisation
if ($action != null) {
self::$config['failedAuthAction'] = $action;
}
if (self::$config['CSRFP_TOKEN'] == '') {
self::$config['CSRFP_TOKEN'] = CSRFP_TOKEN;
}
self::$tokenHeaderKey = 'HTTP_' .strtoupper(self::$config['CSRFP_TOKEN']);
self::$tokenHeaderKey = str_replace('-', '_', self::$tokenHeaderKey);
// Load parameters for setcookie method
if (!isset(self::$config['cookieConfig'])) {
self::$config['cookieConfig'] = array();
}
self::$cookieConfig = new csrfpCookieConfig(self::$config['cookieConfig']);
// Validate the config if everything is filled out
$missingConfiguration = [];
foreach (self::$requiredConfigurations as $value) {
if (!isset(self::$config[$value]) || self::$config[$value] === '') {
$missingConfiguration[] = $value;
}
}
if ($missingConfiguration) {
throw new incompleteConfigurationException(
'OWASP CSRFProtector: Incomplete configuration file: missing ' .
implode(', ', $missingConfiguration) . ' value(s)');
}
// Initialize the logger class
if ($logger !== null) {
self::$logger = $logger;
} else {
self::$logger = new csrfpDefaultLogger();
}
// Authorise the incoming request
self::authorizePost();
// Initialize output buffering handler
if (!defined('__TESTING_CSRFP__')) {
ob_start('csrfProtector::ob_handler');
}
if (!isset($_COOKIE[self::$config['CSRFP_TOKEN']])
|| !isset($_SESSION[self::$config['CSRFP_TOKEN']])
|| !is_array($_SESSION[self::$config['CSRFP_TOKEN']])
|| !in_array($_COOKIE[self::$config['CSRFP_TOKEN']],
$_SESSION[self::$config['CSRFP_TOKEN']])) {
self::refreshToken();
}
}
/*
* Function: authorizePost
* function to authorise incoming post requests
*
* Parameters:
* void
*
* Returns:
* void
*
* TODO(mebjas): this method should be private.
*/
public static function authorizePost()
{
// TODO(mebjas): this method is valid for same origin request only,
// enable it for cross origin also sometime for cross origin the
// functionality is different.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Set request type to POST
self::$requestType = "POST";
// Look for token in payload else from header
$token = self::getTokenFromRequest();
// Currently for same origin only
if (!($token && isset($_SESSION[self::$config['CSRFP_TOKEN']])
&& (self::isValidToken($token)))) {
// Action in case of failed validation
self::failedValidationAction();
} else {
self::refreshToken(); //refresh token for successful validation
}
} else if (!static::isURLallowed()) {
// Currently for same origin only
if (!(isset($_GET[self::$config['CSRFP_TOKEN']])
&& isset($_SESSION[self::$config['CSRFP_TOKEN']])
&& (self::isValidToken($_GET[self::$config['CSRFP_TOKEN']])))) {
// Action in case of failed validation
self::failedValidationAction();
} else {
self::refreshToken(); // Refresh token for successful validation
}
}
}
/*
* Function: getTokenFromRequest
* function to get token in case of POST request
*
* Parameters:
* void
*
* Returns:
* any (string / bool) - token retrieved from header or form payload
*/
private static function getTokenFromRequest()
{
// Look for in $_POST, then header
if (isset($_POST[self::$config['CSRFP_TOKEN']])) {
return $_POST[self::$config['CSRFP_TOKEN']];
}
if (function_exists('getallheaders')) {
$requestHeaders = getallheaders();
if (isset($requestHeaders[self::$config['CSRFP_TOKEN']])) {
return $requestHeaders[self::$config['CSRFP_TOKEN']];
}
}
if (self::$tokenHeaderKey === null) {
return false;
}
if (isset($_SERVER[self::$tokenHeaderKey])) {
return $_SERVER[self::$tokenHeaderKey];
}
return false;
}
/*
* Function: isValidToken
* function to check the validity of token in session array
* Function also clears all tokens older than latest one
*
* Parameters:
* $token - the token sent with GET or POST payload
*
* Returns:
* bool - true if its valid else false
*/
private static function isValidToken($token)
{
if (!isset($_SESSION[self::$config['CSRFP_TOKEN']])) {
return false;
}
if (!is_array($_SESSION[self::$config['CSRFP_TOKEN']])) {
return false;
}
foreach ($_SESSION[self::$config['CSRFP_TOKEN']] as $key => $value) {
if ($value == $token) {
// Clear all older tokens assuming they have been consumed
foreach ($_SESSION[self::$config['CSRFP_TOKEN']] as $_key => $_value) {
if ($_value == $token) break;
array_shift($_SESSION[self::$config['CSRFP_TOKEN']]);
}
return true;
}
}
return false;
}
/*
* Function: failedValidationAction
* function to be called in case of failed validation
* performs logging and take appropriate action
*
* Parameters:
* void
*
* Returns:
* void
*/
private static function failedValidationAction()
{
//call the logging function
static::logCSRFattack();
// TODO(mebjas): ask mentors if $failedAuthAction is better as an int or string
// default case is case 0
switch (self::$config['failedAuthAction'][self::$requestType]) {
case csrfpAction::ForbiddenResponseAction:
// Send 403 header
header('HTTP/1.0 403 Forbidden');
exit("<h2>403 Access Forbidden by CSRFProtector!</h2>");
break;
case csrfpAction::ClearParametersAction:
// Unset the query parameters and forward
if (self::$requestType === 'GET') {
$_GET = array();
} else {
$_POST = array();
}
break;
case csrfpAction::RedirectAction:
// Redirect to custom error page
$location = self::$config['errorRedirectionPage'];
header("location: $location");
exit(self::$config['customErrorMessage']);
break;
case csrfpAction::CustomErrorMessageAction:
// Send custom error message
exit(self::$config['customErrorMessage']);
break;
case csrfpAction::InternalServerErrorResponseAction:
// Send 500 header -- internal server error
header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error', true, 500);
exit("<h2>500 Internal Server Error!</h2>");
break;
default:
// Unset the query parameters and forward
if (self::$requestType === 'GET') {
$_GET = array();
} else {
$_POST = array();
}
break;
}
}
/*
* Function: refreshToken
* Function to set auth cookie
*
* Parameters:
* void
*
* Returns:
* void
*/
public static function refreshToken()
{
$token = self::generateAuthToken();
if (!isset($_SESSION[self::$config['CSRFP_TOKEN']])
|| !is_array($_SESSION[self::$config['CSRFP_TOKEN']]))
$_SESSION[self::$config['CSRFP_TOKEN']] = array();
// Set token to session for server side validation
array_push($_SESSION[self::$config['CSRFP_TOKEN']], $token);
// Set token to cookie for client side processing
if (self::$cookieConfig === null) {
if (!isset(self::$config['cookieConfig']))
self::$config['cookieConfig'] = array();
self::$cookieConfig = new csrfpCookieConfig(self::$config['cookieConfig']);
}
setcookie(
self::$config['CSRFP_TOKEN'],
$token,
time() + self::$cookieConfig->expire,
self::$cookieConfig->path,
self::$cookieConfig->domain,
(bool) self::$cookieConfig->secure);
}
/*
* Function: generateAuthToken
* function to generate random hash of length as given in parameter
* max length = 128
*
* Parameters:
* length to hash required, int
*
* Returns:
* string, token
*/
public static function generateAuthToken()
{
// TODO(mebjas): Make this a member method / configurable
$randLength = 64;
// If config tokenLength value is 0 or some non int
if (intval(self::$config['tokenLength']) == 0) {
self::$config['tokenLength'] = 32; //set as default
}
// TODO(mebjas): if $length > 128 throw exception
if (function_exists("random_bytes")) {
$token = bin2hex(random_bytes($randLength));
} elseif (function_exists("openssl_random_pseudo_bytes")) {
$token = bin2hex(openssl_random_pseudo_bytes($randLength));
} else {
$token = '';
for ($i = 0; $i < 128; ++$i) {
$r = mt_rand (0, 35);
if ($r < 26) {
$c = chr(ord('a') + $r);
} else {
$c = chr(ord('0') + $r - 26);
}
$token .= $c;
}
}
return substr($token, 0, self::$config['tokenLength']);
}
/*
* Function: ob_handler
* Rewrites <form> on the fly to add CSRF tokens to them. This can also
* inject our JavaScript library.
*
* Parameters:
* $buffer - output buffer to which all output are stored
* $flag - INT
*
* Return:
* string, complete output buffer
*/
public static function ob_handler($buffer, $flags)
{
// Even though the user told us to rewrite, we should do a quick heuristic
// to check if the page is *actually* HTML. We don't begin rewriting until
// we hit the first <html tag.
if (!self::$isValidHTML) {
// Not HTML until proven otherwise
if (stripos($buffer, '<html') !== false) {
self::$isValidHTML = true;
} else {
return $buffer;
}
}
// TODO: statically rewrite all forms as well so that if a form is submitted
// before the js has worked on, it will still have token to send
// @priority: medium @labels: important @assign: mebjas
// @deadline: 1 week
// Add a <noscript> message to outgoing HTML output,
// informing the user to enable js for CSRFProtector to work
// best section to add, after <body> tag
$buffer = preg_replace("/<body[^>]*>/", "$0 <noscript>" . self::$config['disabledJavascriptMessage'] .
"</noscript>", $buffer);
$hiddenInput = '<input type="hidden" id="' . CSRFP_FIELD_TOKEN_NAME.'" value="'
.self::$config['CSRFP_TOKEN'] .'">' .PHP_EOL;
$hiddenInput .= '<input type="hidden" id="' .CSRFP_FIELD_URLS .'" value=\''
.json_encode(self::$config['verifyGetFor']) .'\'>';
// Implant hidden fields with check url information for reading in javascript
$buffer = str_ireplace('</body>', $hiddenInput . '</body>', $buffer);
if (self::$config['jsUrl']) {
// Implant the CSRFGuard js file to outgoing script
$script = '<script type="text/javascript" src="' . self::$config['jsUrl'] . '"></script>';
$buffer = str_ireplace('</body>', $script . PHP_EOL . '</body>', $buffer, $count);
// Add the script to the end if the body tag was not closed
if (!$count) {
$buffer .= $script;
}
}
return $buffer;
}
/*
* Function: logCSRFattack
* Function to log CSRF Attack
*
* Parameters:
* void
*
* Returns:
* void
*
* Throws:
* logFileWriteError - if unable to log an attack
*/
protected static function logCSRFattack()
{
//miniature version of the log
$context = array();
$context['HOST'] = $_SERVER['HTTP_HOST'];
$context['REQUEST_URI'] = $_SERVER['REQUEST_URI'];
$context['requestType'] = self::$requestType;
$context['cookie'] = $_COOKIE;
self::$logger->log(
"OWASP CSRF PROTECTOR VALIDATION FAILURE", $context);
}
/*
* Function: getCurrentUrl
* Function to return current url of executing page
*
* Parameters:
* void
*
* Returns:
* string - current url
*/
private static function getCurrentUrl()
{
$request_scheme = 'https';
if (isset($_SERVER['REQUEST_SCHEME'])) {
$request_scheme = $_SERVER['REQUEST_SCHEME'];
} else {
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
$request_scheme = 'https';
} else {
$request_scheme = 'http';
}
}
return $request_scheme . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
}
/*
* Function: isURLallowed
* Function to check if a url matches for any urls
* Listed in config file
*
* Parameters:
* void
*
* Returns:
* boolean - true is url need no validation, false if validation needed
*/
public static function isURLallowed() {
foreach (self::$config['verifyGetFor'] as $key => $value) {
$value = str_replace(array('/','*'), array('\/','(.*)'), $value);
preg_match('/' .$value .'/', self::getCurrentUrl(), $output);
if (count($output) > 0) {
return false;
}
}
return true;
}
};
}