Skip to content

Commit 178734f

Browse files
ENH Add samesite attribute to cookies.
Co-authored-by: pine3ree <[email protected]>
1 parent 6f27dad commit 178734f

File tree

10 files changed

+497
-27
lines changed

10 files changed

+497
-27
lines changed

docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -762,11 +762,15 @@ disable this behaviour using `CanonicalURLMiddleware::singleton()->setForceBasic
762762
configuration in YAML.
763763

764764
We also want to ensure cookies are not shared between secure and non-secure sessions, so we must tell Silverstripe CMS to
765-
use a [secure session](https://docs.silverstripe.org/en/3/developer_guides/cookies_and_sessions/sessions/#secure-session-cookie).
766-
To do this, you may set the `cookie_secure` parameter to `true` in your `config.yml` for `Session`
765+
use a [secure session](/developer_guides/cookies_and_sessions/sessions/#secure-session-cookie).
766+
To do this, you may set the `cookie_secure` parameter to `true` in your `config.yml` for `Session`.
767+
768+
It is also a good idea to set the `samesite` attribute for the session cookie to `Strict` unless you have a specific use case for
769+
sharing the session cookie across domains.
767770

768771
```yml
769772
SilverStripe\Control\Session:
773+
cookie_samesite: 'Strict'
770774
cookie_secure: true
771775
```
772776
@@ -784,6 +788,11 @@ SilverStripe\Core\Injector\Injector:
784788
TokenCookieSecure: true
785789
```
786790
791+
[info]
792+
There is not currently an easy way to pass a `samesite` attribute value for setting this cookie - but you can set the
793+
default value for the attribute for all cookies. See [the main cookies documentation](/developer_guides/cookies_and_sessions/cookies#samesite-attribute) for more information.
794+
[/info]
795+
787796
For other cookies set by your application we should also ensure the users are provided with secure cookies by setting
788797
the "Secure" and "HTTPOnly" flags. These flags prevent them from being stolen by an attacker through javascript.
789798

docs/en/02_Developer_Guides/18_Cookies_And_Sessions/01_Cookies.md

+18
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ icon: cookie-bite
55
---
66

77
# Cookies
8+
9+
Note that cookies can have security implications - before setting your own cookies, make sure to read through the
10+
[secure coding](/developer_guides/security/secure_coding#secure-sessions-cookies-and-tls-https) documentation.
11+
812
## Accessing and Manipulating Cookies
913

1014
Cookies are a mechanism for storing data in the remote browser and thus tracking or identifying return users.
@@ -52,6 +56,20 @@ Cookie::force_expiry($name, $path = null, $domain = null);
5256
// Cookie::force_expiry('MyApplicationPreference')
5357
```
5458

59+
### Samesite attribute
60+
61+
The `samesite` attribute is set on all cookies with a default value of `Lax`. You can change the default value by setting the `default_samesite` value on the
62+
[Cookie](api:SilverStripe\Control\Cookie) class:
63+
64+
```yml
65+
SilverStripe\Control\Cookie:
66+
default_samesite: 'Strict'
67+
```
68+
69+
[info]
70+
Note that this _doesn't_ apply for the session cookie, which is handled separately. See [Sessions](/developer_guides/cookies_and_sessions/sessions#samesite-attribute).
71+
[/info]
72+
5573
## Cookie_Backend
5674
5775
The [Cookie](api:SilverStripe\Control\Cookie) class manipulates and sets cookies using a [Cookie_Backend](api:SilverStripe\Control\Cookie_Backend). The backend is in charge of the logic

docs/en/02_Developer_Guides/18_Cookies_And_Sessions/02_Sessions.md

+15-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,19 @@ including form and page comment information. None of this is vital but `clear_al
101101
$session->clearAll();
102102
```
103103

104-
## Secure Session Cookie
104+
## Cookies
105+
106+
### Samesite attribute
107+
108+
The session cookie is handled slightly differently than most cookies on the site, which provides the opportunity to handle the samesite attribute separately from other cookies.
109+
You can change the `samesite` attribute for session cookies like so:
110+
111+
```yml
112+
SilverStripe\Control\Session:
113+
cookie_samesite: 'Strict'
114+
```
115+
116+
### Secure Session Cookie
105117
106118
In certain circumstances, you may want to use a different `session_name` cookie when using the `https` protocol for security purposes. To do this, you may set the `cookie_secure` parameter to `true` on your `config.yml`
107119

@@ -113,6 +125,8 @@ SilverStripe\Control\Session:
113125

114126
This uses the session_name `SECSESSID` for `https` connections instead of the default `PHPSESSID`. Doing so adds an extra layer of security to your session cookie since you no longer share `http` and `https` sessions.
115127

128+
Note that if you set `cookie_samesite` to `None` (which is _strongly_ discouraged), the `cookie_secure` value will _always_ be `true`.
129+
116130
## Relaxing checks around user agent strings
117131

118132
Out of the box, Silverstripe CMS will invalidate a user's session if the `User-Agent` header changes. This provides some supplemental protection against session high-jacking attacks.

docs/en/04_Changelogs/4.12.0.md

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
title: 4.12.0 (unreleased)
3+
---
4+
5+
# 4.12.0 (unreleased)
6+
7+
## Overview
8+
9+
- [Regression test and Security audit](#audit)
10+
- [Features and enhancements](#features-and-enhancements)
11+
- [Samesite attribute on cookies](#cookies-samesite)
12+
- [Other features](#other-features)
13+
- [Bugfixes](#bugfixes)
14+
15+
## Regression test and Security audit{#audit}
16+
17+
This release has been comprehensively regression tested and passed to a third party for a security-focused audit.
18+
19+
While it is still advised that you perform your own due diligence when upgrading your project, this work is performed to ensure a safe and secure upgrade with each recipe release.
20+
21+
## Features and enhancements {#features-and-enhancements}
22+
23+
### Samesite attribute on cookies {#cookies-samesite}
24+
25+
The `samesite` attribute is now set on all cookies. To avoid backward compatability issues, the `Lax` value has been set by default, but we recommend reviewing the requirements of your project and setting an appropriate value.
26+
27+
The default value can be set for all cookies (except for the session cookie) in yaml configuration like so:
28+
29+
```yml
30+
SilverStripe\Control\Cookie:
31+
default_samesite: 'Strict'
32+
```
33+
34+
Check out the [cookies documentation](/developer_guides/cookies_and_sessions/cookies#samesite-attribute) for more information.
35+
36+
The session cookie is handled separately. You can configure it like so:
37+
38+
```yml
39+
SilverStripe\Control\Session:
40+
cookie_samesite: 'Strict'
41+
```
42+
43+
Note that if you set the `samesite` attribute to `None`, the `secure` is automatically set to `true` as required by the specification.
44+
45+
For more information about the `samesite` attribute check out https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
46+
47+
### Other new features {#other-features}
48+
49+
## Bugfixes {#bugfixes}
50+
51+
This release includes a number of bug fixes to improve a broad range of areas. Check the change logs for full details of these fixes split by module. Thank you to the community members that helped contribute these fixes as part of the release!
52+
53+
<!--- Changes below this line will be automatically regenerated -->
54+
55+
<!--- Changes above this line will be automatically regenerated -->

src/Control/Cookie.php

+41
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace SilverStripe\Control;
44

5+
use LogicException;
6+
use Psr\Log\LoggerInterface;
57
use SilverStripe\Core\Config\Configurable;
68
use SilverStripe\Core\Injector\Injector;
79

@@ -19,6 +21,12 @@ class Cookie
1921
*/
2022
private static $report_errors = true;
2123

24+
/**
25+
* Must be "Strict", "Lax", or "None"
26+
* @config
27+
*/
28+
private static string $default_samesite = 'Lax';
29+
2230
/**
2331
* Fetch the current instance of the cookie backend.
2432
*
@@ -92,4 +100,37 @@ public static function force_expiry($name, $path = null, $domain = null, $secure
92100
{
93101
return self::get_inst()->forceExpiry($name, $path, $domain, $secure, $httpOnly);
94102
}
103+
104+
/**
105+
* Validate if the samesite value for a cookie is valid for the current request.
106+
*
107+
* Logs a warning if the samesite value is "None" for an insecure request.
108+
* @throws LogicException if the value is not "Strict", "Lax", or "None".
109+
*/
110+
public static function validateSameSite(string $sameSite): void
111+
{
112+
$validValues = [
113+
'Strict',
114+
'Lax',
115+
'None',
116+
];
117+
if (!in_array($sameSite, $validValues)) {
118+
throw new LogicException('samesite must be "Strict", "Lax", or "None"');
119+
}
120+
if ($sameSite === 'None' && !Director::is_https(self::getRequest())) {
121+
Injector::inst()->get(LoggerInterface::class)->warning('Cookie samesite cannot be "None" for insecure requests.');
122+
}
123+
}
124+
125+
/**
126+
* Get the current request, if any.
127+
*/
128+
private static function getRequest(): ?HTTPRequest
129+
{
130+
$request = null;
131+
if (Controller::has_curr()) {
132+
$request = Controller::curr()->getRequest();
133+
}
134+
return ($request instanceof NullHTTPRequest) ? null : $request;
135+
}
95136
}

src/Control/CookieJar.php

+31-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
*/
1919
class CookieJar implements Cookie_Backend
2020
{
21-
2221
/**
2322
* Hold the cookies that were existing at time of instantiation (ie: The ones
2423
* sent to PHP by the browser)
@@ -168,9 +167,18 @@ protected function outputCookie(
168167
$secure = false,
169168
$httpOnly = true
170169
) {
170+
$sameSite = $this->getSameSite($name);
171+
Cookie::validateSameSite($sameSite);
171172
// if headers aren't sent, we can set the cookie
172173
if (!headers_sent($file, $line)) {
173-
return setcookie($name ?? '', $value ?? '', $expiry ?? 0, $path ?? '', $domain ?? '', $secure ?? false, $httpOnly ?? false);
174+
return setcookie($name ?? '', $value ?? '', [
175+
'expires' => $expiry ?? 0,
176+
'path' => $path ?? '',
177+
'domain' => $domain ?? '',
178+
'secure' => $this->cookieIsSecure($sameSite, $secure),
179+
'httponly' => $httpOnly ?? false,
180+
'samesite' => $sameSite,
181+
]);
174182
}
175183

176184
if (Cookie::config()->uninherited('report_errors')) {
@@ -180,4 +188,25 @@ protected function outputCookie(
180188
}
181189
return false;
182190
}
191+
192+
/**
193+
* Cookies must be secure if samesite is "None"
194+
*/
195+
private function cookieIsSecure(string $sameSite, $secure): bool
196+
{
197+
return $sameSite === 'None' ? true : (bool) $secure;
198+
}
199+
200+
/**
201+
* Get the correct samesite value - Session cookies use a different configuration variable.
202+
*
203+
* @deprecated 5.0 The relevant methods will include a `$sameSite` parameter instead.
204+
*/
205+
private function getSameSite(string $name): string
206+
{
207+
if ($name === session_name()) {
208+
return Session::config()->get('cookie_samesite');
209+
}
210+
return Cookie::config()->get('default_samesite');
211+
}
183212
}

src/Control/Session.php

+66-21
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ class Session
135135
*/
136136
private static $cookie_name_secure = 'SECSESSID';
137137

138+
/**
139+
* Must be "Strict", "Lax", or "None".
140+
* @config
141+
*/
142+
private static string $cookie_samesite = 'Lax';
143+
138144
/**
139145
* Name of session cache limiter to use.
140146
* Defaults to '' to disable cache limiter entirely.
@@ -283,31 +289,15 @@ public function start(HTTPRequest $request)
283289
throw new BadMethodCallException("Session has already started");
284290
}
285291

286-
$path = $this->config()->get('cookie_path');
287-
if (!$path) {
288-
$path = Director::baseURL();
289-
}
290-
$domain = $this->config()->get('cookie_domain');
291-
$secure = Director::is_https($request) && $this->config()->get('cookie_secure');
292292
$session_path = $this->config()->get('session_store_path');
293-
$timeout = $this->config()->get('timeout');
294-
295-
// Director::baseURL can return absolute domain names - this extracts the relevant parts
296-
// for the session otherwise we can get broken session cookies
297-
if (Director::is_absolute_url($path)) {
298-
$urlParts = parse_url($path ?? '');
299-
$path = $urlParts['path'];
300-
if (!$domain) {
301-
$domain = $urlParts['host'];
302-
}
303-
}
304293

305294
// If the session cookie is already set, then the session can be read even if headers_sent() = true
306295
// This helps with edge-case such as debugging.
307296
$data = [];
308297
if (!session_id() && (!headers_sent() || $this->requestContainsSessionId($request))) {
309298
if (!headers_sent()) {
310-
session_set_cookie_params($timeout ?: 0, $path, $domain ?: null, $secure, true);
299+
$cookieParams = $this->buildCookieParams($request);
300+
session_set_cookie_params($cookieParams);
311301

312302
$limiter = $this->config()->get('sessionCacheLimiter');
313303
if (isset($limiter)) {
@@ -323,16 +313,24 @@ public function start(HTTPRequest $request)
323313
// separate (less secure) session for non-HTTPS requests
324314
// if headers_sent() is true then it's best to throw the resulting error rather than risk
325315
// a security hole.
326-
if ($secure) {
316+
if ($cookieParams['secure']) {
327317
session_name($this->config()->get('cookie_name_secure'));
328318
}
329319

330320
session_start();
331321

332322
// Session start emits a cookie, but only if there's no existing session. If there is a session timeout
333323
// tied to this request, make sure the session is held for the entire timeout by refreshing the cookie age.
334-
if ($timeout && $this->requestContainsSessionId($request)) {
335-
Cookie::set(session_name(), session_id(), $timeout / 86400, $path, $domain ?: null, $secure, true);
324+
if ($cookieParams['lifetime'] && $this->requestContainsSessionId($request)) {
325+
Cookie::set(
326+
session_name(),
327+
session_id(),
328+
$cookieParams['lifetime'] / 86400,
329+
$cookieParams['path'],
330+
$cookieParams['domain'] ?: null,
331+
$cookieParams['secure'],
332+
true
333+
);
336334
}
337335
} else {
338336
// If headers are sent then we can't have a session_cache_limiter otherwise we'll get a warning
@@ -354,6 +352,53 @@ public function start(HTTPRequest $request)
354352
$this->started = true;
355353
}
356354

355+
/**
356+
* Build the parameters used for setting the session cookie.
357+
*/
358+
private function buildCookieParams(HTTPRequest $request): array
359+
{
360+
$timeout = $this->config()->get('timeout');
361+
$path = $this->config()->get('cookie_path');
362+
$domain = $this->config()->get('cookie_domain');
363+
if (!$path) {
364+
$path = Director::baseURL();
365+
}
366+
367+
// Director::baseURL can return absolute domain names - this extracts the relevant parts
368+
// for the session otherwise we can get broken session cookies
369+
if (Director::is_absolute_url($path)) {
370+
$urlParts = parse_url($path ?? '');
371+
$path = $urlParts['path'];
372+
if (!$domain) {
373+
$domain = $urlParts['host'];
374+
}
375+
}
376+
377+
$sameSite = static::config()->get('cookie_samesite');
378+
Cookie::validateSameSite($sameSite);
379+
$secure = $this->isCookieSecure($sameSite, Director::is_https($request));
380+
381+
return [
382+
'lifetime' => $timeout ?: 0,
383+
'path' => $path,
384+
'domain' => $domain ?: null,
385+
'secure' => $secure,
386+
'httponly' => true,
387+
'samesite' => $sameSite,
388+
];
389+
}
390+
391+
/**
392+
* Determines what the value for the `secure` cookie attribute should be.
393+
*/
394+
private function isCookieSecure(string $sameSite, bool $isHttps): bool
395+
{
396+
if ($sameSite === 'None') {
397+
return true;
398+
}
399+
return $isHttps && $this->config()->get('cookie_secure');
400+
}
401+
357402
/**
358403
* Destroy this session
359404
*

0 commit comments

Comments
 (0)