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. diff --git a/packages/http/src/Cookie/CookieConfig.php b/packages/http/src/Cookie/CookieConfig.php new file mode 100644 index 0000000000..9210079815 --- /dev/null +++ b/packages/http/src/Cookie/CookieConfig.php @@ -0,0 +1,24 @@ + $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..bfc8622c12 --- /dev/null +++ b/tests/Integration/Http/CookieHandlingTest.php @@ -0,0 +1,198 @@ +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']); + } + } + + #[Test] + public function 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']); + } + } + + #[Test] + public function 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']); + } + } + + #[Test] + public function 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']); + } + } + + #[Test] + public function 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']); + } + } + + #[Test] + public function 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); + } +}