Skip to content

Commit 809f13c

Browse files
committed
Merge branch 'release/0.9.51'
2 parents 0a598d5 + 92f165f commit 809f13c

10 files changed

Lines changed: 452 additions & 1 deletion

File tree

.version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"strategy": "semver",
33
"major": 0,
44
"minor": 9,
5-
"patch": 50,
5+
"patch": 51,
66
"build": 0
77
}

src/Bootstrap.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
use Neuron\Data\Settings\SettingManagerFactory;
1313
use Neuron\Patterns\Registry;
1414

15+
// Load framework-level global view helpers (csrf_token, csrf_field, ...).
16+
require_once __DIR__ . '/helpers.php';
17+
1518
/**
1619
* Initialize the application.
1720
*

src/Mvc/Application.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,9 @@ protected function loadRoutes(): void
461461
// Configure rate limiting if enabled
462462
$this->configureRateLimit();
463463

464+
// Configure framework-level CSRF protection (enabled by default)
465+
$this->configureCsrf();
466+
464467
$this->configure404Route();
465468

466469
// Load routing configuration (rewrites, controller paths)
@@ -717,6 +720,48 @@ protected function configureRateLimit(): void
717720
}
718721
}
719722

723+
/**
724+
* Register framework-level CSRF protection.
725+
*
726+
* Registers the 'csrf' route filter so any route can opt in via
727+
* `filters: ['csrf']`, and seeds the current token into the registry so
728+
* the csrf_field() / csrf_token() view helpers work with no per-app wiring.
729+
*
730+
* Enabled by default; disable by setting `security.csrf` to false.
731+
*
732+
* @return void
733+
*/
734+
protected function configureCsrf(): void
735+
{
736+
$source = $this->getSettingManager()?->getSource();
737+
$enabled = $source?->get( 'security', 'csrf' );
738+
739+
// Default on: only skip when explicitly disabled.
740+
if( in_array( $enabled, [ false, 0, '0', 'false', 'off', 'no' ], true ) )
741+
{
742+
return;
743+
}
744+
745+
try
746+
{
747+
$csrfToken = new \Neuron\Mvc\Security\CsrfToken( new \Neuron\Core\System\RealSession() );
748+
$this->_router->registerFilter( 'csrf', new \Neuron\Mvc\Security\CsrfFilter( $csrfToken ) );
749+
750+
// Seed the token for views. Avoid starting a session in CLI/test
751+
// contexts or once output has begun.
752+
if( PHP_SAPI !== 'cli' && !headers_sent() )
753+
{
754+
Registry::getInstance()->set( RegistryKeys::AUTH_CSRF_TOKEN, $csrfToken->getToken() );
755+
}
756+
757+
Log::debug( 'CSRF protection configured' );
758+
}
759+
catch( \Exception $e )
760+
{
761+
Log::warning( 'Failed to configure CSRF: ' . $e->getMessage() );
762+
}
763+
}
764+
720765
/**
721766
* Clear expired cache entries
722767
*
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Neuron\Mvc\Exceptions;
4+
5+
use RuntimeException;
6+
7+
/**
8+
* Exception thrown when CSRF token validation fails.
9+
*
10+
* Carries HTTP status 403. Applications/middleware should catch this and
11+
* return a Forbidden response.
12+
*
13+
* @package Neuron\Mvc\Exceptions
14+
*/
15+
class CsrfValidationException extends RuntimeException
16+
{
17+
private string $_userMessage;
18+
19+
/**
20+
* @param string $message Technical message for logging
21+
* @param string $userMessage User-friendly message to display
22+
*/
23+
public function __construct( string $message, string $userMessage = 'CSRF token validation failed' )
24+
{
25+
parent::__construct( $message, 403 );
26+
$this->_userMessage = $userMessage;
27+
}
28+
29+
/**
30+
* Get user-friendly message suitable for display.
31+
*/
32+
public function getUserMessage(): string
33+
{
34+
return $this->_userMessage;
35+
}
36+
}

