Skip to content

Commit 0a64315

Browse files
committed
feat: add csrf protection through sec-fetch
1 parent edac0f6 commit 0a64315

File tree

12 files changed

+414
-17
lines changed

12 files changed

+414
-17
lines changed

docs/1-essentials/01-routing.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -542,20 +542,33 @@ final readonly class ValidateWebhook implements HttpMiddleware
542542
{ /* … */ }
543543
```
544544

545+
### Cross-site request forgery protection
546+
547+
Tempest provides [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) protection based on the presence and values of the [`{txt}Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) and [`{txt}Sec-Fetch-Mode`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) headers through the {b`Tempest\Router\PreventCrossSiteRequestsMiddleware`} middleware, included by default in all requests.
548+
549+
Unlike traditional CSRF tokens, this approach uses browser-generated headers that cannot be forged by external websites:
550+
551+
- [`{txt}Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) indicates whether the request came from the same domain, subdomain, a different site or if it was user-initiated, such as typing the URL directly,
552+
- [`{txt}Sec-Fetch-Mode`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) allows distinguishing between requests originating from a user navigating between HTML pages, and requests to load images and other resources.
553+
554+
:::info
555+
This middleware requires browsers that support `{txt}Sec-Fetch-*` headers, which is the case for all modern browsers. You may [exclude this middleware](#excluding-route-middleware) and implement traditional CSRF protection using tokens if you need to support older browsers.
556+
:::
557+
545558
### Excluding route middleware
546559

547-
Some routes do not require specific global middleware to be applied. For instance, API routes do not need CSRF protection. Specific middleware can be skipped by using the `without` argument of the route attribute.
560+
Some routes do not require specific global middleware to be applied. For instance, a publicly accessible health check endpoint could bypass rate limiting that's applied to other routes. Specific middleware can be skipped by using the `without` argument of the route attribute.
548561

549-
```php app/Slack/ReceiveInteractionController.php
550-
use Tempest\Router\Post;
562+
```php app/HealthCheckController.php
563+
use Tempest\Router\Get;
551564
use Tempest\Http\Response;
552565

553-
final readonly class ReceiveInteractionController
566+
final readonly class HealthCheckController
554567
{
555-
#[Post('/slack/interaction', without: [VerifyCsrfMiddleware::class, SetCookieMiddleware::class])]
568+
#[Get('/health', without: [RateLimitMiddleware::class])]
556569
public function __invoke(): Response
557570
{
558-
// …
571+
return new Ok(['status' => 'healthy']);
559572
}
560573
}
561574
```
@@ -639,8 +652,9 @@ Explicitly removes middleware to all associated routes.
639652
```php
640653
use Tempest\Router\WithoutMiddleware;
641654
use Tempest\Router\Get;
655+
use Tempest\Router\PreventCrossSiteRequestsMiddleware;
642656

643-
#[WithoutMiddleware(VerifyCsrfMiddleware::class, SetCookieMiddleware::class)]
657+
#[WithoutMiddleware(PreventCrossSiteRequestsMiddleware::class)]
644658
final readonly class StatelessController { /* … */ }
645659
```
646660

packages/http/src/RequestHeaders.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use ArrayIterator;
99
use IteratorAggregate;
1010
use LogicException;
11+
use Tempest\Support\Str;
1112
use Traversable;
1213

1314
final readonly class RequestHeaders implements ArrayAccess, IteratorAggregate
@@ -17,11 +18,10 @@
1718
*/
1819
public static function normalizeFromArray(array $headers): self
1920
{
20-
$normalized = array_combine(
21+
return new self(array_combine(
2122
array_map(strtolower(...), array_keys($headers)),
22-
array_values($headers),
23-
);
24-
return new self($normalized);
23+
array_values(array_map(fn (mixed $value) => Str\parse($value), $headers)),
24+
));
2525
}
2626

2727
/** @param array<string, string> $headers */

packages/http/src/Session/TrackPreviousUrlMiddleware.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
final readonly class TrackPreviousUrlMiddleware implements HttpMiddleware
1313
{
1414
public function __construct(
15-
private PreviousUrl $previousUrlTracker,
15+
private PreviousUrl $previousUrl,
1616
) {}
1717

1818
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
1919
{
20-
$this->previousUrlTracker->track($request);
20+
$this->previousUrl->track($request);
2121

2222
return $next($request);
2323
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Router;
6+
7+
use Tempest\Core\Priority;
8+
use Tempest\Http\Method;
9+
use Tempest\Http\Request;
10+
use Tempest\Http\Response;
11+
use Tempest\Http\Responses\Forbidden;
12+
13+
/**
14+
* Protects against cross-site requests using `Sec-Fetch-*` headers. This is a modern equivalent of session token-based cross-site forgery protection.
15+
*
16+
* - Safe HTTP methods are always allowed
17+
* - Requests from same-origin or same-site are allowed
18+
* - Cross-site requests that aren't navigation are blocked
19+
* - Requests without Sec-Fetch-Site headers are blocked
20+
*
21+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site
22+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode
23+
* @see https://web.dev/articles/fetch-metadata
24+
*/
25+
#[Priority(Priority::FRAMEWORK - 8)]
26+
final readonly class PreventCrossSiteRequestsMiddleware implements HttpMiddleware
27+
{
28+
private const array SAFE_METHODS = [
29+
Method::GET,
30+
Method::HEAD,
31+
Method::OPTIONS,
32+
Method::TRACE,
33+
];
34+
35+
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
36+
{
37+
if ($this->shouldValidate($request) && ! $this->isValidRequest($request)) {
38+
return new Forbidden();
39+
}
40+
41+
return $next($request);
42+
}
43+
44+
/**
45+
* Determines if the request should be validated for CSRF.
46+
*/
47+
private function shouldValidate(Request $request): bool
48+
{
49+
if (in_array($request->method, self::SAFE_METHODS, strict: true)) {
50+
return false;
51+
}
52+
53+
return true;
54+
}
55+
56+
/**
57+
* Validates the request using `Sec-Fetch-*` headers.
58+
*/
59+
private function isValidRequest(Request $request): bool
60+
{
61+
$secFetchSite = SecFetchSite::tryFrom($request->headers->get('sec-fetch-site') ?? '');
62+
$secFetchMode = SecFetchMode::tryFrom($request->headers->get('sec-fetch-mode') ?? '');
63+
64+
// prevent the request if there is no `sec-fetch-site` header
65+
if ($secFetchSite === null) {
66+
return false;
67+
}
68+
69+
// allow cross-site only on navigation requests
70+
if ($secFetchSite === SecFetchSite::CROSS_SITE && $secFetchMode === SecFetchMode::NAVIGATE) {
71+
return true;
72+
}
73+
74+
// same origin, same site and user-originated requests are always allowed
75+
if ($secFetchSite !== SecFetchSite::CROSS_SITE) {
76+
return true;
77+
}
78+
79+
return false;
80+
}
81+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Router;
6+
7+
/**
8+
* Represents the Sec-Fetch-Mode header value.
9+
*
10+
* This header indicates the mode of the request.
11+
*
12+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode
13+
*/
14+
enum SecFetchMode: string
15+
{
16+
/**
17+
* The request is for navigation between HTML pages.
18+
*/
19+
case NAVIGATE = 'navigate';
20+
21+
/**
22+
* The request is for CORS-enabled requests.
23+
*/
24+
case CORS = 'cors';
25+
26+
/**
27+
* The request is for no-CORS requests.
28+
*/
29+
case NO_CORS = 'no-cors';
30+
31+
/**
32+
* The request is for same-origin requests.
33+
*/
34+
case SAME_ORIGIN = 'same-origin';
35+
36+
/**
37+
* The request is for WebSocket connections.
38+
*/
39+
case WEBSOCKET = 'websocket';
40+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Router;
6+
7+
/**
8+
* Represents the `Sec-Fetch-Site` header value.
9+
*
10+
* This header indicates the relationship between a request initiator's origin
11+
* and the origin of the resource being requested.
12+
*
13+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site
14+
*/
15+
enum SecFetchSite: string
16+
{
17+
/**
18+
* The request was initiated from the same origin.
19+
*/
20+
case SAME_ORIGIN = 'same-origin';
21+
22+
/**
23+
* The request was initiated from a same-site but cross-origin context.
24+
*/
25+
case SAME_SITE = 'same-site';
26+
27+
/**
28+
* The request was initiated from a cross-site context.
29+
*/
30+
case CROSS_SITE = 'cross-site';
31+
32+
/**
33+
* The request was initiated in a user-initiated way (e.g., entering a URL in the address bar).
34+
*/
35+
case NONE = 'none';
36+
}

packages/router/src/Stateless.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public function decorate(Route $route): Route
1515
{
1616
$route->without = [
1717
...$route->without,
18+
PreventCrossSiteRequestsMiddleware::class,
1819
ManageSessionLifecycleMiddleware::class,
1920
SetCookieHeadersMiddleware::class,
2021
];

src/Tempest/Framework/Testing/Http/HttpRouterTester.php

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tempest\Framework\Testing\Http;
66

7+
use BackedEnum;
78
use Laminas\Diactoros\ServerRequestFactory;
89
use Psr\Http\Message\ServerRequestInterface as PsrRequest;
910
use Tempest\Container\Container;
@@ -14,6 +15,8 @@
1415
use Tempest\Http\Request;
1516
use Tempest\Router\Exceptions\HttpExceptionHandler;
1617
use Tempest\Router\Router;
18+
use Tempest\Router\SecFetchMode;
19+
use Tempest\Router\SecFetchSite;
1720
use Tempest\Support\Uri;
1821
use Throwable;
1922

@@ -23,6 +26,8 @@ final class HttpRouterTester
2326
{
2427
private(set) ?ContentType $contentType = null;
2528

29+
private(set) bool $includeSecFetchHeaders = true;
30+
2631
public function __construct(
2732
private Container $container,
2833
) {}
@@ -37,6 +42,16 @@ public function as(ContentType $contentType): self
3742
return $this;
3843
}
3944

45+
/**
46+
* Specifies that subsequent requests should be sent without Sec-Fetch headers.
47+
*/
48+
public function withoutSecFetchHeaders(): self
49+
{
50+
$this->includeSecFetchHeaders = false;
51+
52+
return $this;
53+
}
54+
4055
public function get(string $uri, array $query = [], array $headers = []): TestResponseHelper
4156
{
4257
return $this->sendRequest(new GenericRequest(
@@ -162,8 +177,12 @@ public function makePsrRequest(
162177
$_SERVER['REQUEST_URI'] = $uri;
163178
$_SERVER['REQUEST_METHOD'] = $method->value;
164179

165-
foreach ($headers as $key => $value) {
166-
$key = strtoupper($key);
180+
foreach ($this->createHeaders($headers) as $key => $value) {
181+
if ($value instanceof BackedEnum) {
182+
$value = $value->value;
183+
}
184+
185+
$key = strtoupper(str_replace('-', '_', $key));
167186

168187
$_SERVER["HTTP_{$key}"] = $value;
169188
}
@@ -185,6 +204,16 @@ private function createHeaders(array $headers = []): array
185204
$headers[$key ?? 'accept'] = $this->contentType->value;
186205
}
187206

207+
if ($this->includeSecFetchHeaders === true) {
208+
if (! array_key_exists('sec-fetch-site', array_change_key_case($headers, case: CASE_LOWER))) {
209+
$headers['sec-fetch-site'] = SecFetchSite::SAME_ORIGIN;
210+
}
211+
212+
if (! array_key_exists('sec-fetch-mode', array_change_key_case($headers, case: CASE_LOWER))) {
213+
$headers['sec-fetch-mode'] = SecFetchMode::CORS;
214+
}
215+
}
216+
188217
return $headers;
189218
}
190219
}

tests/Integration/Route/ClientTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ public function test_form_post_request(): void
5353
->withHeader('Referer', 'http://localhost:8088/request-test/form')
5454
->withHeader('Accept', 'application/json')
5555
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
56+
->withHeader('Sec-Fetch-Site', 'same-origin')
57+
->withHeader('Sec-Fetch-Mode', 'cors')
5658
->withBody(new StreamFactory()->createStream('name=a a&b.name=b'));
5759

5860
try {
@@ -72,6 +74,8 @@ public function test_json_post_request(): void
7274
->createRequest('POST', new Uri('http://localhost:8088/request-test/form'))
7375
->withHeader('Accept', 'application/json')
7476
->withHeader('Content-Type', 'application/json')
77+
->withHeader('Sec-Fetch-Site', 'same-origin')
78+
->withHeader('Sec-Fetch-Mode', 'cors')
7579
->withBody(new StreamFactory()->createStream('{"name": "a a", "b": {"name": "b"}}'));
7680

7781
try {

tests/Integration/Route/RequestTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public function test_from_factory(): void
8585
$this->assertEquals(Method::POST->value, $request->getMethod());
8686
$this->assertEquals('/test', $request->getUri()->getPath());
8787
$this->assertEquals(['test' => 'test'], $request->getParsedBody());
88-
$this->assertEquals(['x-test' => ['test']], $request->getHeaders());
88+
$this->assertArrayIsEqualToArrayIgnoringListOfKeys(['x-test' => ['test']], $request->getHeaders(), ['sec-fetch-site', 'sec-fetch-mode']);
8989
$this->assertCount(1, $request->getCookieParams());
9090
$this->assertArrayHasKey('test', $request->getCookieParams());
9191
$this->assertSame('test', $this->container->get(Encrypter::class)->decrypt($request->getCookieParams()['test']));

0 commit comments

Comments
 (0)