diff --git a/packages/http/src/Session/CsrfTokenComponent.php b/packages/http/src/Session/CsrfTokenComponent.php new file mode 100644 index 000000000..ac7e049f8 --- /dev/null +++ b/packages/http/src/Session/CsrfTokenComponent.php @@ -0,0 +1,29 @@ +session->token}"> + HTML; + } +} diff --git a/packages/http/src/Session/CsrfTokenDidNotMatch.php b/packages/http/src/Session/CsrfTokenDidNotMatch.php new file mode 100644 index 000000000..c71bedda0 --- /dev/null +++ b/packages/http/src/Session/CsrfTokenDidNotMatch.php @@ -0,0 +1,13 @@ +get(self::CSRF_TOKEN_KEY)) { + $this->set(self::CSRF_TOKEN_KEY, Random\uuid()); + } + + return $this->get(self::CSRF_TOKEN_KEY); + } + } + public function __construct( public SessionId $id, public DateTimeInterface $createdAt, diff --git a/packages/http/src/Session/VerifyCsrfMiddleware.php b/packages/http/src/Session/VerifyCsrfMiddleware.php new file mode 100644 index 000000000..971d3952c --- /dev/null +++ b/packages/http/src/Session/VerifyCsrfMiddleware.php @@ -0,0 +1,78 @@ +cookies->add(new Cookie( + key: self::CSRF_COOKIE_KEY, + value: $this->session->token, + expiresAt: $this->clock->now()->plus($this->sessionConfig->expiration), + )); + + if ($this->shouldSkipCheck($request)) { + return $next($request); + } + + $this->ensureTokenMatches($request); + + return $next($request); + } + + private function shouldSkipCheck(Request $request): bool + { + if (in_array($request->method, [Method::GET, Method::HEAD, Method::OPTIONS], strict: true)) { + return true; + } + + if ($this->appConfig->environment->isTesting()) { + return true; + } + + return false; + } + + private function ensureTokenMatches(Request $request): void + { + $tokenFromRequest = $request->get( + key: Session::CSRF_TOKEN_KEY, + default: $request->headers->get(self::CSRF_HEADER_KEY), + ); + + if (! $tokenFromRequest) { + throw new CsrfTokenDidNotMatch(); + } + + if (! hash_equals($this->session->token, $tokenFromRequest)) { + throw new CsrfTokenDidNotMatch(); + } + } +} diff --git a/packages/router/src/Exceptions/HttpExceptionHandler.php b/packages/router/src/Exceptions/HttpExceptionHandler.php index 450d35afb..a4d02bd1e 100644 --- a/packages/router/src/Exceptions/HttpExceptionHandler.php +++ b/packages/router/src/Exceptions/HttpExceptionHandler.php @@ -10,6 +10,7 @@ use Tempest\Http\GenericResponse; use Tempest\Http\HttpRequestFailed; use Tempest\Http\Response; +use Tempest\Http\Session\CsrfTokenDidNotMatch; use Tempest\Http\Status; use Tempest\Router\ResponseSender; use Tempest\Support\Filesystem; @@ -34,6 +35,7 @@ public function handle(Throwable $throwable): void $response = match (true) { $throwable instanceof ConvertsToResponse => $throwable->toResponse(), $throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status, $throwable), + $throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT), default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR), }; @@ -57,6 +59,7 @@ private function renderErrorResponse(Status $status, ?HttpRequestFailed $excepti Status::NOT_FOUND => 'This page could not be found on the server', Status::FORBIDDEN => 'You do not have permission to access this page', Status::UNAUTHORIZED => 'You must be authenticated in to access this page', + Status::UNPROCESSABLE_CONTENT => 'The request could not be processed due to invalid data', default => null, } , diff --git a/tests/Integration/Http/CsrfTest.php b/tests/Integration/Http/CsrfTest.php new file mode 100644 index 000000000..dbdaa3920 --- /dev/null +++ b/tests/Integration/Http/CsrfTest.php @@ -0,0 +1,109 @@ +container->get(AppConfig::class)->environment = Environment::PRODUCTION; + + $token = $this->container->get(Session::class)->get(Session::CSRF_TOKEN_KEY); + + $this->http + ->get('/test') + ->assertHasCookie( + VerifyCsrfMiddleware::CSRF_COOKIE_KEY, + fn (Cookie $cookie) => $cookie->value === $token, // @mago-expect security/no-insecure-comparison + ); + } + + #[TestWith([Method::POST])] + #[TestWith([Method::PUT])] + #[TestWith([Method::PATCH])] + #[TestWith([Method::DELETE])] + public function test_throws_when_missing_in_write_verbs(Method $method): void + { + $this->expectException(CsrfTokenDidNotMatch::class); + + $this->container->get(AppConfig::class)->environment = Environment::PRODUCTION; + $this->http->sendRequest(new GenericRequest($method, uri: '/test')); + } + + #[TestWith([Method::GET])] + #[TestWith([Method::OPTIONS])] + #[TestWith([Method::HEAD])] + public function test_allows_missing_in_read_verbs(Method $method): void + { + $this->container->get(AppConfig::class)->environment = Environment::PRODUCTION; + + $this->http + ->sendRequest(new GenericRequest($method, uri: '/test')) + ->assertOk(); + } + + public function test_throws_when_mismatch_from_body(): void + { + $this->expectException(CsrfTokenDidNotMatch::class); + + $this->container->get(AppConfig::class)->environment = Environment::PRODUCTION; + $this->container->get(Session::class)->set(Session::CSRF_TOKEN_KEY, 'abc'); + + $this->http->post('/test', [Session::CSRF_TOKEN_KEY => 'def']); + } + + public function test_throws_when_mismatch_from_header(): void + { + $this->expectException(CsrfTokenDidNotMatch::class); + + $this->container->get(AppConfig::class)->environment = Environment::PRODUCTION; + $this->container->get(Session::class)->set(Session::CSRF_TOKEN_KEY, 'abc'); + + $this->http->post('/test', [Session::CSRF_TOKEN_KEY => 'def']); + } + + public function test_matches_from_body(): void + { + $this->container->get(AppConfig::class)->environment = Environment::PRODUCTION; + + $session = $this->container->get(Session::class); + + $this->http + ->post('/test', [Session::CSRF_TOKEN_KEY => $session->token]) + ->assertOk(); + } + + public function test_matches_from_header(): void + { + $this->container->get(AppConfig::class)->environment = Environment::PRODUCTION; + + $session = $this->container->get(Session::class); + + $this->http + ->post('/test', headers: [VerifyCsrfMiddleware::CSRF_HEADER_KEY => $session->token]) + ->assertOk(); + } + + public function test_csrf_component(): void + { + $rendered = $this->render(<<
+ HTML); + + $session = $this->container->get(Session::class); + + $this->assertStringMatchesFormat('', $rendered); + $this->assertStringContainsString($session->token, $rendered); + } +} diff --git a/tests/Integration/Http/HttpExceptionHandlerTest.php b/tests/Integration/Http/HttpExceptionHandlerTest.php index 6d30e44d1..9cef50484 100644 --- a/tests/Integration/Http/HttpExceptionHandlerTest.php +++ b/tests/Integration/Http/HttpExceptionHandlerTest.php @@ -12,6 +12,7 @@ use Tempest\Http\HttpRequestFailed; use Tempest\Http\Response; use Tempest\Http\Responses\Redirect; +use Tempest\Http\Session\CsrfTokenDidNotMatch; use Tempest\Http\Status; use Tempest\Router\Exceptions\HttpExceptionHandler; use Tempest\Router\Exceptions\RouteBindingFailed; @@ -120,7 +121,7 @@ public function test_exception_handler_returns_500_by_default(): void #[TestWith([Status::NOT_FOUND])] #[TestWith([Status::FORBIDDEN])] #[TestWith([Status::METHOD_NOT_ALLOWED])] - public function test_exception_handler_returns_sane_code_as_http_exception(Status $status): void + public function test_exception_handler_returns_same_code_as_http_exception(Status $status): void { $this->callExceptionHandler(function () use ($status): void { $handler = $this->container->get(HttpExceptionHandler::class); @@ -149,6 +150,16 @@ public function test_exception_handler_runs_exception_processors(): void NullExceptionProcessor::$exceptions = []; } + public function test_exception_handler_returns_unprocessable_for_csrf_mismatch(): void + { + $this->callExceptionHandler(function (): void { + $handler = $this->container->get(HttpExceptionHandler::class); + $handler->handle(new CsrfTokenDidNotMatch()); + }); + + $this->assertSame(Status::UNPROCESSABLE_CONTENT, $this->response->status); + } + private function callExceptionHandler(Closure $callback): void { try {