From 1f83448be79e9f01e9bf11b7718e7b62cfce316c Mon Sep 17 00:00:00 2001 From: rexpl <75545403+rexpl@users.noreply.github.com> Date: Thu, 21 May 2026 08:33:18 +0200 Subject: [PATCH 1/4] feat(http): add CookieConfig to control plaintext cookies --- packages/http/src/Cookie/CookieConfig.php | 13 ++ .../PsrRequestToGenericRequestMapper.php | 14 +- .../router/src/SetCookieHeadersMiddleware.php | 10 +- tests/Integration/Http/CookieHandlingTest.php | 192 ++++++++++++++++++ 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 packages/http/src/Cookie/CookieConfig.php create mode 100644 tests/Integration/Http/CookieHandlingTest.php diff --git a/packages/http/src/Cookie/CookieConfig.php b/packages/http/src/Cookie/CookieConfig.php new file mode 100644 index 0000000000..14e9640257 --- /dev/null +++ b/packages/http/src/Cookie/CookieConfig.php @@ -0,0 +1,13 @@ + $uploads, 'cookies' => Arr\filter(Arr\map( array: $_COOKIE, - map: function (string $value, string $key) { + map: function (string $rawValue, string $key) { try { + $value = \in_array($key, $this->cookieConfig->plaintextCookies, true) + ? $rawValue + : $this->encrypter->decrypt($rawValue); + return new Cookie( key: $key, - value: $this->encrypter->decrypt($value), + value: $value, ); } catch (Throwable) { - $this->cookies->remove($key); + if ($this->cookieConfig->discardUnencryptedCookies) { + $this->cookies->remove($key); + } return null; } diff --git a/packages/router/src/SetCookieHeadersMiddleware.php b/packages/router/src/SetCookieHeadersMiddleware.php index f9b9779d3c..f220ee4cfc 100644 --- a/packages/router/src/SetCookieHeadersMiddleware.php +++ b/packages/router/src/SetCookieHeadersMiddleware.php @@ -5,6 +5,7 @@ namespace Tempest\Router; use Tempest\Cryptography\Encryption\Encrypter; +use Tempest\Http\Cookie\CookieConfig; use Tempest\Http\Cookie\CookieManager; use Tempest\Http\Request; use Tempest\Http\Response; @@ -19,6 +20,7 @@ public function __construct( private Encrypter $encrypter, private CookieManager $cookies, + private CookieConfig $cookieConfig, ) {} public function __invoke(Request $request, HttpMiddlewareCallable $next): Response @@ -26,9 +28,11 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon $response = $next($request); foreach ($this->cookies->all() as $cookie) { - $cookieValue = $cookie->value === '' - ? '' - : $this->encrypter->encrypt($cookie->value)->serialize(); + $cookieValue = match (true) { + $cookie->value === '' => '', + \in_array($cookie->key, $this->cookieConfig->plaintextCookies, true) => $cookie->value, + default => $this->encrypter->encrypt($cookie->value)->serialize(), + }; $response->addHeader('set-cookie', (string) $cookie->withValue($cookieValue)); } diff --git a/tests/Integration/Http/CookieHandlingTest.php b/tests/Integration/Http/CookieHandlingTest.php new file mode 100644 index 0000000000..fc9bff75d8 --- /dev/null +++ b/tests/Integration/Http/CookieHandlingTest.php @@ -0,0 +1,192 @@ +container->get(Encrypter::class); + $_COOKIE['Cookie_name'] = $encrypter->encrypt('myCookieValue')->serialize(); + + $responseHelper = $this->http + ->registerRoute($this->returnCookieValueController()) + ->get('/get_cookie_value') + ->assertOk() + ->assertSee('myCookieValue'); + + foreach ($responseHelper->headers as $header) { + if ($header->name !== 'set-cookie') { + continue; + } + + foreach ($header->values as $value) { + $this->assertNotEquals( + $value, + 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax', + ); + } + } + } finally { + unset($_COOKIE['Cookie_name']); + } + } + + public function test_unencrypted_cookies_are_discarded_when_default(): void + { + try { + $_COOKIE['Cookie_name'] = 'myCookieValue'; + + $this->http + ->registerRoute($this->returnCookieValueController()) + ->get('/get_cookie_value') + ->assertOk() + ->assertHeaderMatches('set-cookie', 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax') + ->assertNotSee('myCookieValue'); + } finally { + unset($_COOKIE['Cookie_name']); + } + } + + public function test_unencrypted_cookies_are_kept_when_discard_false(): void + { + $this->container->config(new CookieConfig(discardUnencryptedCookies: false)); + + try { + $_COOKIE['Cookie_name'] = 'myCookieValue'; + + $responseHelper = $this->http + ->registerRoute($this->returnCookieValueController()) + ->get('/get_cookie_value') + ->assertOk() + ->assertNotSee('myCookieValue'); // cookies are not discarded but not whitelisted so not available + + foreach ($responseHelper->headers as $header) { + if ($header->name !== 'set-cookie') { + continue; + } + + foreach ($header->values as $value) { + $this->assertNotEquals( + $value, + 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax', + ); + } + } + } finally { + unset($_COOKIE['Cookie_name']); + } + } + + public function test_unencrypted_cookies_are_discarded_when_discard_true(): void + { + $this->container->config(new CookieConfig(discardUnencryptedCookies: true)); + + try { + $_COOKIE['Cookie_name'] = 'myCookieValue'; + + $this->http + ->registerRoute($this->returnCookieValueController()) + ->get('/get_cookie_value') + ->assertOk() + ->assertHeaderMatches('set-cookie', 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax') + ->assertNotSee('myCookieValue'); + } finally { + unset($_COOKIE['Cookie_name']); + } + } + + public function test_whitelisted_plaintext_cookies_are_kept(): void + { + $this->container->config(new CookieConfig( + discardUnencryptedCookies: true, + plaintextCookies: ['Cookie_name'], + )); + + try { + $_COOKIE['Cookie_name'] = 'myCookieValue'; + + $responseHelper = $this->http + ->registerRoute($this->returnCookieValueController()) + ->get('/get_cookie_value') + ->assertOk() + ->assertSee('myCookieValue'); + + foreach ($responseHelper->headers as $header) { + if ($header->name !== 'set-cookie') { + continue; + } + + foreach ($header->values as $value) { + $this->assertNotEquals( + $value, + 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax', + ); + } + } + } finally { + unset($_COOKIE['Cookie_name']); + } + } + + public function test_whitelisted_plaintext_cookies_are_send_in_plain(): void + { + $this->container->config(new CookieConfig( + plaintextCookies: ['Cookie_name'], + )); + + $controller = new class { + #[Get('/test_whitelisted_unencrypted_cookies_are_send_in_plain')] + public function __invoke(): Ok + { + return new Ok()->addCookie( + new Cookie( + key: 'Cookie_name', + value: 'value', + ), + ); + } + }; + + $reflection = new ReflectionClass($controller); + $method = $reflection->getMethod('__invoke'); + + $this->http + ->registerRoute(new MethodReflector($method)) + ->get('/test_whitelisted_unencrypted_cookies_are_send_in_plain') + ->assertOk() + ->assertHeaderMatches('set-cookie', 'Cookie_name=value; Path=/; Secure; SameSite=Lax'); + } + + private function returnCookieValueController(): MethodReflector + { + $controller = new class() { + #[Get('/get_cookie_value')] + public function __invoke(Request $request): Ok + { + return new Ok( + $request->getCookie('Cookie_name')->value ?? '', + ); + } + }; + + $reflection = new ReflectionClass($controller); + $method = $reflection->getMethod('__invoke'); + + return new MethodReflector($method); + } +} From 2a5b7689a5413b80c69c4808cf002fa3080144e6 Mon Sep 17 00:00:00 2001 From: rexpl <75545403+rexpl@users.noreply.github.com> Date: Thu, 21 May 2026 17:53:55 +0200 Subject: [PATCH 2/4] testing details --- tests/Integration/Http/CookieHandlingTest.php | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/Integration/Http/CookieHandlingTest.php b/tests/Integration/Http/CookieHandlingTest.php index fc9bff75d8..bfc8622c12 100644 --- a/tests/Integration/Http/CookieHandlingTest.php +++ b/tests/Integration/Http/CookieHandlingTest.php @@ -4,6 +4,7 @@ namespace Integration\Http; +use PHPUnit\Framework\Attributes\Test; use ReflectionClass; use Tempest\Cryptography\Encryption\Encrypter; use Tempest\Http\Cookie\Cookie; @@ -16,10 +17,10 @@ final class CookieHandlingTest extends FrameworkIntegrationTestCase { - public function test_encrypted_cookies_are_kept_when_default(): void + #[Test] + public function encrypted_cookies_are_kept_when_default(): void { try { - /** @var \Tempest\Cryptography\Encryption\Encrypter $encrypter */ $encrypter = $this->container->get(Encrypter::class); $_COOKIE['Cookie_name'] = $encrypter->encrypt('myCookieValue')->serialize(); @@ -46,7 +47,8 @@ public function test_encrypted_cookies_are_kept_when_default(): void } } - public function test_unencrypted_cookies_are_discarded_when_default(): void + #[Test] + public function unencrypted_cookies_are_discarded_when_default(): void { try { $_COOKIE['Cookie_name'] = 'myCookieValue'; @@ -62,7 +64,8 @@ public function test_unencrypted_cookies_are_discarded_when_default(): void } } - public function test_unencrypted_cookies_are_kept_when_discard_false(): void + #[Test] + public function unencrypted_cookies_are_kept_when_discard_false(): void { $this->container->config(new CookieConfig(discardUnencryptedCookies: false)); @@ -92,7 +95,8 @@ public function test_unencrypted_cookies_are_kept_when_discard_false(): void } } - public function test_unencrypted_cookies_are_discarded_when_discard_true(): void + #[Test] + public function unencrypted_cookies_are_discarded_when_discard_true(): void { $this->container->config(new CookieConfig(discardUnencryptedCookies: true)); @@ -110,7 +114,8 @@ public function test_unencrypted_cookies_are_discarded_when_discard_true(): void } } - public function test_whitelisted_plaintext_cookies_are_kept(): void + #[Test] + public function whitelisted_plaintext_cookies_are_kept(): void { $this->container->config(new CookieConfig( discardUnencryptedCookies: true, @@ -143,7 +148,8 @@ public function test_whitelisted_plaintext_cookies_are_kept(): void } } - public function test_whitelisted_plaintext_cookies_are_send_in_plain(): void + #[Test] + public function whitelisted_plaintext_cookies_are_send_in_plain(): void { $this->container->config(new CookieConfig( plaintextCookies: ['Cookie_name'], From 40ecf5100da31a49db107728bd67ebf70ddd2ec6 Mon Sep 17 00:00:00 2001 From: rexpl <75545403+rexpl@users.noreply.github.com> Date: Thu, 21 May 2026 18:15:25 +0200 Subject: [PATCH 3/4] config property docblocks --- packages/http/src/Cookie/CookieConfig.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/http/src/Cookie/CookieConfig.php b/packages/http/src/Cookie/CookieConfig.php index 14e9640257..9210079815 100644 --- a/packages/http/src/Cookie/CookieConfig.php +++ b/packages/http/src/Cookie/CookieConfig.php @@ -7,7 +7,18 @@ final class CookieConfig { public function __construct( + /** + * Whether to discard cookies that cannot be decrypted. + * What this means: any cookies not encrypted by your application (or not whitelisted) that + * arrive with a request, will prompt tempest to request the browser to forget these cookies. + * Cookies sent unencrypted and not whitelisted will also not be available in the request object. + */ public bool $discardUnencryptedCookies = true, + + /** + * List of cookies that will not be decrypted by tempest, be available in the request object. + * Outgoing whitelisted cookies will be sent to the browser in plaintext. + */ public array $plaintextCookies = [], ) {} } From f3ec431a50e8f25e3f7d8eeb5b8b375ec5cfe0d6 Mon Sep 17 00:00:00 2001 From: rexpl <75545403+rexpl@users.noreply.github.com> Date: Sun, 31 May 2026 21:16:12 +0200 Subject: [PATCH 4/4] docs --- docs/1-essentials/01-routing.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md index c2297b0ae0..c2d1d5200c 100644 --- a/docs/1-essentials/01-routing.md +++ b/docs/1-essentials/01-routing.md @@ -875,6 +875,25 @@ final readonly class ErrorResponseProcessor implements ResponseProcessor } ``` +## Cookie management + +### Configuration + +By default, Tempest encrypts all cookies it sets, and discards any incoming cookie it cannot decrypt. +This behaviour can be configured by creating a `cookie.config.php` file [anywhere](../1-essentials/06-configuration.md#configuration-files). + +```php app/cookie.config.php +use Tempest\Http\Cookie\CookieConfig; + +return new CookieConfig( + plaintextCookies: ['darkmode'], +); +``` + +**`discardUnencryptedCookies`** — When `true` (default), any incoming cookie that Tempest cannot decrypt will be discarded and the browser instructed to delete it. Set to `false` to silently ignore unencrypted cookies instead, leaving them intact in the browser. Note that either way, unencrypted cookies will not be accessible in the request object unless whitelisted via `plaintextCookies`. + +**`plaintextCookies`** — A list of cookie names that Tempest will not attempt to encrypt or decrypt. Whitelisted cookies are preserved in the browser, accessible in the request object, and sent to the browser in plaintext. Useful for cookies set by third-party services such as reverse proxies or CDNs, or cookies that must be readable by JavaScript (e.g. UI preferences like dark mode). + ## Session management Sessions in Tempest are managed by the {b`Tempest\Http\Session\Session`} class. It can be injected anywhere needed. As soon as the {b`Tempest\Http\Session\Session`} is injected, it is started behind the scenes.