8000 Add SameSite cookies to FrameWorkBundle by rpkamp · Pull Request #28168 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions UPGRADE-4.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ Form

* Deprecated calling `FormRenderer::searchAndRenderBlock` for fields which were already rendered.
Instead of expecting such calls to return empty strings, check if the field has already been rendered.

Before:
```twig
{% for field in fieldsWithPotentialDuplicates %}
{{ form_widget(field) }}
{% endfor %}
```

After:
```twig
{% for field in fieldsWithPotentialDuplicates if not field.rendered %}
Expand Down Expand Up @@ -83,6 +83,7 @@ FrameworkBundle
is UTF-8 (see kernel's `getCharset()` method), it is recommended to set it to `true`:
this will generate 404s for non-UTF-8 URLs, which are incompatible with you app anyway,
and will allow dumping optimized routers and using Unicode classes in requirements.
* Added support for the SameSite attribute for session cookies. It is highly recommended to set this setting (`framework.session.cookie_samesite`) to `lax` for increased security against CSRF attacks.

Messenger
---------
Expand Down
1 change: 1 addition & 0 deletions UPGRADE-5.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ FrameworkBundle
* Warming up a router in `RouterCacheWarmer` that does not implement the `WarmableInterface` is not supported anymore.
* The `RequestDataCollector` class has been removed. Use the `Symfony\Component\HttpKernel\DataCollector\RequestDataCollector` class instead.
* Removed `Symfony\Bundle\FrameworkBundle\Controller\Controller`. Use `Symfony\Bundle\FrameworkBundle\Controller\AbstractController` instead.
* Added support for the SameSite attribute for session cookies. It is highly recommended to set this setting (`framework.session.cookie_samesite`) to `lax` for increased security against CSRF attacks.

HttpFoundation
--------------
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ CHANGELOG
* Deprecated `Symfony\Bundle\FrameworkBundle\Controller\ControllerNameParser`
* The `container.service_locator` tag of `ServiceLocator`s is now autoconfigured.
* Add the ability to search a route in `debug:router`.
* Add the ability to use SameSite cookies for sessions.

4.0.0
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\Lock\Lock;
use Symfony\Component\Lock\Store\SemaphoreStore;
use Symfony\Component\Messenger\MessageBusInterface;
Expand Down Expand Up @@ -484,6 +485,7 @@ private function addSessionSection(ArrayNodeDefinition $rootNode)
->scalarNode('cookie_domain')->end()
->enumNode('cookie_secure')->values(array(true, false, 'auto'))->end()
->booleanNode('cookie_httponly')->defaultTrue()->end()
->enumNode('cookie_samesite')->values(array(null, Cookie::SAMESITE_LAX, Cookie::SAMESITE_STRICT))->defaultNull()->end()
->booleanNode('use_cookies')->end()
->scalarNode('gc_divisor')->end()
->scalarNode('gc_probability')->defaultValue(1)->end()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,7 @@ private function registerSessionConfiguration(array $config, ContainerBuilder $c
// session storage
$container->setAlias('session.storage', $config['storage_id'])->setPrivate(true);
$options = array('cache_limiter' => '0');
foreach (array('name', 'cookie_lifetime', 'cookie_path', 'cookie_domain', 'cookie_secure', 'cookie_httponly', 'use_cookies', 'gc_maxlifetime', 'gc_probability', 'gc_divisor') as $key) {
foreach (array('name', 'cookie_lifetime', 'cookie_path', 'cookie_domain', 'cookie_secure', 'cookie_httponly', 'cookie_samesite', 'use_cookies', 'gc_maxlifetime', 'gc_probability', 'gc_divisor') as $key) {
if (isset($config[$key])) {
$options[$key] = $config[$key];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ protected static function getBundleDefaultConfig()
'storage_id' => 'session.storage.native',
'handler_id' => 'session.handler.native_file',
'cookie_httponly' => true,
'cookie_samesite' => null,
'gc_probability' => 1,
'save_path' => '%kernel.cache_dir%/sessions',
'metadata_update_threshold' => '0',
Expand Down
59 changes: 59 additions & 0 deletions src/Symfony/Component/HttpFoundation/Session/SessionUtils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\HttpFoundation\Session;

/**
* Session utility functions.
*
* @author Nicolas Grekas <p@tchwork.com>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nicolas-grekas I've added you here since this was originally implemented by you but I've moved it here. Is that ok?

* @author Rémon van de Kamp <rpkamp@gmail.com>
*
* @internal
*/
final class SessionUtils
{
/**
* Find the session header amongst the headers that are to be sent, remove it, and return
* it so the caller can process it further.
*/
public static function popSessionCookie(string $sessionName, string $sessionId): ?string
{
$sessionCookie = null;
$sessionCookiePrefix = sprintf(' %s=', urlencode($sessionName));
$sessionCookieWithId = sprintf('%s%s;', $sessionCookiePrefix, urlencode($sessionId));
$otherCookies = array();
foreach (headers_list() as $h) {
if (0 !== stripos($h, 'Set-Cookie:')) {
continue;
}
if (11 === strpos($h, $sessionCookiePrefix, 11)) {
$sessionCookie = $h;

if (11 !== strpos($h, $sessionCookieWithId, 11)) {
$otherCookies[] = $h;
}
} else {
$otherCookies[] = $h;
}
}
if (null === $sessionCookie) {
return null;
}

header_remove('Set-Cookie');
foreach ($otherCookies as $h) {
header($h, false);
}

return $sessionCookie;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;

use Symfony\Component\HttpFoundation\Session\SessionUtils;

/**
* This abstract session handler provides a generic implementation
* of the PHP 7.0 SessionUpdateTimestampHandlerInterface,
Expand Down Expand Up @@ -121,31 +123,13 @@ public function destroy($sessionId)
if (!$this->sessionName) {
throw new \LogicException(sprintf('Session name cannot be empty, did you forget to call "parent::open()" in "%s"?.', \get_class($this)));
}
$sessionCookie = sprintf(' %s=', urlencode($this->sessionName));
$sessionCookieWithId = sprintf('%s%s;', $sessionCookie, urlencode($sessionId));
$sessionCookieFound = false;
$otherCookies = array();
foreach (headers_list() as $h) {
if (0 !== stripos($h, 'Set-Cookie:')) {
continue;
}
if (11 === strpos($h, $sessionCookie, 11)) {
$sessionCookieFound = true;

if (11 !== strpos($h, $sessionCookieWithId, 11)) {
$otherCookies[] = $h;
}
$cookie = SessionUtils::popSessionCookie($this->sessionName, $sessionId);
if (null === $cookie) {
if (\PHP_VERSION_ID < 70300) {
setcookie($this->sessionName, '', 0, ini_get('session.cookie_path'), ini_get('session.cookie_domain'), ini_get('session.cookie_secure'), ini_get('session.cookie_httponly'));
} else {
$otherCookies[] = $h;
}
}
if ($sessionCookieFound) {
header_remove('Set-Cookie');
foreach ($otherCookies as $h) {
header($h, false);
setcookie($this->sessionName, '', 0, ini_get('session.cookie_path'), ini_get('session.cookie_domain'), ini_get('session.cookie_secure'), ini_get('session.cookie_httponly'), ini_get('session.cookie_samesite'));
}
} else {
setcookie($this->sessionName, '', 0, ini_get('session.cookie_path'), ini_get('session.cookie_domain'), ini_get('session.cookie_secure'), ini_get('session.cookie_httponly'));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\HttpFoundation\Session\Storage;

use Symfony\Component\HttpFoundation\Session\SessionBagInterface;
use Symfony\Component\HttpFoundation\Session\SessionUtils;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\StrictSessionHandler;
use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy;
use Symfony\Component\HttpFoundation\Session\Storage\Proxy\SessionHandlerProxy;
Expand Down Expand Up @@ -48,6 +49,11 @@ class NativeSessionStorage implements SessionStorageInterface
*/
protected $metadataBag;

/**
* @var string|null
*/
private $emulateSameSite;

/**
* Depending on how you want the storage driver to behave you probably
* want to override this constructor entirely.
Expand All @@ -67,6 +73,7 @@ class NativeSessionStorage implements SessionStorageInterface
* cookie_lifetime, "0"
* cookie_path, "/"
* cookie_secure, ""
* cookie_samesite, null
* gc_divisor, "100"
* gc_maxlifetime, "1440"
* gc_probability, "1"
Expand Down Expand Up @@ -143,6 +150,13 @@ public function start()
throw new \RuntimeException('Failed to start the session');
}

if (null !== $this->emulateSameSite) {
$originalCookie = SessionUtils::popSessionCookie(session_name(), session_id());
if (null !== $originalCookie) {
header(sprintf('%s; SameSite=%s', $originalCookie, $this->emulateSameSite));
}
}

$this->loadSession();

return true;
Expand Down Expand Up @@ -347,7 +361,7 @@ public function setOptions(array $options)

$validOptions = array_flip(array(
'cache_expire', 'cache_limiter', 'cookie_domain', 'cookie_httponly',
'cookie_lifetime', 'cookie_path', 'cookie_secure',
'cookie_lifetime', 'cookie_path', 'cookie_secure', 'cookie_samesite',
'gc_divisor', 'gc_maxlifetime', 'gc_probability',
'lazy_write', 'name', 'referer_check',
'serialize_handler', 'use_strict_mode', 'use_cookies',
Expand All @@ -359,6 +373,12 @@ public function setOptions(array $options)

foreach ($options as $key => $value) {
if (isset($validOptions[$key])) {
if ('cookie_samesite' === $key && \PHP_VERSION_ID < 70300) {
// PHP < 7.3 does not support same_site cookies. We will emulate it in
// the start() method instead.
$this->emulateSameSite = $value;
continue;
}
ini_set('url_rewriter.tags' !== $key ? 'session.'.$key : $key, $value);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
open
validateId
read
doRead:
read

write
doWrite: foo|s:3:"bar";
close
Array
(
[0] => Content-Type: text/plain; charset=utf-8
[1] => Cache-Control: max-age=0, private, must-revalidate
[2] => Set-Cookie: sid=random_session_id; path=/; secure; HttpOnly; SameSite=lax
)
shutdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

require __DIR__.'/common.inc';

use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;

$storage = new NativeSessionStorage(array('cookie_samesite' => 'lax'));
$storage->setSaveHandler(new TestSessionHandler());
$storage->start();

$_SESSION = array('foo' => 'bar');

ob_start(function ($buffer) { return str_replace(session_id(), 'random_session_id', $buffer); });
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ public function testCookieOptions()
'cookie_httponly' => false,
);

if (\PHP_VERSION_ID >= 70300) {
$options['cookie_samesite'] = 'lax';
}

$this->getStorage($options);
$temp = session_get_cookie_params();
$gco = array();
Expand Down
0