src/Mvc/Security/CsrfFilter.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Neuron\Mvc\Security;
4+
5+
use Neuron\Routing\Filter;
6+
use Neuron\Routing\RouteMap;
7+
use Neuron\Mvc\Exceptions\CsrfValidationException;
8+
use Neuron\Log\Log;
9+
10+
/**
11+
* Framework-level CSRF protection filter.
12+
*
13+
* Validates CSRF tokens on state-changing requests (POST, PUT, DELETE, PATCH)
14+
* to prevent Cross-Site Request Forgery. Register as the 'csrf' route filter
15+
* and apply via `filters: ['csrf']` on unsafe-method routes.
16+
*
17+
* @package Neuron\Mvc\Security
18+
*/
19+
class CsrfFilter extends Filter
20+
{
21+
private CsrfToken $_csrfToken;
22+
23+
/** @var list<string> */
24+
private array $_exemptMethods = [ 'GET', 'HEAD', 'OPTIONS' ];
25+
26+
public function __construct( CsrfToken $csrfToken )
27+
{
28+
$this->_csrfToken = $csrfToken;
29+
30+
parent::__construct(
31+
function( RouteMap $route ) { $this->validateCsrfToken( $route ); },
32+
null
33+
);
34+
}
35+
36+
/**
37+
* Validate the request's CSRF token.
38+
*
39+
* @throws CsrfValidationException When the token is missing or invalid
40+
*/
41+
protected function validateCsrfToken( RouteMap $route ): void
42+
{
43+
$method = $_SERVER[ 'REQUEST_METHOD' ] ?? 'GET';
44+
45+
if( in_array( strtoupper( $method ), $this->_exemptMethods, true ) )
46+
{
47+
return;
48+
}
49+
50+
$token = $this->getTokenFromRequest();
51+
52+
if( !$token )
53+
{
54+
Log::warning( 'CSRF token missing from request' );
55+
throw new CsrfValidationException(
56+
'CSRF token missing from request',
57+
'CSRF token missing'
58+
);
59+
}
60+
61+
if( !$this->_csrfToken->validate( $token ) )
62+
{
63+
Log::warning( 'Invalid CSRF token' );
64+
throw new CsrfValidationException(
65+
'Invalid CSRF token provided',
66+
'Invalid CSRF token'
67+
);
68+
}
69+
}
70+
71+
/**
72+
* Extract the CSRF token from the POST body or X-CSRF-Token header.
73+
*/
74+
private function getTokenFromRequest(): ?string
75+
{
76+
$token = \Neuron\Data\Filters\Post::filterScalar( 'csrf_token' );
77+
78+
if( $token )
79+
{
80+
return $token;
81+
}
82+
83+
if( isset( $_SERVER[ 'HTTP_X_CSRF_TOKEN' ] ) )
84+
{
85+
return $_SERVER[ 'HTTP_X_CSRF_TOKEN' ];
86+
}
87+
88+
return null;
89+
}
90+
}

src/Mvc/Security/CsrfToken.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
namespace Neuron\Mvc\Security;
4+
5+
use Neuron\Core\System\ISession;
6+
use Neuron\Core\System\RealSession;
7+
use Neuron\Core\System\IRandom;
8+
use Neuron\Core\System\RealRandom;
9+
10+
/**
11+
* Framework-level CSRF token service.
12+
*
13+
* Generates and validates single-use CSRF tokens backed by the core
14+
* {@see ISession} abstraction (defaulting to a real PHP session). The session
15+
* key ('csrf_token') matches the CMS implementation so both stay compatible.
16+
*
17+
* @package Neuron\Mvc\Security
18+
*/
19+
class CsrfToken
20+
{
21+
private ISession $_session;
22+
private IRandom $_random;
23+
private string $_tokenKey = 'csrf_token';
24+
25+
/**
26+
* @param ISession|null $session Session abstraction (defaults to RealSession)
27+
* @param IRandom|null $random Random source (defaults to RealRandom)
28+
*/
29+
public function __construct( ?ISession $session = null, ?IRandom $random = null )
30+
{
31+
$this->_session = $session ?? new RealSession();
32+
$this->_random = $random ?? new RealRandom();
33+
}
34+
35+
/**
36+
* Generate and store a new CSRF token.
37+
*/
38+
public function generate(): string
39+
{
40+
$token = $this->_random->string( 64, 'hex' );
41+
$this->_session->set( $this->_tokenKey, $token );
42+
43+
return $token;
44+
}
45+
46+
/**
47+
* Get the current CSRF token, generating one if absent.
48+
*/
49+
public function getToken(): string
50+
{
51+
if( !$this->_session->has( $this->_tokenKey ) )
52+
{
53+
return $this->generate();
54+
}
55+
56+
return $this->_session->get( $this->_tokenKey );
57+
}
58+
59+
/**
60+
* Validate a CSRF token. Valid tokens are consumed (single-use).
61+
*/
62+
public function validate( string $token ): bool
63+
{
64+
$storedToken = $this->_session->get( $this->_tokenKey );
65+
66+
if( !$storedToken )
67+
{
68+
return false;
69+
}
70+
71+
$isValid = hash_equals( $storedToken, $token );
72+
73+
if( $isValid )
74+
{
75+
$this->_session->remove( $this->_tokenKey );
76+
}
77+
78+
return $isValid;
79+
}
80+
81+
/**
82+
* Regenerate the CSRF token.
83+
*/
84+
public function regenerate(): string
85+
{
86+
return $this->generate();
87+
}
88+
}

src/helpers.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/**
4+
* Framework-level view helpers for Neuron MVC.
5+
*
6+
* These are declared in the global namespace and guarded with function_exists()
7+
* so they coexist with the equivalent CMS helpers when both packages are loaded.
8+
*/
9+
10+
use Neuron\Core\Registry\RegistryKeys;
11+
use Neuron\Patterns\Registry;
12+
13+
if( !function_exists( 'csrf_token' ) )
14+
{
15+
/**
16+
* Get the current CSRF token seeded into the registry by the framework.
17+
*
18+
* @return string
19+
*/
20+
function csrf_token(): string
21+
{
22+
$token = Registry::getInstance()->get( RegistryKeys::AUTH_CSRF_TOKEN );
23+
24+
return is_string( $token ) ? $token : '';
25+
}
26+
}
27+
28+
if( !function_exists( 'csrf_field' ) )
29+
{
30+
/**
31+
* Render a hidden CSRF token input for use in HTML forms.
32+
*
33+
* @return string
34+
*/
35+
function csrf_field(): string
36+
{
37+
$token = csrf_token();
38+
39+
return '<input type="hidden" name="csrf_token" value="'
40+
. htmlspecialchars( $token, ENT_QUOTES )
41+
. '">';
42+
}
43+
}

0 commit comments

Comments
 (0)