Bearsampp
2026.9.23
Toggle main menu visibility
Loading...
Searching...
No Matches
class.csrf.php
Go to the documentation of this file.
1
<?php
2
/*
3
* Copyright (c) 2021-2025 Bearsampp
4
* License: GNU General Public License version 3 or later; see LICENSE.txt
5
* Author: Bear
6
* Website: https://bearsampp.com
7
* Github: https://github.com/Bearsampp
8
*/
9
34
class
Csrf
35
{
39
const
SESSION_KEY
=
'bearsampp_csrf_tokens'
;
40
44
const
TOKEN_EXPIRATION
= 7200;
45
50
const
MAX_TOKENS
= 10;
51
58
public
static
function
init
()
59
{
60
// Harden session settings before the session is started
61
self::configureSession
();
62
63
// Start session if not already started
64
if
(session_status() === PHP_SESSION_NONE) {
65
session_start();
66
}
67
68
// Initialize token storage if not exists
69
if
(!isset($_SESSION[self::SESSION_KEY])) {
70
$_SESSION[self::SESSION_KEY] = [];
71
}
72
73
// Clean up expired tokens
74
self::cleanupExpiredTokens
();
75
}
76
84
private
static
function
configureSession
()
85
{
86
// Only relevant before the session has been started
87
if
(session_status() !== PHP_SESSION_NONE || session_id() !==
''
) {
88
return
;
89
}
90
91
$secure = (!empty($_SERVER[
'HTTPS'
]) && $_SERVER[
'HTTPS'
] !==
'off'
);
92
93
session_set_cookie_params([
94
'lifetime'
=> 0,
95
'path'
=>
'/'
,
96
'secure'
=> $secure,
97
'httponly'
=>
true
,
98
'samesite'
=>
'Strict'
99
]);
100
101
ini_set(
'session.use_only_cookies'
,
'1'
);
102
ini_set(
'session.use_strict_mode'
,
'1'
);
103
}
104
111
public
static
function
generateToken
()
112
{
113
self::init
();
114
115
// Fail closed: never fall back to weak randomness. random_bytes()
116
// throws if no secure source of entropy is available.
117
$token = bin2hex(random_bytes(32));
118
119
// Store token with timestamp
120
$_SESSION[self::SESSION_KEY][$token] = time();
121
122
// Limit number of stored tokens
123
if
(count($_SESSION[self::SESSION_KEY]) > self::MAX_TOKENS) {
124
// Remove oldest token
125
$oldestToken = array_key_first($_SESSION[self::SESSION_KEY]);
126
unset($_SESSION[self::SESSION_KEY][$oldestToken]);
127
}
128
129
// Log token generation without exposing token material
130
Log::debug
(
'CSRF token generated successfully'
);
131
132
return
$token;
133
}
134
140
public
static
function
getToken
()
141
{
142
self::init
();
143
144
// If no tokens exist, generate one
145
if
(empty($_SESSION[self::SESSION_KEY])) {
146
return
self::generateToken
();
147
}
148
149
// Return the most recent token
150
$tokens = $_SESSION[self::SESSION_KEY];
151
end($tokens);
152
$latestToken = key($tokens);
153
154
// Check if latest token is expired
155
if
(time() - $tokens[$latestToken] > self::TOKEN_EXPIRATION) {
156
// Generate new token if expired
157
return
self::generateToken
();
158
}
159
160
return
$latestToken;
161
}
162
171
public
static
function
validateToken
($token, $removeAfterValidation =
false
)
172
{
173
self::init
();
174
175
// Check if token is provided
176
if
(empty($token) || !is_string($token)) {
177
Log::warning
(
'CSRF validation failed: No token provided'
);
178
179
return
false
;
180
}
181
182
// Check if token exists in session
183
if
(!isset($_SESSION[self::SESSION_KEY][$token])) {
184
Log::warning
(
'CSRF validation failed: Token not found in session'
);
185
186
return
false
;
187
}
188
189
// Check if token is expired
190
$tokenTimestamp = $_SESSION[self::SESSION_KEY][$token];
191
if
(time() - $tokenTimestamp > self::TOKEN_EXPIRATION) {
192
Log::warning
(
'CSRF validation failed: Token expired'
);
193
unset($_SESSION[self::SESSION_KEY][$token]);
194
195
return
false
;
196
}
197
198
// Token is valid
199
Log::debug
(
'CSRF token validated successfully'
);
200
201
// Remove token if one-time use is requested
202
if
($removeAfterValidation) {
203
unset($_SESSION[self::SESSION_KEY][$token]);
204
}
205
206
return
true
;
207
}
208
218
public
static
function
validateRequest
($removeAfterValidation =
false
)
219
{
220
// Reject cross-origin requests before checking the token.
221
// Combined with SameSite=Strict cookies this blocks classic CSRF and
222
// DNS-rebinding attacks.
223
if
(!self::validateOrigin()) {
224
return
false
;
225
}
226
227
// Accept the token from the POST body or a dedicated header only.
228
// Tokens in the query string leak via Referer headers, logs and
229
// browser history.
230
if
(isset($_POST[
'csrf_token'
])) {
231
return
self::validateToken
($_POST[
'csrf_token'
], $removeAfterValidation);
232
}
233
234
// Check custom header (for AJAX requests)
235
$headers =
self::getAllHeaders
();
236
237
// Check for X-CSRF-Token header (case-insensitive)
238
foreach
($headers as $key => $value) {
239
if
(strtolower($key) ===
'x-csrf-token'
) {
240
return
self::validateToken
($value, $removeAfterValidation);
241
}
242
}
243
244
Log::warning
(
'CSRF validation failed: No token in request'
);
245
246
return
false
;
247
}
248
266
private
static
function
validateOrigin
()
267
{
268
$httpHost = isset($_SERVER[
'HTTP_HOST'
]) ? (string)$_SERVER[
'HTTP_HOST'
] :
''
;
269
if
($httpHost ===
''
) {
270
Log::warning
(
'CSRF validation failed: HTTP_HOST not available'
);
271
272
return
false
;
273
}
274
275
$allowedHosts =
self::getAllowedHosts
();
276
277
// The request must be addressed to an intended host. This is the key
278
// defense against DNS rebinding: an attacker's domain, even resolved
279
// to 127.0.0.1, will not be in the allowlist.
280
$requestHost =
self::normalizeHost
($httpHost);
281
if
(!self::isHostAllowed($requestHost, $allowedHosts)) {
282
Log::warning
(
'CSRF validation failed: Request host "'
. $requestHost .
'" is not an allowed host'
);
283
284
return
false
;
285
}
286
287
$scheme = (!empty($_SERVER[
'HTTPS'
]) && strtolower((
string
)$_SERVER[
'HTTPS'
]) !==
'off'
) ?
'https'
:
'http'
;
288
289
$origin = isset($_SERVER[
'HTTP_ORIGIN'
]) ? (string)$_SERVER[
'HTTP_ORIGIN'
] :
''
;
290
if
($origin !==
''
) {
291
return
self::isAllowedOrigin
($origin, $httpHost, $scheme, $allowedHosts);
292
}
293
294
$referer = isset($_SERVER[
'HTTP_REFERER'
]) ? (string)$_SERVER[
'HTTP_REFERER'
] :
''
;
295
if
($referer !==
''
) {
296
return
self::isAllowedOrigin
($referer, $httpHost, $scheme, $allowedHosts);
297
}
298
299
Log::warning
(
'CSRF validation failed: Missing Origin and Referer headers'
);
300
301
return
false
;
302
}
303
311
private
static
function
getAllowedHosts
()
312
{
313
$allowed = array(
'localhost'
,
APP_LOCALHOST
,
'[::1]'
,
'::1'
);
314
315
// The local address this very request was received on. This is the
316
// reachable address (e.g. a LAN IP) clients use when the application
317
// has been put online, so requests addressed to the server's own
318
// reachable addresses must be accepted.
319
if
(isset($_SERVER[
'SERVER_ADDR'
])) {
320
$serverAddr =
self::normalizeHost
((
string
)$_SERVER[
'SERVER_ADDR'
]);
321
if
($serverAddr !==
''
) {
322
$allowed[] = $serverAddr;
323
}
324
}
325
326
$hostname = function_exists(
'gethostname'
) ? @gethostname() :
false
;
327
if
($hostname !==
false
&& $hostname !==
''
) {
328
$allowed[] =
self::normalizeHost
($hostname);
329
}
330
331
global
$bearsamppConfig
;
332
if
(is_object(
$bearsamppConfig
) && method_exists(
$bearsamppConfig
,
'getHostname'
)) {
333
$cfgHostname =
$bearsamppConfig
->getHostname();
334
if
(is_string($cfgHostname) && $cfgHostname !==
''
) {
335
$allowed[] =
self::normalizeHost
($cfgHostname);
336
}
337
}
338
339
global
$bearsamppBins
;
340
if
(is_object(
$bearsamppBins
) && method_exists(
$bearsamppBins
,
'getApache'
)) {
341
try
{
342
$apache =
$bearsamppBins
->getApache();
343
if
(is_object($apache) && method_exists($apache,
'getVhosts'
)) {
344
$vhostNames = array();
345
foreach
($apache->getVhosts() as $vhost) {
346
if
(is_string($vhost) && $vhost !==
''
) {
347
$vhostNames[] = $vhost;
348
}
349
}
350
351
// Resolve the actual host names each vhost config serves.
352
// Relying on the config file names alone would miss ServerAlias
353
// entries and hosts that differ from the file name, which would
354
// wrongly reject legitimate requests when HTTPS (secure Apache
355
// settings) is used.
356
$parsedHosts = array();
357
$vhostsPath =
Path::getVhostsPath
();
358
if
(is_dir($vhostsPath)) {
359
foreach
($vhostNames as $vhost) {
360
$content = @file_get_contents($vhostsPath .
'/'
. $vhost .
'.conf'
);
361
if
($content ===
false
) {
362
// Fall back to the config file name when it cannot be read
363
$parsedHosts[] = $vhost;
364
continue
;
365
}
366
367
$found =
false
;
368
foreach
(array(
'ServerName'
,
'ServerAlias'
) as $directive) {
369
if
(preg_match_all(
'/^\s*'
. $directive .
'\s+([^\s#]+)/mi'
, $content, $matches)) {
370
foreach
($matches[1] as $declaredHost) {
371
$normalized =
self::normalizeHost
($declaredHost);
372
if
($normalized !==
''
) {
373
$parsedHosts[] = $normalized;
374
$found =
true
;
375
}
376
}
377
}
378
}
379
380
// Keep the file name when the config declares no host name
381
if
(!$found) {
382
$parsedHosts[] = $vhost;
383
}
384
}
385
}
386
387
foreach
(array_unique($parsedHosts) as $vhost) {
388
$allowed[] = $vhost;
389
// Vhost certificates cover any subdomain (e.g. www.vhost.local),
390
// so those hosts must be accepted as well.
391
$allowed[] =
'*.'
. $vhost;
392
}
393
}
394
}
catch
(\Throwable $e) {
395
// Host discovery must never break CSRF validation
396
Log::warning
(
'CSRF validation failed: Unable to read virtual hosts: '
. $e->getMessage());
397
}
398
}
399
400
return
array_values(array_unique($allowed));
401
}
402
414
private
static
function
isHostAllowed
($host, array $allowedHosts)
415
{
416
$host =
self::normalizeHost
($host);
417
if
($host ===
''
) {
418
return
false
;
419
}
420
421
if
(in_array($host, $allowedHosts,
true
)) {
422
return
true
;
423
}
424
425
foreach
($allowedHosts as $allowedHost) {
426
if
(strpos($allowedHost,
'*.'
) === 0) {
427
$suffix = substr($allowedHost, 1);
428
if
($suffix !==
''
&& substr($host, -strlen($suffix)) === $suffix) {
429
return
true
;
430
}
431
}
432
}
433
434
return
false
;
435
}
436
445
private
static
function
normalizeHost
($host)
446
{
447
$host = strtolower(trim((
string
)$host));
448
449
// Strip a trailing port (both host:port and [ipv6]:port forms)
450
if
(preg_match(
'/^\[.*\]:\d+$/'
, $host)) {
451
$host = substr($host, 0, strrpos($host,
':'
));
452
} elseif (preg_match(
'/^[^:]+:\d+$/'
, $host)) {
453
$host = substr($host, 0, strrpos($host,
':'
));
454
}
455
456
// Bracket IPv6 loopback so ::1 and [::1] compare equal
457
if
($host ===
'::1'
) {
458
$host =
'[::1]'
;
459
}
460
461
return
$host;
462
}
463
475
private
static
function
isAllowedOrigin
($url, $httpHost, $scheme, array $allowedHosts)
476
{
477
$parts = parse_url($url);
478
if
($parts ===
false
|| empty($parts[
'host'
])) {
479
Log::warning
(
'CSRF validation failed: Unparseable Origin/Referer header'
);
480
481
return
false
;
482
}
483
484
// Reject URLs carrying credentials (userinfo). Real browser Origins
485
// never contain them, so this is purely defense in depth.
486
if
(isset($parts[
'user'
]) || isset($parts[
'pass'
])) {
487
Log::warning
(
'CSRF validation failed: Origin/Referer header must not contain credentials'
);
488
489
return
false
;
490
}
491
492
$originScheme = isset($parts[
'scheme'
]) ? strtolower($parts[
'scheme'
]) :
''
;
493
if
(!in_array($originScheme, array(
'http'
,
'https'
),
true
)) {
494
Log::warning
(
'CSRF validation failed: Unsupported Origin/Referer scheme "'
. $originScheme .
'"'
);
495
496
return
false
;
497
}
498
499
$originHost =
self::normalizeHost
($parts[
'host'
]);
500
if
(!self::isHostAllowed($originHost, $allowedHosts)) {
501
Log::warning
(
'CSRF validation failed: Origin/Referer host "'
. $originHost .
'" is not an allowed host'
);
502
503
return
false
;
504
}
505
506
// The scheme of the origin must match the scheme the request was made over
507
if
($originScheme !== $scheme) {
508
Log::warning
(
'CSRF validation failed: Origin/Referer scheme "'
. $originScheme .
'" does not match request scheme "'
. $scheme .
'"'
);
509
510
return
false
;
511
}
512
513
// The origin host must match the host the request was addressed to
514
$requestHost =
self::normalizeHost
($httpHost);
515
if
($originHost !== $requestHost) {
516
Log::warning
(
'CSRF validation failed: Origin/Referer host "'
. $originHost .
'" does not match request host "'
. $requestHost .
'"'
);
517
518
return
false
;
519
}
520
521
// The effective port of the origin must match the request port
522
if
(self::getOriginPort($parts, $originScheme) !== self::getRequestPort($httpHost, $scheme)) {
523
Log::warning
(
'CSRF validation failed: Origin/Referer port mismatch'
);
524
525
return
false
;
526
}
527
528
return
true
;
529
}
530
540
private
static
function
getOriginPort
(array $parts, $scheme)
541
{
542
if
(isset($parts[
'port'
]) && is_numeric($parts[
'port'
])) {
543
return
(
int
)$parts[
'port'
];
544
}
545
546
return
$scheme ===
'https'
? 443 : 80;
547
}
548
558
private
static
function
getRequestPort
($httpHost, $scheme)
559
{
560
if
(preg_match(
'/:(\d+)$/'
, $httpHost, $matches)) {
561
return
(
int
)$matches[1];
562
}
563
564
return
$scheme ===
'https'
? 443 : 80;
565
}
566
573
private
static
function
getAllHeaders
()
574
{
575
// Use getallheaders() if available (Apache)
576
if
(function_exists(
'getallheaders'
)) {
577
$headers = getallheaders();
578
if
($headers !==
false
) {
579
return
$headers;
580
}
581
}
582
583
// Fallback for FastCGI/CGI environments
584
$headers = [];
585
foreach
($_SERVER as $key => $value) {
586
// Extract HTTP headers from $_SERVER
587
if
(substr($key, 0, 5) ===
'HTTP_'
) {
588
// Convert HTTP_X_CSRF_TOKEN to X-Csrf-Token
589
$headerName = str_replace(
' '
,
'-'
, ucwords(strtolower(str_replace(
'_'
,
' '
, substr($key, 5)))));
590
$headers[$headerName] = $value;
591
}
// Handle CONTENT_TYPE and CONTENT_LENGTH specially
592
elseif (in_array($key, [
'CONTENT_TYPE'
,
'CONTENT_LENGTH'
])) {
593
$headerName = str_replace(
' '
,
'-'
, ucwords(strtolower(str_replace(
'_'
,
' '
, $key))));
594
$headers[$headerName] = $value;
595
}
596
}
597
598
return
$headers;
599
}
600
606
private
static
function
cleanupExpiredTokens
()
607
{
608
if
(!isset($_SESSION[self::SESSION_KEY])) {
609
return
0;
610
}
611
612
$removed = 0;
613
$currentTime = time();
614
615
foreach
($_SESSION[self::SESSION_KEY] as $token => $timestamp) {
616
if
($currentTime - $timestamp > self::TOKEN_EXPIRATION) {
617
unset($_SESSION[self::SESSION_KEY][$token]);
618
$removed++;
619
}
620
}
621
622
if
($removed > 0) {
623
Log::debug
(
"Cleaned up $removed expired CSRF tokens"
);
624
}
625
626
return
$removed;
627
}
628
635
public
static
function
regenerateToken
()
636
{
637
self::init
();
638
639
// Clear all existing tokens
640
$_SESSION[self::SESSION_KEY] = [];
641
642
// Generate new token
643
return
self::generateToken
();
644
}
645
651
public
static
function
getTokenField
()
652
{
653
$token =
self::getToken
();
654
655
return
'<input type="hidden" name="csrf_token" value="'
. htmlspecialchars($token, ENT_QUOTES,
'UTF-8'
) .
'">'
;
656
}
657
664
public
static
function
getTokenMeta
()
665
{
666
$token =
self::getToken
();
667
668
return
'<meta name="csrf-token" content="'
. htmlspecialchars($token, ENT_QUOTES,
'UTF-8'
) .
'">'
;
669
}
670
679
public
static
function
validateOrDie
($removeAfterValidation =
false
)
680
{
681
if
(!self::validateRequest($removeAfterValidation)) {
682
http_response_code(403);
683
header(
'Content-Type: application/json'
);
684
echo json_encode([
685
'error'
=>
'CSRF validation failed'
,
686
'message'
=>
'Invalid or expired security token. Please refresh the page and try again.'
687
]);
688
exit
;
689
}
690
}
691
698
public
static
function
getStats
()
699
{
700
self::init
();
701
702
$tokens = $_SESSION[self::SESSION_KEY] ?? [];
703
$currentTime = time();
704
$expired = 0;
705
$valid = 0;
706
707
foreach
($tokens as $timestamp) {
708
if
($currentTime - $timestamp > self::TOKEN_EXPIRATION) {
709
$expired++;
710
}
else
{
711
$valid++;
712
}
713
}
714
715
return
[
716
'total'
=> count($tokens),
717
'valid'
=> $valid,
718
'expired'
=> $expired,
719
'max_tokens'
=> self::MAX_TOKENS,
720
'expiration_seconds'
=> self::TOKEN_EXPIRATION
721
];
722
}
723
}
724
$bearsamppBins
global $bearsamppBins
Definition
ajax.apache.php:16
exit
exit
Definition
ajax.apply.moduleconfig.php:72
Csrf
Definition
class.csrf.php:35
Csrf\generateToken
static generateToken()
Definition
class.csrf.php:111
Csrf\cleanupExpiredTokens
static cleanupExpiredTokens()
Definition
class.csrf.php:606
Csrf\MAX_TOKENS
const MAX_TOKENS
Definition
class.csrf.php:50
Csrf\TOKEN_EXPIRATION
const TOKEN_EXPIRATION
Definition
class.csrf.php:44
Csrf\regenerateToken
static regenerateToken()
Definition
class.csrf.php:635
Csrf\getOriginPort
static getOriginPort(array $parts, $scheme)
Definition
class.csrf.php:540
Csrf\getTokenField
static getTokenField()
Definition
class.csrf.php:651
Csrf\validateOrigin
static validateOrigin()
Definition
class.csrf.php:266
Csrf\configureSession
static configureSession()
Definition
class.csrf.php:84
Csrf\isHostAllowed
static isHostAllowed($host, array $allowedHosts)
Definition
class.csrf.php:414
Csrf\getStats
static getStats()
Definition
class.csrf.php:698
Csrf\getToken
static getToken()
Definition
class.csrf.php:140
Csrf\getRequestPort
static getRequestPort($httpHost, $scheme)
Definition
class.csrf.php:558
Csrf\validateOrDie
static validateOrDie($removeAfterValidation=false)
Definition
class.csrf.php:679
Csrf\isAllowedOrigin
static isAllowedOrigin($url, $httpHost, $scheme, array $allowedHosts)
Definition
class.csrf.php:475
Csrf\validateRequest
static validateRequest($removeAfterValidation=false)
Definition
class.csrf.php:218
Csrf\getAllHeaders
static getAllHeaders()
Definition
class.csrf.php:573
Csrf\getTokenMeta
static getTokenMeta()
Definition
class.csrf.php:664
Csrf\validateToken
static validateToken($token, $removeAfterValidation=false)
Definition
class.csrf.php:171
Csrf\init
static init()
Definition
class.csrf.php:58
Csrf\getAllowedHosts
static getAllowedHosts()
Definition
class.csrf.php:311
Csrf\SESSION_KEY
const SESSION_KEY
Definition
class.csrf.php:39
Csrf\normalizeHost
static normalizeHost($host)
Definition
class.csrf.php:445
Log\debug
static debug($data, $file=null)
Definition
class.log.php:726
Log\warning
static warning($data, $file=null)
Definition
class.log.php:748
Path\getVhostsPath
static getVhostsPath($aetrayPath=false)
Definition
class.path.php:1168
$bearsamppConfig
global $bearsamppConfig
Definition
homepage.php:46
APP_LOCALHOST
const APP_LOCALHOST
Definition
root.php:21
sandbox
core
classes
class.csrf.php
Generated by
1.17.0