From: Christian Flothmann <christian.flothmann@sensiolabs.de>
Date: Fri, 18 May 2018 19:27:18 +0200
Subject: clear CSRF tokens when the user is logged out

[CVE-2018-11406] https://symfony.com/blog/cve-2018-11406-csrf-token-fixation

Origin: backport, https://github.com/symfony/symfony/commit/4b91c171af18ea2fb40200b05bed325cbfaf5ba5
---
 .../RegisterCsrfTokenClearingLogoutHandlerPass.php | 42 ++++++++++++
 .../Bundle/SecurityBundle/SecurityBundle.php       |  2 +
 .../SecurityBundle/Tests/Functional/LogoutTest.php | 18 +++++
 .../LogoutWithoutSessionInvalidation/bundles.php   | 18 +++++
 .../LogoutWithoutSessionInvalidation/config.yml    | 26 ++++++++
 .../LogoutWithoutSessionInvalidation/routing.yml   |  5 ++
 .../TokenStorage/NativeSessionTokenStorageTest.php | 28 ++++++++
 .../Tests/TokenStorage/SessionTokenStorageTest.php | 27 ++++++++
 .../ClearableTokenStorageInterface.php             | 23 +++++++
 .../TokenStorage/NativeSessionTokenStorage.php     | 10 ++-
 .../Csrf/TokenStorage/SessionTokenStorage.php      | 14 +++-
 .../Http/Logout/CsrfTokenClearingLogoutHandler.php | 35 ++++++++++
 .../Logout/CsrfTokenClearingLogoutHandlerTest.php  | 76 ++++++++++++++++++++++
 13 files changed, 322 insertions(+), 2 deletions(-)
 create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfTokenClearingLogoutHandlerPass.php
 create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/bundles.php
 create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml
 create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/routing.yml
 create mode 100644 src/Symfony/Component/Security/Csrf/TokenStorage/ClearableTokenStorageInterface.php
 create mode 100644 src/Symfony/Component/Security/Http/Logout/CsrfTokenClearingLogoutHandler.php
 create mode 100644 src/Symfony/Component/Security/Http/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php

diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfTokenClearingLogoutHandlerPass.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfTokenClearingLogoutHandlerPass.php
new file mode 100644
index 0000000..d4d28ec
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfTokenClearingLogoutHandlerPass.php
@@ -0,0 +1,42 @@
+<?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\Bundle\SecurityBundle\DependencyInjection\Compiler;
+
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * @author Christian Flothmann <christian.flothmann@sensiolabs.de>
+ */
+class RegisterCsrfTokenClearingLogoutHandlerPass implements CompilerPassInterface
+{
+    public function process(ContainerBuilder $container)
+    {
+        if (!$container->has('security.logout_listener') || !$container->has('security.csrf.token_storage')) {
+            return;
+        }
+
+        $csrfTokenStorage = $container->findDefinition('security.csrf.token_storage');
+        $csrfTokenStorageClass = $container->getParameterBag()->resolveValue($csrfTokenStorage->getClass());
+
+        if (!is_subclass_of($csrfTokenStorageClass, 'Symfony\Component\Security\Csrf\TokenStorage\ClearableTokenStorageInterface')) {
+            return;
+        }
+
+        $container->register('security.logout.handler.csrf_token_clearing', 'Symfony\Component\Security\Http\Logout\CsrfTokenClearingLogoutHandler')
+            ->addArgument(new Reference('security.csrf.token_storage'))
+            ->setPublic(false);
+
+        $container->findDefinition('security.logout_listener')->addMethodCall('addHandler', array(new Reference('security.logout.handler.csrf_token_clearing')));
+    }
+}
diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php
index 61649f3..bee07f3 100644
--- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php
+++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php
@@ -11,6 +11,7 @@
 
 namespace Symfony\Bundle\SecurityBundle;
 
+use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterCsrfTokenClearingLogoutHandlerPass;
 use Symfony\Component\HttpKernel\Bundle\Bundle;
 use Symfony\Component\DependencyInjection\Compiler\PassConfig;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -58,5 +59,6 @@ class SecurityBundle extends Bundle
         $extension->addUserProviderFactory(new LdapFactory());
         $container->addCompilerPass(new AddSecurityVotersPass());
         $container->addCompilerPass(new AddSessionDomainConstraintPass(), PassConfig::TYPE_AFTER_REMOVING);
