Skip to content

Commit 89e9456

Browse files
rexplbrendtinnocenzi
authored
feat(http): add cookie config options to control unencrypted cookie discarding (#2132)
Co-authored-by: Brent Roose <brent.roose@gmail.com> Co-authored-by: Enzo Innocenzi <enzo@innocenzi.dev>
1 parent b147cd3 commit 89e9456

7 files changed

Lines changed: 256 additions & 7 deletions

File tree

docs/1-essentials/01-routing.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,26 @@ final readonly class ErrorResponseProcessor implements ResponseProcessor
875875
}
876876
```
877877

878+
## Cookie management
879+
880+
### Configuration
881+
882+
By default, Tempest encrypts all cookies it sets, and discards any incoming cookie it cannot decrypt.
883+
884+
This behaviour can be configured by creating a `cookie.config.php` file [anywhere](../1-essentials/06-configuration.md#configuration-files).
885+
886+
```php app/cookie.config.php
887+
use Tempest\Http\Cookie\CookieConfig;
888+
889+
return new CookieConfig(
890+
plaintextCookies: ['darkmode'],
891+
);
892+
```
893+
894+
**`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`.
895+
896+
**`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 using JavaScript (e.g. UI preferences like dark mode).
897+
878898
## Session management
879899

880900
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.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Cookie;
6+
7+
final class CookieConfig
8+
{
9+
/**
10+
*
11+
* @param bool $discardUnencryptedCookies Whether to discard cookies that cannot be decrypted.
12+
* @param string[] $plaintextCookies List of cookies keys that will not be decrypted by Tempest. Outgoing whitelisted cookies will be sent to the browser in plaintext.
13+
*/
14+
public function __construct(
15+
public bool $discardUnencryptedCookies = true,
16+
public array $plaintextCookies = [],
17+
) {}
18+
}

packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Psr\Http\Message\UploadedFileInterface;
99
use Tempest\Cryptography\Encryption\Encrypter;
1010
use Tempest\Http\Cookie\Cookie;
11+
use Tempest\Http\Cookie\CookieConfig;
1112
use Tempest\Http\Cookie\CookieManager;
1213
use Tempest\Http\GenericRequest;
1314
use Tempest\Http\Method;
@@ -25,6 +26,7 @@
2526
public function __construct(
2627
private Encrypter $encrypter,
2728
private CookieManager $cookies,
29+
private CookieConfig $cookieConfig,
2830
) {}
2931

