diff --git a/formwork/routes.php b/formwork/routes.php index b9b745012..0e60744aa 100644 --- a/formwork/routes.php +++ b/formwork/routes.php @@ -1,17 +1,22 @@ [ 'index' => [ - 'path' => '/', - 'action' => 'Formwork\Controllers\PageController@load', + 'path' => '/', + 'action' => 'Formwork\Controllers\PageController@load', + 'methods' => ['GET', 'POST'], ], 'index.pagination' => [ 'path' => '/page/{paginationPage:num}/', @@ -34,12 +39,50 @@ 'action' => 'Formwork\Controllers\PageController@load', ], 'page' => [ - 'path' => '/{page}/', - 'action' => 'Formwork\Controllers\PageController@load', + 'path' => '/{page}/', + 'action' => 'Formwork\Controllers\PageController@load', + 'methods' => ['GET', 'POST'], ], ], 'filters' => [ + 'request.validateSize' => [ + 'action' => static function (Config $config, Request $request, Router $router, ErrorHandlers $errorHandlers) { + if ($config->get('system.panel.enabled') && $router->requestHasPrefix($config->get('system.panel.root'))) { + return; + } + + // Validate HTTP request Content-Length according to `post_max_size` directive + if ($request->contentLength() !== null) { + $maxSize = FileSystem::shorthandToBytes(ini_get('post_max_size') ?: '0'); + + if ($request->contentLength() > $maxSize && $maxSize > 0) { + $errorHandlers->displayErrorPage(ResponseStatus::PayloadTooLarge); + } + } + }, + 'methods' => ['POST'], + 'types' => ['HTTP', 'XHR'], + ], + + 'request.validateCsrf' => [ + 'action' => static function (Config $config, Request $request, Router $router, CsrfToken $csrfToken, ErrorHandlers $errorHandlers) { + if ($config->get('system.panel.enabled') && $router->requestHasPrefix($config->get('system.panel.root'))) { + return; + } + + $tokenName = (string) $request->input()->get('csrf-token-name', 'site'); + $token = (string) $request->input()->get('csrf-token'); + + if (!($csrfToken->validate($tokenName, $token))) { + $csrfToken->destroy($tokenName); + $errorHandlers->displayErrorPage(ResponseStatus::Forbidden); + } + }, + 'methods' => ['POST'], + 'types' => ['HTTP', 'XHR'], + ], + 'language' => [ 'action' => function (Config $config, Request $request, Router $router, Languages $languages) { if (($requested = $languages->requested()) !== null) { diff --git a/formwork/src/App.php b/formwork/src/App.php index 6f3ed6e47..b94e424aa 100644 --- a/formwork/src/App.php +++ b/formwork/src/App.php @@ -159,7 +159,8 @@ protected function loadServices(Container $container): void ->loader(ErrorHandlersServiceLoader::class) ->lazy(!$this->config()->get('system.errors.setHandlers', true)); - $container->define(CsrfToken::class); + $container->define(CsrfToken::class) + ->alias('csrfToken'); $container->define(Router::class) ->alias('router'); diff --git a/formwork/src/Pages/Templates/Template.php b/formwork/src/Pages/Templates/Template.php index 86f18f081..725f33c32 100644 --- a/formwork/src/Pages/Templates/Template.php +++ b/formwork/src/Pages/Templates/Template.php @@ -5,7 +5,6 @@ use Closure; use Formwork\App; use Formwork\Assets; -use Formwork\Pages\Page; use Formwork\Pages\Site; use Formwork\Utils\Constraint; use Formwork\Utils\FileSystem; @@ -98,8 +97,9 @@ public function render(array $vars = []): string protected function defaultVars(): array { return [ - 'router' => $this->app->router(), - 'site' => $this->site, + 'router' => $this->app->router(), + 'site' => $this->site, + 'csrfToken' => $this->app->getService('csrfToken'), ]; } diff --git a/formwork/src/Panel/Controllers/AbstractController.php b/formwork/src/Panel/Controllers/AbstractController.php index d714eaad9..fb3f1131c 100644 --- a/formwork/src/Panel/Controllers/AbstractController.php +++ b/formwork/src/Panel/Controllers/AbstractController.php @@ -102,7 +102,7 @@ protected function defaults(): array 'location' => $this->name, 'site' => $this->site(), 'panel' => $this->panel(), - 'csrfToken' => $this->csrfToken->get(), + 'csrfToken' => $this->csrfToken->get($this->panel()->getCsrfTokenName()), 'modals' => $this->modals(), 'colorScheme' => $this->getColorScheme(), 'navigation' => [ diff --git a/formwork/src/Panel/Controllers/AuthenticationController.php b/formwork/src/Panel/Controllers/AuthenticationController.php index f4215afc0..808f84163 100644 --- a/formwork/src/Panel/Controllers/AuthenticationController.php +++ b/formwork/src/Panel/Controllers/AuthenticationController.php @@ -20,9 +20,11 @@ class AuthenticationController extends AbstractController */ public function login(Request $request, CsrfToken $csrfToken, AccessLimiter $accessLimiter): Response { + $csrfTokenName = $this->panel()->getCsrfTokenName(); + if ($accessLimiter->hasReachedLimit()) { $minutes = round($this->config->get('system.panel.loginResetTime') / 60); - $csrfToken->generate(); + $csrfToken->generate($csrfTokenName); return $this->error($this->translate('panel.login.attempt.tooMany', $minutes)); } @@ -33,7 +35,7 @@ public function login(Request $request, CsrfToken $csrfToken, AccessLimiter $acc } // Always generate a new CSRF token - $csrfToken->generate(); + $csrfToken->generate($csrfTokenName); return new Response($this->view('authentication.login', [ 'title' => $this->translate('panel.login.login'), @@ -47,7 +49,7 @@ public function login(Request $request, CsrfToken $csrfToken, AccessLimiter $acc // Ensure no required data is missing if (!$data->hasMultiple(['username', 'password'])) { - $csrfToken->generate(); + $csrfToken->generate($csrfTokenName); $this->error($this->translate('panel.login.attempt.failed')); } @@ -61,7 +63,7 @@ public function login(Request $request, CsrfToken $csrfToken, AccessLimiter $acc $request->session()->set('FORMWORK_USERNAME', $data->get('username')); // Regenerate CSRF token - $csrfToken->generate(); + $csrfToken->generate($csrfTokenName); $accessLog = new Log(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'access.json')); $lastAccessRegistry = new Registry(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'lastAccess.json')); @@ -79,7 +81,7 @@ public function login(Request $request, CsrfToken $csrfToken, AccessLimiter $acc return $this->redirect($this->generateRoute('panel.index')); } - $csrfToken->generate(); + $csrfToken->generate($csrfTokenName); return $this->error($this->translate('panel.login.attempt.failed'), [ 'username' => $data->get('username'), 'error' => true, @@ -94,7 +96,7 @@ public function login(Request $request, CsrfToken $csrfToken, AccessLimiter $acc */ public function logout(Request $request, CsrfToken $csrfToken): RedirectResponse { - $csrfToken->destroy(); + $csrfToken->destroy($this->panel()->getCsrfTokenName()); $request->session()->remove('FORMWORK_USERNAME'); $request->session()->destroy(); diff --git a/formwork/src/Panel/Controllers/RegisterController.php b/formwork/src/Panel/Controllers/RegisterController.php index 354014c22..c176cf089 100644 --- a/formwork/src/Panel/Controllers/RegisterController.php +++ b/formwork/src/Panel/Controllers/RegisterController.php @@ -26,7 +26,7 @@ public function register(Request $request, Schemes $schemes, CsrfToken $csrfToke return $this->redirectToReferer(); } - $csrfToken->generate(); + $csrfToken->generate($this->panel()->getCsrfTokenName()); $fields = $schemes->get('forms.register')->fields(); diff --git a/formwork/src/Panel/Panel.php b/formwork/src/Panel/Panel.php index 25d613270..1bae01b68 100644 --- a/formwork/src/Panel/Panel.php +++ b/formwork/src/Panel/Panel.php @@ -15,6 +15,8 @@ final class Panel { + protected const CSRF_TOKEN_NAME = 'panel'; + /** * Assets instance */ @@ -178,4 +180,12 @@ public function availableTranslations(): array return $translations; } + + /** + * Get panel CSRF token name + */ + public function getCsrfTokenName(): string + { + return self::CSRF_TOKEN_NAME; + } } diff --git a/formwork/src/Router/Router.php b/formwork/src/Router/Router.php index fb7292251..b3374a327 100644 --- a/formwork/src/Router/Router.php +++ b/formwork/src/Router/Router.php @@ -88,6 +88,14 @@ public function request(): string return $this->requestUri; } + /** + * Check if the request has the given prefix + */ + public function requestHasPrefix(string $prefix): bool + { + return $this->matchPrefix($prefix); + } + public function setRequest(string $request): void { $requestPath = Uri::path($request) ?? throw new UnexpectedValueException('Cannot get request path'); diff --git a/formwork/src/Security/CsrfToken.php b/formwork/src/Security/CsrfToken.php index 210f5b549..81615e226 100644 --- a/formwork/src/Security/CsrfToken.php +++ b/formwork/src/Security/CsrfToken.php @@ -9,7 +9,7 @@ class CsrfToken /** * Session key to store the CSRF token */ - protected const SESSION_KEY = 'CSRF_TOKEN'; + protected const SESSION_KEY_PREFIX = '_formwork_csrf_tokens'; public function __construct(protected Request $request) { @@ -18,34 +18,45 @@ public function __construct(protected Request $request) /** * Generate a new CSRF token */ - public function generate(): string + public function generate(string $name): string { $token = base64_encode(random_bytes(36)); - $this->request->session()->set(self::SESSION_KEY, $token); + $this->request->session()->set(self::SESSION_KEY_PREFIX . '.' . $name, $token); return $token; } /** - * Get current CSRF token + * Check if CSRF token exists */ - public function get(): ?string + public function has(string $name): bool { - return $this->request->session()->get(self::SESSION_KEY); + return $this->request->session()->has(self::SESSION_KEY_PREFIX . '.' . $name); + } + + /** + * Get CSRF token by name + */ + public function get(string $name, bool $autoGenerate = false): ?string + { + if ($autoGenerate && !$this->has($name)) { + return $this->generate($name); + } + return $this->request->session()->get(self::SESSION_KEY_PREFIX . '.' . $name); } /** * Check if given CSRF token is valid */ - public function validate(string $token): bool + public function validate(string $name, string $token): bool { - return ($storedToken = $this->get()) && hash_equals($token, $storedToken); + return ($storedToken = $this->get($name)) && hash_equals($token, $storedToken); } /** * Remove CSRF token from session data */ - public function destroy(): void + public function destroy(string $name): void { - $this->request->session()->remove(self::SESSION_KEY); + $this->request->session()->remove(self::SESSION_KEY_PREFIX . '.' . $name); } } diff --git a/panel/routes.php b/panel/routes.php index a2e4ed41f..809d3ede8 100644 --- a/panel/routes.php +++ b/panel/routes.php @@ -223,7 +223,7 @@ ], 'filters' => [ - 'request.validateSize' => [ + 'panel.request.validateSize' => [ 'action' => static function (Request $request, Translations $translations, Panel $panel) { // Validate HTTP request Content-Length according to `post_max_size` directive if ($request->contentLength() !== null) { @@ -239,14 +239,16 @@ } }, 'methods' => ['POST'], + 'types' => ['HTTP', 'XHR'], ], - 'request.validateCsrf' => [ + 'panel.request.validateCsrf' => [ 'action' => static function (Request $request, Translations $translations, Panel $panel, CsrfToken $csrfToken) { - $token = $request->input()->get('csrf-token'); + $tokenName = $panel->getCsrfTokenName(); + $token = (string) $request->input()->get('csrf-token'); - if (!($token !== null && $csrfToken->validate($token))) { - $csrfToken->destroy(); + if (!$csrfToken->validate($tokenName, $token)) { + $csrfToken->destroy($tokenName); $request->session()->remove('FORMWORK_USERNAME'); $panel->notify(