+        $container->addCompilerPass(new RegisterCsrfTokenClearingLogoutHandlerPass());
     }
 }
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php
index 7eeb7c2..d3c3b77 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/LogoutTest.php
@@ -31,4 +31,22 @@ class LogoutTest extends WebTestCase
 
         $this->assertNull($cookieJar->get('REMEMBERME'));
     }
+
+    public function testCsrfTokensAreClearedOnLogout()
+    {
+        $client = $this->createClient(array('test_case' => 'LogoutWithoutSessionInvalidation', 'root_config' => 'config.yml'));
+        $client->getContainer()->get('security.csrf.token_storage')->setToken('foo', 'bar');
+
+        $client->request('POST', '/login', array(
+            '_username' => 'johannes',
+            '_password' => 'test',
+        ));
+
+        $this->assertTrue($client->getContainer()->get('security.csrf.token_storage')->hasToken('foo'));
+        $this->assertSame('bar', $client->getContainer()->get('security.csrf.token_storage')->getToken('foo'));
+
+        $client->request('GET', '/logout');
+
+        $this->assertFalse($client->getContainer()->get('security.csrf.token_storage')->hasToken('foo'));
+    }
 }
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/bundles.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/bundles.php
new file mode 100644
index 0000000..d90f774
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/bundles.php
@@ -0,0 +1,18 @@
+<?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.
+ */
+
+use Symfony\Bundle\SecurityBundle\SecurityBundle;
+use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
+
+return array(
+    new FrameworkBundle(),
+    new SecurityBundle(),
+);
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml
new file mode 100644
index 0000000..d3fd8d0
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/config.yml
@@ -0,0 +1,26 @@
+imports:
+    - { resource: ./../config/framework.yml }
+
+security:
+    encoders:
+        Symfony\Component\Security\Core\User\User: plaintext
+
+    providers:
+        in_memory:
+            memory:
+                users:
+                    johannes: { password: test, roles: [ROLE_USER] }
+
+    firewalls:
+        default:
+            form_login:
+                check_path: login
+                remember_me: true
+                require_previous_session: false
+            remember_me:
+                always_remember_me: true
+                key: key
+            logout:
+                invalidate_session: false
+            anonymous: ~
+            stateless: true
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/routing.yml
new file mode 100644
index 0000000..1dddfca
--- /dev/null
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/LogoutWithoutSessionInvalidation/routing.yml
@@ -0,0 +1,5 @@
+login:
+    path: /login
+
+logout:
+    path: /logout
diff --git a/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/NativeSessionTokenStorageTest.php b/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/NativeSessionTokenStorageTest.php
index 7d3a537..52e6744 100644
--- a/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/NativeSessionTokenStorageTest.php
+++ b/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/NativeSessionTokenStorageTest.php
@@ -123,4 +123,32 @@ class NativeSessionTokenStorageTest extends \PHPUnit_Framework_TestCase
         $this->assertSame('TOKEN', $this->storage->removeToken('token_id'));
         $this->assertFalse($this->storage->hasToken('token_id'));
     }
+
+    public function testClearRemovesAllTokensFromTheConfiguredNamespace()
+    {
+        $this->storage->setToken('foo', 'bar');
+        $this->storage->clear();
+
+        $this->assertFalse($this->storage->hasToken('foo'));
+        $this->assertArrayNotHasKey(self::SESSION_NAMESPACE, $_SESSION);
+    }
+
+    public function testClearDoesNotRemoveSessionValuesFromOtherNamespaces()
+    {
+        $_SESSION['foo']['bar'] = 'baz';
+        $this->storage->clear();
+
+        $this->assertArrayHasKey('foo', $_SESSION);
+        $this->assertArrayHasKey('bar', $_SESSION['foo']);
+        $this->assertSame('baz', $_SESSION['foo']['bar']);
+    }
+
+    public function testClearDoesNotRemoveNonNamespacedSessionValues()
+    {
+        $_SESSION['foo'] = 'baz';
+        $this->storage->clear();
+
+        $this->assertArrayHasKey('foo', $_SESSION);
+        $this->assertSame('baz', $_SESSION['foo']);
+    }
 }
diff --git a/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/SessionTokenStorageTest.php b/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/SessionTokenStorageTest.php
index 9ba1ab2..967c1ec 100644
--- a/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/SessionTokenStorageTest.php
+++ b/src/Symfony/Component/Security/Csrf/Tests/TokenStorage/SessionTokenStorageTest.php
@@ -128,4 +128,31 @@ class SessionTokenStorageTest extends \PHPUnit_Framework_TestCase
 
         $this->assertSame('TOKEN', $this->storage->removeToken('token_id'));
     }
