Bearsampp 2026.9.23
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
34class 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
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
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
global $bearsamppBins
static generateToken()
static cleanupExpiredTokens()
const MAX_TOKENS
const TOKEN_EXPIRATION
static regenerateToken()
static getOriginPort(array $parts, $scheme)
static getTokenField()
static validateOrigin()
static configureSession()
static isHostAllowed($host, array $allowedHosts)
static getStats()
static getToken()
static getRequestPort($httpHost, $scheme)
static validateOrDie($removeAfterValidation=false)
static isAllowedOrigin($url, $httpHost, $scheme, array $allowedHosts)
static validateRequest($removeAfterValidation=false)
static getAllHeaders()
static getTokenMeta()
static validateToken($token, $removeAfterValidation=false)
static init()
static getAllowedHosts()
const SESSION_KEY
static normalizeHost($host)
static debug($data, $file=null)
static warning($data, $file=null)
static getVhostsPath($aetrayPath=false)
global $bearsamppConfig
Definition homepage.php:46
const APP_LOCALHOST
Definition root.php:21