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); + } +}