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/3] 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/3] 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/3] 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 = [], ) {} }