Skip to content

Commit 2bb4fcf

Browse files
authored
feat(http)!: add cross-site request forgery protection (#1411)
1 parent 3d20766 commit 2bb4fcf

File tree

7 files changed

+260
-1
lines changed

7 files changed

+260
-1
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Session;
6+
7+
use Tempest\View\Elements\ViewComponentElement;
8+
use Tempest\View\ViewComponent;
9+
10+
final readonly class CsrfTokenComponent implements ViewComponent
11+
{
12+
public function __construct(
13+
private Session $session,
14+
) {}
15+
16+
public static function getName(): string
17+
{
18+
return 'x-csrf-token';
19+
}
20+
21+
public function compile(ViewComponentElement $element): string
22+
{
23+
$name = Session::CSRF_TOKEN_KEY;
24+
25+
return <<<HTML
26+
<input type="hidden" name="{$name}" value="{$this->session->token}">
27+
HTML;
28+
}
29+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Tempest\Http\Session;
4+
5+
use Exception;
6+
7+
final class CsrfTokenDidNotMatch extends Exception
8+
{
9+
public function __construct()
10+
{
11+
parent::__construct('The CSRF token did not match.');
12+
}
13+
}

packages/http/src/Session/Session.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tempest\Http\Session;
66

77
use Tempest\DateTime\DateTimeInterface;
8+
use Tempest\Support\Random;
89

910
use function Tempest\get;
1011

@@ -16,8 +17,23 @@ final class Session
1617

1718
public const string PREVIOUS_URL = '_previous_url';
1819

20+
public const string CSRF_TOKEN_KEY = '_csrf_token';
21+
1922
private array $expiredKeys = [];
2023

24+
/**
25+
* Session token used for cross-site request forgery protection.
26+
*/
27+
public string $token {
28+
get {
29+
if (! $this->get(self::CSRF_TOKEN_KEY)) {
30+
$this->set(self::CSRF_TOKEN_KEY, Random\uuid());
31+
}
32+
33+
return $this->get(self::CSRF_TOKEN_KEY);
34+
}
35+
}
36+
2137
public function __construct(
2238
public SessionId $id,
2339
public DateTimeInterface $createdAt,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Session;
6+
7+
use Tempest\Clock\Clock;
8+
use Tempest\Core\AppConfig;
9+
use Tempest\Core\Priority;
10+
use Tempest\Http\Cookie\Cookie;
11+
use Tempest\Http\Cookie\CookieManager;
12+
use Tempest\Http\Method;
13+
use Tempest\Http\Request;
14+
use Tempest\Http\Response;
15+
use Tempest\Http\Session\Session;
16+
use Tempest\Router\HttpMiddleware;
17+
use Tempest\Router\HttpMiddlewareCallable;
18+
19+
#[Priority(Priority::FRAMEWORK)]
20+
final readonly class VerifyCsrfMiddleware implements HttpMiddleware
21+
{
22+
public const string CSRF_COOKIE_KEY = 'xsrf-token';
23+
public const string CSRF_HEADER_KEY = 'x-xsrf-token';
24+
25+
public function __construct(
26+
private Session $session,
27+
private AppConfig $appConfig,
28+
private SessionConfig $sessionConfig,
29+
private CookieManager $cookies,
30+
private Clock $clock,
31+
) {}
32+
33+
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
34+
{
35+
$this->cookies->add(new Cookie(
36+
key: self::CSRF_COOKIE_KEY,
37+
value: $this->session->token,
38+
expiresAt: $this->clock->now()->plus($this->sessionConfig->expiration),
39+
));
40+
41+
if ($this->shouldSkipCheck($request)) {
42+
return $next($request);
43+
}
44+
45+
$this->ensureTokenMatches($request);
46+
47+
return $next($request);
48+
}
49+
50+
private function shouldSkipCheck(Request $request): bool
51+
{
52+
if (in_array($request->method, [Method::GET, Method::HEAD, Method::OPTIONS], strict: true)) {
53+
return true;
54+
}
55+
56+
if ($this->appConfig->environment->isTesting()) {
57+
return true;
58+
}
59+
60+
return false;
61+
}
62+
63+
private function ensureTokenMatches(Request $request): void
64+
{
65+
$tokenFromRequest = $request->get(
66+
key: Session::CSRF_TOKEN_KEY,
67+
default: $request->headers->get(self::CSRF_HEADER_KEY),
68+
);
69+
70+
if (! $tokenFromRequest) {
71+
throw new CsrfTokenDidNotMatch();
72+
}
73+
74+
if (! hash_equals($this->session->token, $tokenFromRequest)) {
75+
throw new CsrfTokenDidNotMatch();
76+
}
77+
}
78+
}

packages/router/src/Exceptions/HttpExceptionHandler.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Tempest\Http\GenericResponse;
1111
use Tempest\Http\HttpRequestFailed;
1212
use Tempest\Http\Response;
13+
use Tempest\Http\Session\CsrfTokenDidNotMatch;
1314
use Tempest\Http\Status;
1415
use Tempest\Router\ResponseSender;
1516
use Tempest\Support\Filesystem;
@@ -34,6 +35,7 @@ public function handle(Throwable $throwable): void
3435
$response = match (true) {
3536
$throwable instanceof ConvertsToResponse => $throwable->toResponse(),
3637
$throwable instanceof HttpRequestFailed => $this->renderErrorResponse($throwable->status, $throwable),
38+
$throwable instanceof CsrfTokenDidNotMatch => $this->renderErrorResponse(Status::UNPROCESSABLE_CONTENT),
3739
default => $this->renderErrorResponse(Status::INTERNAL_SERVER_ERROR),
3840
};
3941

@@ -57,6 +59,7 @@ private function renderErrorResponse(Status $status, ?HttpRequestFailed $excepti
5759
Status::NOT_FOUND => 'This page could not be found on the server',
5860
Status::FORBIDDEN => 'You do not have permission to access this page',
5961
Status::UNAUTHORIZED => 'You must be authenticated in to access this page',
62+
Status::UNPROCESSABLE_CONTENT => 'The request could not be processed due to invalid data',
6063
default => null,
6164
}
6265
,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Http;
4+
5+
use PHPUnit\Framework\Attributes\TestWith;
6+
use Tempest\Core\AppConfig;
7+
use Tempest\Core\Environment;
8+
use Tempest\Http\Cookie\Cookie;
9+
use Tempest\Http\GenericRequest;
10+
use Tempest\Http\Method;
11+
use Tempest\Http\Session\CsrfTokenDidNotMatch;
12+
use Tempest\Http\Session\Session;
13+
use Tempest\Http\Session\VerifyCsrfMiddleware;
14+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
15+
16+
final class CsrfTest extends FrameworkIntegrationTestCase
17+
{
18+
public function test_csrf_is_sent_as_cookie(): void
19+
{
20+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
21+
22+
$token = $this->container->get(Session::class)->get(Session::CSRF_TOKEN_KEY);
23+
24+
$this->http
25+
->get('/test')
26+
->assertHasCookie(
27+
VerifyCsrfMiddleware::CSRF_COOKIE_KEY,
28+
fn (Cookie $cookie) => $cookie->value === $token, // @mago-expect security/no-insecure-comparison
29+
);
30+
}
31+
32+
#[TestWith([Method::POST])]
33+
#[TestWith([Method::PUT])]
34+
#[TestWith([Method::PATCH])]
35+
#[TestWith([Method::DELETE])]
36+
public function test_throws_when_missing_in_write_verbs(Method $method): void
37+
{
38+
$this->expectException(CsrfTokenDidNotMatch::class);
39+
40+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
41+
$this->http->sendRequest(new GenericRequest($method, uri: '/test'));
42+
}
43+
44+
#[TestWith([Method::GET])]
45+
#[TestWith([Method::OPTIONS])]
46+
#[TestWith([Method::HEAD])]
47+
public function test_allows_missing_in_read_verbs(Method $method): void
48+
{
49+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
50+
51+
$this->http
52+
->sendRequest(new GenericRequest($method, uri: '/test'))
53+
->assertOk();
54+
}
55+
56+
public function test_throws_when_mismatch_from_body(): void
57+
{
58+
$this->expectException(CsrfTokenDidNotMatch::class);
59+
60+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
61+
$this->container->get(Session::class)->set(Session::CSRF_TOKEN_KEY, 'abc');
62+
63+
$this->http->post('/test', [Session::CSRF_TOKEN_KEY => 'def']);
64+
}
65+
66+
public function test_throws_when_mismatch_from_header(): void
67+
{
68+
$this->expectException(CsrfTokenDidNotMatch::class);
69+
70+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
71+
$this->container->get(Session::class)->set(Session::CSRF_TOKEN_KEY, 'abc');
72+
73+
$this->http->post('/test', [Session::CSRF_TOKEN_KEY => 'def']);
74+
}
75+
76+
public function test_matches_from_body(): void
77+
{
78+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
79+
80+
$session = $this->container->get(Session::class);
81+
82+
$this->http
83+
->post('/test', [Session::CSRF_TOKEN_KEY => $session->token])
84+
->assertOk();
85+
}
86+
87+
public function test_matches_from_header(): void
88+
{
89+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
90+
91+
$session = $this->container->get(Session::class);
92+
93+
$this->http
94+
->post('/test', headers: [VerifyCsrfMiddleware::CSRF_HEADER_KEY => $session->token])
95+
->assertOk();
96+
}
97+
98+
public function test_csrf_component(): void
99+
{
100+
$rendered = $this->render(<<<HTML
101+
<x-csrf-token />
102+
HTML);
103+
104+
$session = $this->container->get(Session::class);
105+
106+
$this->assertStringMatchesFormat('<input type="hidden" name="_csrf_token" value="%s">', $rendered);
107+
$this->assertStringContainsString($session->token, $rendered);
108+
}
109+
}

tests/Integration/Http/HttpExceptionHandlerTest.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Tempest\Http\HttpRequestFailed;
1313
use Tempest\Http\Response;
1414
use Tempest\Http\Responses\Redirect;
15+
use Tempest\Http\Session\CsrfTokenDidNotMatch;
1516
use Tempest\Http\Status;
1617
use Tempest\Router\Exceptions\HttpExceptionHandler;
1718
use Tempest\Router\Exceptions\RouteBindingFailed;
@@ -120,7 +121,7 @@ public function test_exception_handler_returns_500_by_default(): void
120121
#[TestWith([Status::NOT_FOUND])]
121122
#[TestWith([Status::FORBIDDEN])]
122123
#[TestWith([Status::METHOD_NOT_ALLOWED])]
123-
public function test_exception_handler_returns_sane_code_as_http_exception(Status $status): void
124+
public function test_exception_handler_returns_same_code_as_http_exception(Status $status): void
124125
{
125126
$this->callExceptionHandler(function () use ($status): void {
126127
$handler = $this->container->get(HttpExceptionHandler::class);
@@ -149,6 +150,16 @@ public function test_exception_handler_runs_exception_processors(): void
149150
NullExceptionProcessor::$exceptions = [];
150151
}
151152

153+
public function test_exception_handler_returns_unprocessable_for_csrf_mismatch(): void
154+
{
155+
$this->callExceptionHandler(function (): void {
156+
$handler = $this->container->get(HttpExceptionHandler::class);
157+
$handler->handle(new CsrfTokenDidNotMatch());
158+
});
159+
160+
$this->assertSame(Status::UNPROCESSABLE_CONTENT, $this->response->status);
161+
}
162+
152163
private function callExceptionHandler(Closure $callback): void
153164
{
154165
try {

0 commit comments

Comments
 (0)