+
+    public function testClearRemovesAllTokensFromTheConfiguredNamespace()
+    {
+        $this->storage->setToken('foo', 'bar');
+        $this->storage->clear();
+
+        $this->assertFalse($this->storage->hasToken('foo'));
+        $this->assertFalse($this->session->has(self::SESSION_NAMESPACE.'/foo'));
+    }
+
+    public function testClearDoesNotRemoveSessionValuesFromOtherNamespaces()
+    {
+        $this->session->set('foo/bar', 'baz');
+        $this->storage->clear();
+
+        $this->assertTrue($this->session->has('foo/bar'));
+        $this->assertSame('baz', $this->session->get('foo/bar'));
+    }
+
+    public function testClearDoesNotRemoveNonNamespacedSessionValues()
+    {
+        $this->session->set('foo', 'baz');
+        $this->storage->clear();
+
+        $this->assertTrue($this->session->has('foo'));
+        $this->assertSame('baz', $this->session->get('foo'));
+    }
 }
diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/ClearableTokenStorageInterface.php b/src/Symfony/Component/Security/Csrf/TokenStorage/ClearableTokenStorageInterface.php
new file mode 100644
index 0000000..0d6f16b
--- /dev/null
+++ b/src/Symfony/Component/Security/Csrf/TokenStorage/ClearableTokenStorageInterface.php
@@ -0,0 +1,23 @@
+<?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\Security\Csrf\TokenStorage;
+
+/**
+ * @author Christian Flothmann <christian.flothmann@sensiolabs.de>
+ */
+interface ClearableTokenStorageInterface extends TokenStorageInterface
+{
+    /**
+     * Removes all CSRF tokens.
+     */
+    public function clear();
+}
diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/NativeSessionTokenStorage.php b/src/Symfony/Component/Security/Csrf/TokenStorage/NativeSessionTokenStorage.php
index 2620156..e4618fc 100644
--- a/src/Symfony/Component/Security/Csrf/TokenStorage/NativeSessionTokenStorage.php
+++ b/src/Symfony/Component/Security/Csrf/TokenStorage/NativeSessionTokenStorage.php
@@ -20,7 +20,7 @@ use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException;
  *
  * @author Bernhard Schussek <bschussek@gmail.com>
  */
