-
Notifications
You must be signed in to change notification settings - Fork 89
/
csrfprotector.php
executable file
·536 lines (472 loc) · 15.5 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
<?php
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");
/**
* child exception classes
*/
class configFileNotFoundException extends \exception {};
class logDirectoryNotFoundException extends \exception {};
class jsFileNotFoundException extends \exception {};
class logFileWriteError extends \exception {};
class baseJSFileNotFoundExceptio extends \exception {};
class incompleteConfigurationException extends \exception {};
class alreadyInitializedException extends \exception {};
class csrfProtector
{
/*
* Variable: $cookieExpiryTime
* expiry time for cookie
* @var int
*/
public static $cookieExpiryTime = 1800; //30 minutes
/*
* 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: $requestType
* Varaible to store weather 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: #2: logDirectory (string) => directory in which log will be saved
* 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
*/
public static $config = array();
/*
* Variable: $requiredConfigurations
* Contains list of those parameters that are required to be there
* in config file for csrfp to work
*/
public static $requiredConfigurations = array('logDirectory', 'failedAuthAction', 'jsPath', 'jsUrl', 'tokenLength');
/*
* Function: init
*
* function to initialise the csrfProtector work flow
*
* Parameters:
* $length - length of CSRF_AUTH_TOKEN to be generated
* $action - int array, for different actions to be taken in case of failed validation
*
* 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)
{
/*
* 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 verification, no filtering
* Already done by mod_csrfp
*/
if (getenv('mod_csrfp_enabled'))
return;
//start session in case its not
if (session_id() == '')
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;
// Validate the config if everythings filled out
// TODO: collect all missing values and throw exception together
foreach (self::$requiredConfigurations as $value) {
if (!isset(self::$config[$value]) || self::$config[$value] == '') {
throw new incompleteConfigurationException(
sprintf(
"OWASP CSRFProtector: Incomplete configuration file, Value: %s missing ",
$value
)
);
exit;
}
}
// 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();
// Set protected by CSRF Protector header
header('X-CSRF-Protection: OWASP CSRFP 1.0.0');
}
/*
* Function: authorizePost
* function to authorise incoming post requests
*
* Parameters:
* void
*
* Returns:
* void
*
* Throws:
* logDirectoryNotFoundException - if log directory is not found
*/
public static function authorizePost()
{
//#todo 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";
//currently for same origin only
if (!(isset($_POST[self::$config['CSRFP_TOKEN']])
&& isset($_SESSION[self::$config['CSRFP_TOKEN']])
&& (self::isValidToken($_POST[self::$config['CSRFP_TOKEN']]))
)) {
//action in case of failed validation
self::failedValidationAction();
} else {
self::refreshToken(); //refresh token for successfull 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 successfull validation
}
}
}
/*
* 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()
{
if (!file_exists(__DIR__ ."/../" .self::$config['logDirectory']))
throw new logDirectoryNotFoundException("OWASP CSRFProtector: Log Directory Not Found!");
//call the logging function
static::logCSRFattack();
//#todo: ask mentors if $failedAuthAction is better as an int or string
//default case is case 0
switch (self::$config['failedAuthAction'][self::$requestType]) {
case 0:
//send 403 header
header('HTTP/1.0 403 Forbidden');
exit("<h2>403 Access Forbidden by CSRFProtector!</h2>");
break;
case 1:
//unset the query parameters and forward
if (self::$requestType === 'GET') {
$_GET = array();
} else {
$_POST = array();
}
break;
case 2:
//redirect to custom error page
$location = self::$config['errorRedirectionPage'];
header("location: $location");
case 3:
//send custom error message
exit(self::$config['customErrorMessage']);
break;
case 4:
//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
setcookie(self::$config['CSRFP_TOKEN'],
$token,
time() + self::$cookieExpiryTime,
'',
'',
(array_key_exists('secureCookie', self::$config) ? (bool)self::$config['secureCookie'] : false));
}
/*
* 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 - 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 - 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);
//implant the CSRFGuard js file to outgoing script
$script = '<script type="text/javascript" src="' . self::$config['jsUrl'] . '"></script>' . PHP_EOL;
$buffer = str_ireplace('</body>', $script . '</body>', $buffer, $count);
if (!$count)
$buffer .= $script;
return $buffer;
}
/*
* Function: logCSRFattack
* Function to log CSRF Attack
*
* Parameters:
* void
*
* Retruns:
* void
*
* Throws:
* logFileWriteError - if unable to log an attack
*/
protected static function logCSRFattack()
{
//if file doesnot exist for, create it
$logFile = fopen(__DIR__ ."/../" .self::$config['logDirectory']
."/" .date("m-20y") .".log", "a+");
//throw exception if above fopen fails
if (!$logFile)
throw new logFileWriteError("OWASP CSRFProtector: Unable to write to the log file");
//miniature version of the log
$log = array();
$log['timestamp'] = time();
$log['HOST'] = $_SERVER['HTTP_HOST'];
$log['REQUEST_URI'] = $_SERVER['REQUEST_URI'];
$log['requestType'] = self::$requestType;
if (self::$requestType === "GET")
$log['query'] = $_GET;
else
$log['query'] = $_POST;
$log['cookie'] = $_COOKIE;
//convert log array to JSON format to be logged
$log = json_encode($log) .PHP_EOL;
//append log to the file
fwrite($logFile, $log);
//close the file handler
fclose($logFile);
}
/*
* 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 mataches 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;
}
};
}