Skip to content

Commit bd4be5e

Browse files
authored
refactor(http)!: improve session management and CSRF protection (#1829)
1 parent 516a0ac commit bd4be5e

File tree

77 files changed

+2356
-1322
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+2356
-1322
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@
250250
"lint:fix": "vendor/bin/mago lint --fix --format-after-fix",
251251
"style": "composer fmt && composer lint:fix",
252252
"test": "composer phpunit",
253-
"test:stop": "composer phpunit --stop-on-error --stop-on-failure",
253+
"test:stop": "composer phpunit -- --stop-on-error --stop-on-failure",
254254
"lint": "vendor/bin/mago lint --potentially-unsafe --minimum-fail-level=note",
255255
"phpstan": "vendor/bin/phpstan analyse src tests --memory-limit=1G",
256256
"rector": "vendor/bin/rector process --no-ansi",

docs/1-essentials/01-routing.md

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,12 @@ final readonly class AircraftController
449449

450450
When users submit forms—like updating profile settings, or posting comments—the data needs validation before processing. Tempest automatically validates request objects using type hints and validation attributes, then provides errors back to users when something is wrong.
451451

452-
On validation failure, Tempest either redirects back to the form (for web pages) or returns a 400 response (for stateless requests). Validation errors are available in two places:
452+
On validation failure, Tempest either redirects back to the form (for web pages) or returns a 422 response (for stateless requests). Validation errors are available in two places:
453453

454454
- As a JSON encoded string in the `{txt}X-Validation` header
455-
- Within the session stored in `Session::VALIDATION_ERRORS`
455+
- Through the `b{Tempest\Http\Session\FormSession}` class
456456

457-
The JSON-encoded header is available for APIs built with Tempest. The session errors are available for web pages. For web pages, Tempest provides built-in view components to display errors when they occur.
457+
For web pages, Tempest also provides built-in view components to display errors when they occur.
458458

459459
```html
460460
<x-form :action="uri(StorePostController::class)">
@@ -464,7 +464,7 @@ The JSON-encoded header is available for APIs built with Tempest. The session er
464464
</x-form>
465465
```
466466

467-
`{html}<x-form>` is a view component that automatically includes the CSRF token and defaults to sending `POST` requests. `{html}<x-input>` is a view component that renders a label, input field, and validation errors all at once.
467+
`{html}<x-form>` is a view component that defaults to sending `POST` requests. `{html}<x-input>` is a view component that renders a label, input field, and validation errors all at once.
468468

469469
:::info
470470
These built-in view components can be customized. Run `./tempest install view-components` and select the components to pull into the project. [Read more about installing view components here](../1-essentials/02-views.md#built-in-components).
@@ -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/auth/src/Authentication/AuthenticatorInitializer.php

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

55
namespace Tempest\Auth\Authentication;
66

7-
use Tempest\Auth\AuthConfig;
87
use Tempest\Container\Container;
98
use Tempest\Container\Initializer;
109
use Tempest\Container\Singleton;
1110
use Tempest\Http\Session\Session;
11+
use Tempest\Http\Session\SessionManager;
1212

1313
final readonly class AuthenticatorInitializer implements Initializer
1414
{
1515
#[Singleton]
1616
public function initialize(Container $container): Authenticator
1717
{
1818
return new SessionAuthenticator(
19-
authConfig: $container->get(AuthConfig::class),
19+
sessionManager: $container->get(SessionManager::class),
2020
session: $container->get(Session::class),
2121
authenticatableResolver: $container->get(AuthenticatableResolver::class),
2222
);

packages/auth/src/Authentication/SessionAuthenticator.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@
44

55
namespace Tempest\Auth\Authentication;
66

7-
use Tempest\Auth\AuthConfig;
87
use Tempest\Http\Session\Session;
8+
use Tempest\Http\Session\SessionManager;
99

1010
final readonly class SessionAuthenticator implements Authenticator
1111
{
1212
public const string AUTHENTICATABLE_KEY = '#authenticatable:id';
1313
public const string AUTHENTICATABLE_CLASS = '#authenticatable:class';
1414

1515
public function __construct(
16-
private AuthConfig $authConfig,
16+
private SessionManager $sessionManager,
1717
private Session $session,
1818
private AuthenticatableResolver $authenticatableResolver,
1919
) {}
@@ -34,7 +34,8 @@ public function authenticate(Authenticatable $authenticatable): void
3434
public function deauthenticate(): void
3535
{
3636
$this->session->remove(self::AUTHENTICATABLE_KEY);
37-
$this->session->destroy();
37+
$this->session->remove(self::AUTHENTICATABLE_CLASS);
38+
$this->sessionManager->save($this->session);
3839
}
3940

4041
public function current(): ?Authenticatable

packages/core/src/AppConfig.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,13 @@ final class AppConfig
1010
{
1111
public string $baseUri;
1212

13+
/** @var array<class-string<\Tempest\Core\InsightsProvider>> */
14+
public array $insightsProviders = [];
15+
1316
public function __construct(
1417
public ?string $name = null,
15-
1618
?string $baseUri = null,
17-
18-
/**
19-
* @var array<class-string<\Tempest\Core\InsightsProvider>>
20-
*/
21-
public array $insightsProviders = [],
2219
) {
23-
$this->baseUri = $baseUri ?? env('BASE_URI') ?? '';
20+
$this->baseUri = $baseUri ?: env('BASE_URI', default: '');
2421
}
2522
}

packages/core/src/FrameworkKernel.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ public function registerExceptionHandler(): self
245245

246246
$handler = $this->container->get(ExceptionHandler::class);
247247

248+
ini_set('display_errors', 'Off'); // @mago-expect lint:no-ini-set
248249
set_exception_handler($handler->handle(...));
249250
set_error_handler(function (int $code, string $message, string $filename, int $line) use ($handler): bool {
250251
$handler->handle(new ErrorException(
@@ -255,7 +256,7 @@ public function registerExceptionHandler(): self
255256
));
256257

257258
return true;
258-
}, error_levels: E_ERROR);
259+
}, error_levels: E_ALL);
259260

260261
return $this;
261262
}

packages/http/src/Cookie/Cookie.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ public function __construct(
2828
public ?int $maxAge = null,
2929
public ?string $domain = null,
3030
public ?string $path = '/',
31-
public bool $secure = false,
31+
public bool $secure = true,
3232
public bool $httpOnly = false,
33-
public ?SameSite $sameSite = null,
33+
public SameSite $sameSite = SameSite::LAX,
3434
) {}
3535

3636
public function withValue(string $value): self

packages/http/src/Header.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Tempest\Http;
66

7+
use BackedEnum;
8+
79
final class Header
810
{
911
public function __construct(
@@ -14,6 +16,10 @@ public function __construct(
1416

1517
public function add(mixed $value): void
1618
{
19+
if ($value instanceof BackedEnum) {
20+
$value = $value->value;
21+
}
22+
1723
$this->values[] = $value;
1824
}
1925

packages/http/src/IsResponse.php

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Tempest\Http\Cookie\CookieManager;
1111
use Tempest\Http\Session\Session;
1212
use Tempest\View\View;
13+
use UnitEnum;
1314

1415
use function Tempest\get;
1516

@@ -23,14 +24,14 @@ trait IsResponse
2324
/** @var \Tempest\Http\Header[] */
2425
private(set) array $headers = [];
2526

26-
public Session $session {
27-
get => get(Session::class);
28-
}
29-
3027
public CookieManager $cookieManager {
3128
get => get(CookieManager::class);
3229
}
3330

31+
public Session $session {
32+
get => get(Session::class);
33+
}
34+
3435
private(set) ?View $view = null;
3536

3637
public function getHeader(string $name): ?Header
@@ -72,42 +73,35 @@ public function removeHeader(string $key): self
7273
return $this;
7374
}
7475

75-
public function addSession(string $name, mixed $value): self
76-
{
77-
$this->session->set($name, $value);
78-
79-
return $this;
80-
}
81-
82-
public function removeSession(string $name): self
76+
public function addCookie(Cookie $cookie): self
8377
{
84-
$this->session->remove($name);
78+
$this->cookieManager->add($cookie);
8579

8680
return $this;
8781
}
8882

89-
public function destroySession(): self
83+
public function removeCookie(string $key): self
9084
{
91-
$this->session->destroy();
85+
$this->cookieManager->remove($key);
9286

9387
return $this;
9488
}
9589

96-
public function addCookie(Cookie $cookie): self
90+
public function addSession(string $name, mixed $value): self
9791
{
98-
$this->cookieManager->add($cookie);
92+
$this->session->set($name, $value);
9993

10094
return $this;
10195
}
10296

103-
public function removeCookie(string $key): self
97+
public function removeSession(string $name): self
10498
{
105-
$this->cookieManager->remove($key);
99+
$this->session->remove($name);
106100

107101
return $this;
108102
}
109103

110-
public function flash(string $key, mixed $value): self
104+
public function flash(string|UnitEnum $key, mixed $value): self
111105
{
112106
$this->session->flash($key, $value);
113107

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 */

0 commit comments

Comments
 (0)