-class NativeSessionTokenStorage implements TokenStorageInterface
+class NativeSessionTokenStorage implements ClearableTokenStorageInterface
 {
     /**
      * The namespace used to store values in the session.
@@ -108,6 +108,14 @@ class NativeSessionTokenStorage implements TokenStorageInterface
         return $token;
     }
 
+    /**
+     * {@inheritdoc}
+     */
+    public function clear()
+    {
+        unset($_SESSION[$this->namespace]);
+    }
+
     private function startSession()
     {
         if (PHP_VERSION_ID >= 50400) {
diff --git a/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorage.php b/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorage.php
index a6a6ea3..251aa94 100644
--- a/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorage.php
+++ b/src/Symfony/Component/Security/Csrf/TokenStorage/SessionTokenStorage.php
@@ -21,7 +21,7 @@ use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException;
  *
  * @author Bernhard Schussek <bschussek@gmail.com>
  */
-class SessionTokenStorage implements TokenStorageInterface
+class SessionTokenStorage implements ClearableTokenStorageInterface
 {
     /**
      * The namespace used to store values in the session.
@@ -106,4 +106,16 @@ class SessionTokenStorage implements TokenStorageInterface
 
         return $this->session->remove($this->namespace.'/'.$tokenId);
     }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function clear()
+    {
+        foreach (array_keys($this->session->all()) as $key) {
+            if (0 === strpos($key, $this->namespace.'/')) {
+                $this->session->remove($key);
+            }
+        }
+    }
 }
diff --git a/src/Symfony/Component/Security/Http/Logout/CsrfTokenClearingLogoutHandler.php b/src/Symfony/Component/Security/Http/Logout/CsrfTokenClearingLogoutHandler.php
new file mode 100644
index 0000000..ad6b888
--- /dev/null
+++ b/src/Symfony/Component/Security/Http/Logout/CsrfTokenClearingLogoutHandler.php
@@ -0,0 +1,35 @@
+<?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\Security\Http\Logout;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Csrf\TokenStorage\ClearableTokenStorageInterface;
+
+/**
+ * @author Christian Flothmann <christian.flothmann@sensiolabs.de>
+ */
+class CsrfTokenClearingLogoutHandler implements LogoutHandlerInterface
+{
+    private $csrfTokenStorage;
+
+    public function __construct(ClearableTokenStorageInterface $csrfTokenStorage)
+    {
+        $this->csrfTokenStorage = $csrfTokenStorage;
+    }
+
+    public function logout(Request $request, Response $response, TokenInterface $token)
+    {
+        $this->csrfTokenStorage->clear();
+    }
+}
diff --git a/src/Symfony/Component/Security/Http/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php
new file mode 100644
index 0000000..fe34eaa
--- /dev/null
+++ b/src/Symfony/Component/Security/Http/Tests/Logout/CsrfTokenClearingLogoutHandlerTest.php
@@ -0,0 +1,76 @@
+<?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\Security\Http\Tests\Logout;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\Session\Session;
+use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
+use Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage;
+use Symfony\Component\Security\Http\Logout\CsrfTokenClearingLogoutHandler;
+
+class CsrfTokenClearingLogoutHandlerTest extends TestCase
+{
+    private $session;
+    private $csrfTokenStorage;
+    private $csrfTokenClearingLogoutHandler;
+
+    protected function setUp()
+    {
+        $this->session = new Session(new MockArraySessionStorage());
+        $this->csrfTokenStorage = new SessionTokenStorage($this->session, 'foo');
+        $this->csrfTokenStorage->setToken('foo', 'bar');
+        $this->csrfTokenStorage->setToken('foobar', 'baz');
+        $this->csrfTokenClearingLogoutHandler = new CsrfTokenClearingLogoutHandler($this->csrfTokenStorage);
+    }
+
+    public function testCsrfTokenCookieWithSameNamespaceIsRemoved()
+    {
+        $this->assertSame('bar', $this->session->get('foo/foo'));
+        $this->assertSame('baz', $this->session->get('foo/foobar'));
+
+        $this->csrfTokenClearingLogoutHandler->logout(new Request(), new Response(), $this->getMockBuilder('Symfony\Component\Security\Core\Authentication\Token\TokenInterface')->getMock());
+
+        $this->assertFalse($this->csrfTokenStorage->hasToken('foo'));
+        $this->assertFalse($this->csrfTokenStorage->hasToken('foobar'));
+
+        $this->assertFalse($this->session->has('foo/foo'));
+        $this->assertFalse($this->session->has('foo/foobar'));
+    }
+
+    public function testCsrfTokenCookieWithDifferentNamespaceIsNotRemoved()
+    {
+        $barNamespaceCsrfSessionStorage = new SessionTokenStorage($this->session, 'bar');
+        $barNamespaceCsrfSessionStorage->setToken('foo', 'bar');
+        $barNamespaceCsrfSessionStorage->setToken('foobar', 'baz');
+
+        $this->assertSame('bar', $this->session->get('foo/foo'));
+        $this->assertSame('baz', $this->session->get('foo/foobar'));
+        $this->assertSame('bar', $this->session->get('bar/foo'));
+        $this->assertSame('baz', $this->session->get('bar/foobar'));
+
+        $this->csrfTokenClearingLogoutHandler->logout(new Request(), new Response(), $this->getMockBuilder('Symfony\Component\Security\Core\Authentication\Token\TokenInterface')->getMock());
+
+        $this->assertTrue($barNamespaceCsrfSessionStorage->hasToken('foo'));
+        $this->assertTrue($barNamespaceCsrfSessionStorage->hasToken('foobar'));
+        $this->assertSame('bar', $barNamespaceCsrfSessionStorage->getToken('foo'));
+        $this->assertSame('baz', $barNamespaceCsrfSessionStorage->getToken('foobar'));
+        $this->assertFalse($this->csrfTokenStorage->hasToken('foo'));
+        $this->assertFalse($this->csrfTokenStorage->hasToken('foobar'));
+
+        $this->assertFalse($this->session->has('foo/foo'));
+        $this->assertFalse($this->session->has('foo/foobar'));
+        $this->assertSame('bar', $this->session->get('bar/foo'));
+        $this->assertSame('baz', $this->session->get('bar/foobar'));
+    }
+}