3032
public function canMap(mixed $from, mixed $to): bool
@@ -65,14 +67,18 @@ public function map(mixed $from, mixed $to): GenericRequest
6567
'files' => $uploads,
6668
'cookies' => Arr\filter(Arr\map(
6769
array: $_COOKIE,
68-
map: function (string $value, string $key) {
70+
map: function (string $rawValue, string $key) {
6971
try {
7072
return new Cookie(
7173
key: $key,
72-
value: $this->encrypter->decrypt($value),
74+
value: in_array($key, $this->cookieConfig->plaintextCookies, strict: true)
75+
? $rawValue
76+
: $this->encrypter->decrypt($rawValue),
7377
);
7478
} catch (Throwable) {
75-
$this->cookies->remove($key);
79+
if ($this->cookieConfig->discardUnencryptedCookies) {
80+
$this->cookies->remove($key);
81+
}
7682

7783
return null;
7884
}

packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Tempest\Cryptography\Signing\SigningAlgorithm;
2121
use Tempest\Cryptography\Signing\SigningConfig;
2222
use Tempest\Cryptography\Timelock;
23+
use Tempest\Http\Cookie\CookieConfig;
2324
use Tempest\Http\Cookie\CookieManager;
2425
use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper;
2526
use Tempest\Http\Method;
@@ -40,6 +41,7 @@ protected function setUp(): void
4041
new AppConfig(baseUri: 'https://test.com'),
4142
new GenericClock(),
4243
),
44+
new CookieConfig(),
4345
);
4446

4547
$reflection = new ReflectionClass($this->mapper);

packages/router/src/SetCookieHeadersMiddleware.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tempest\Router;
66

77
use Tempest\Cryptography\Encryption\Encrypter;
8+
use Tempest\Http\Cookie\CookieConfig;
89
use Tempest\Http\Cookie\CookieManager;
910
use Tempest\Http\Request;
1011
use Tempest\Http\Response;
@@ -19,16 +20,19 @@
1920
public function __construct(
2021
private Encrypter $encrypter,
2122
private CookieManager $cookies,
23+
private CookieConfig $cookieConfig,
2224
) {}
2325

2426
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
2527
{
2628
$response = $next($request);
2729

2830
foreach ($this->cookies->all() as $cookie) {
29-
$cookieValue = $cookie->value === ''
30-
? ''
31-
: $this->encrypter->encrypt($cookie->value)->serialize();
31+
$cookieValue = match (true) {
32+
$cookie->value === '' => '',
33+
in_array($cookie->key, $this->cookieConfig->plaintextCookies, strict: true) => $cookie->value,
34+
default => $this->encrypter->encrypt($cookie->value)->serialize(),
35+
};
3236

3337
$response->addHeader('set-cookie', (string) $cookie->withValue($cookieValue));
3438
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Integration\Http;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use ReflectionClass;
9+
use Tempest\Cryptography\Encryption\Encrypter;
10+
use Tempest\Http\Cookie\Cookie;
11+
use Tempest\Http\Cookie\CookieConfig;
12+
use Tempest\Http\Request;
13+
use Tempest\Http\Responses\Ok;
14+
use Tempest\Reflection\MethodReflector;
15+
use Tempest\Router\Get;
16+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
17+
18+
final class CookieHandlingTest extends FrameworkIntegrationTestCase
19+
{
20+
#[Test]
21+
public function encrypted_cookies_are_kept_when_default(): void
22+
{
23+
try {
24+
$encrypter = $this->container->get(Encrypter::class);
25+
$_COOKIE['Cookie_name'] = $encrypter->encrypt('myCookieValue')->serialize();
26+
27+
$responseHelper = $this->http
28+
->registerRoute($this->returnCookieValueController())
29+
->get('/get_cookie_value')
30+
->assertOk()
31+
->assertSee('myCookieValue');
32+
33+
foreach ($responseHelper->headers as $header) {
34+
if ($header->name !== 'set-cookie') {
35+
continue;
36+
}
37+
38+
foreach ($header->values as $value) {
39+
$this->assertNotEquals(
40+
$value,
41+
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
42+
);
43+
}
44+
}
45+
} finally {
46+
unset($_COOKIE['Cookie_name']);
47+
}
48+
}
49+
50+
#[Test]
51+
public function unencrypted_cookies_are_discarded_when_default(): void
52+
{
53+
try {
54+
$_COOKIE['Cookie_name'] = 'myCookieValue';
55+
56+
$this->http
57+
->registerRoute($this->returnCookieValueController())
58+
->get('/get_cookie_value')
59+
->assertOk()
60+
->assertHeaderMatches('set-cookie', 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax')
61+
->assertNotSee('myCookieValue');
62+
} finally {
63+
unset($_COOKIE['Cookie_name']);
64+
}
65+
}
66+
67+
#[Test]
68+
public function unencrypted_cookies_are_kept_when_discard_false(): void
69+
{
70+
$this->container->config(new CookieConfig(discardUnencryptedCookies: false));
71+
72+
try {
73+
$_COOKIE['Cookie_name'] = 'myCookieValue';
74+
75+
$responseHelper = $this->http
76+
->registerRoute($this->returnCookieValueController())
77+
->get('/get_cookie_value')
78+
->assertOk()
79+
->assertNotSee('myCookieValue'); // cookies are not discarded but not whitelisted so not available
80+
81+
foreach ($responseHelper->headers as $header) {
82+
if ($header->name !== 'set-cookie') {
83+
continue;
84+
}
85+
86+
foreach ($header->values as $value) {
87+
$this->assertNotEquals(
88+
$value,
89+
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
90+
);
91+
}
92+
}
93+
} finally {
94+
unset($_COOKIE['Cookie_name']);
95+
}
96+
}
97+
98+
#[Test]
99+
public function unencrypted_cookies_are_discarded_when_discard_true(): void
100+
{
101+
$this->container->config(new CookieConfig(discardUnencryptedCookies: true));
102+
103+
try {
104+
$_COOKIE['Cookie_name'] = 'myCookieValue';
105+
106+
$this->http
107+
->registerRoute($this->returnCookieValueController())
108+
->get('/get_cookie_value')
109+
->assertOk()
110+
->assertHeaderMatches('set-cookie', 'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax')
111+
->assertNotSee('myCookieValue');
112+
} finally {
113+
unset($_COOKIE['Cookie_name']);
114+
}
115+
}
116+
117+
#[Test]
118+
public function whitelisted_plaintext_cookies_are_kept(): void
119+
{
120+
$this->container->config(new CookieConfig(
121+
discardUnencryptedCookies: true,
122+
plaintextCookies: ['Cookie_name'],
123+
));
124+
125+
try {
126+
$_COOKIE['Cookie_name'] = 'myCookieValue';
127+
128+
$responseHelper = $this->http
129+
->registerRoute($this->returnCookieValueController())
130+
->get('/get_cookie_value')
131+
->assertOk()
132+
->assertSee('myCookieValue');
133+
134+
foreach ($responseHelper->headers as $header) {
135+
if ($header->name !== 'set-cookie') {
136+
continue;
137+
}
138+
139+
foreach ($header->values as $value) {
140+
$this->assertNotEquals(
141+
$value,
142+
'Cookie_name=; Expires=Wed, 31-Dec-1969 23:59:59 GMT; Max-Age=0; Path=/; Secure; SameSite=Lax',
143+
);
144+
}
145+
}
146+
} finally {
147+
unset($_COOKIE['Cookie_name']);
148+
}
149+
}
150+
151+
#[Test]
152+
public function whitelisted_plaintext_cookies_are_send_in_plain(): void
153+
{
154+
$this->container->config(new CookieConfig(
155+
plaintextCookies: ['Cookie_name'],
156+
));
157+
158+
$controller = new class {
159+
#[Get('/test_whitelisted_unencrypted_cookies_are_send_in_plain')]
160+
public function __invoke(): Ok
161+
{
162+
return new Ok()->addCookie(
163+
new Cookie(
164+
key: 'Cookie_name',
165+
value: 'value',
166+
),
167+
);
168+
}
169+
};
170+
171+
$reflection = new ReflectionClass($controller);
172+
$method = $reflection->getMethod('__invoke');
173+
174+
$this->http
175+
->registerRoute(new MethodReflector($method))
176+
->get('/test_whitelisted_unencrypted_cookies_are_send_in_plain')
177+
->assertOk()
178+
->assertHeaderMatches('set-cookie', 'Cookie_name=value; Path=/; Secure; SameSite=Lax');
179+
}
180+
181+
private function returnCookieValueController(): MethodReflector
182+
{
183+
$controller = new class() {
184+
#[Get('/get_cookie_value')]
185+
public function __invoke(Request $request): Ok
186+
{
187+
return new Ok(
188+
$request->getCookie('Cookie_name')->value ?? '',
189+
);
190+
}
191+
};
192+
193+
$reflection = new ReflectionClass($controller);
194+
$method = $reflection->getMethod('__invoke');
195+
196+
return new MethodReflector($method);
197+
}
198+
}

tests/Integration/Route/PsrRequestToGenericRequestMapperTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Laminas\Diactoros\UploadedFile;
1010
use Laminas\Diactoros\Uri;
1111
use Tempest\Cryptography\Encryption\Encrypter;
12+
use Tempest\Http\Cookie\CookieConfig;
1213
use Tempest\Http\Cookie\CookieManager;
1314
use Tempest\Http\GenericRequest;
1415
use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper;
@@ -30,7 +31,7 @@ final class PsrRequestToGenericRequestMapperTest extends FrameworkIntegrationTes
3031
}
3132

3233
private PsrRequestToGenericRequestMapper $mapper {
33-
get => new PsrRequestToGenericRequestMapper($this->encrypter, $this->cookies);
34+
get => new PsrRequestToGenericRequestMapper($this->encrypter, $this->cookies, new CookieConfig());
3435
}
3536

3637
public function test_generic_request_is_used_when_interface_is_passed(): void

0 commit comments

Comments
 (0)