Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feature #49306 [Security] Add logout configuration for Clear-Site-Dat…
…a header (maxbeckers)

This PR was merged into the 6.3 branch.

Discussion
----------

[Security] Add logout configuration for Clear-Site-Data header

| Q             | A
| ------------- | ---
| Branch?       | 6.3
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix #49266
| License       | MIT
| Doc PR        | symfony/symfony-docs#17900

Enhance security by issuing a Clear-Site-Data header on logout.
* [Clear-Site-Data](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data#sign_out_of_a_web_site) Documentation
* Example: https://www.w3.org/TR/clear-site-data/#example-signout

Default config is off.

Config example for all:
```yaml
security:
    # ...
    firewalls:
        main:
            # ...
            logout:
                path: app_logout
                clear_site_data:
                    - "*"
```
Instead of all with the ``*`` it's also possible to add a set of  ``cache``, ``cookies``, ``storage``, ``executionContexts``. For example without cookies it will look like this:
```yaml
security:
    # ...
    firewalls:
        main:
            # ...
            logout:
                path: app_logout
                clear_site_data:
                    - cache
                    - storage
                    - executionContexts
```

**TODO**
- [x] Doc PR symfony/symfony-docs#17900

Commits
-------

f9e76c1 [Security] Add logout configuration for Clear-Site-Data header
  • Loading branch information
fabpot committed Mar 10, 2023
2 parents ae7a65e + f9e76c1 commit 5c99187
Show file tree
Hide file tree
Showing 11 changed files with 235 additions and 2 deletions.
Expand Up @@ -251,6 +251,15 @@ private function addFirewallsSection(ArrayNodeDefinition $rootNode, array $facto
->scalarNode('path')->defaultValue('/logout')->end()
->scalarNode('target')->defaultValue('/')->end()
->booleanNode('invalidate_session')->defaultTrue()->end()
->arrayNode('clear_site_data')
->performNoDeepMerging()
->beforeNormalization()->ifString()->then(fn ($v) => $v ? array_map('trim', explode(',', $v)) : [])->end()
->enumPrototype()
->values([
'*', 'cache', 'cookies', 'storage', 'executionContexts',
])
->end()
->end()
->end()
->fixXmlConfig('delete_cookie')
->children()
Expand Down
Expand Up @@ -479,6 +479,13 @@ private function createFirewall(ContainerBuilder $container, string $id, array $
->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]);
}

// add clear site data listener
if ($firewall['logout']['clear_site_data'] ?? false) {
$container->setDefinition('security.logout.listener.clear_site_data.'.$id, new ChildDefinition('security.logout.listener.clear_site_data'))
->addArgument($firewall['logout']['clear_site_data'])
->addTag('kernel.event_subscriber', ['dispatcher' => $firewallEventDispatcherId]);
}

// register with LogoutUrlGenerator
$container
->getDefinition('security.logout_url_generator')
Expand Down
Expand Up @@ -171,9 +171,10 @@
</xsd:complexType>

<xsd:complexType name="logout">
<xsd:sequence>
<xsd:choice minOccurs="0" maxOccurs="unbounded">
<xsd:element name="delete-cookie" type="delete_cookie" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:element name="clear-site-data" type="clear_site_data" minOccurs="0" maxOccurs="unbounded" />
</xsd:choice>
<xsd:attribute name="csrf-parameter" type="xsd:string" />
<xsd:attribute name="csrf-token-generator" type="xsd:string" />
<xsd:attribute name="csrf-token-id" type="xsd:string" />
Expand Down Expand Up @@ -407,4 +408,14 @@
</xsd:simpleContent>
</xsd:complexType>

<xsd:simpleType name="clear_site_data">
<xsd:restriction base="xsd:string">
<xsd:enumeration value="*" />
<xsd:enumeration value="cache" />
<xsd:enumeration value="cookies" />
<xsd:enumeration value="storage" />
<xsd:enumeration value="executionContexts" />
</xsd:restriction>
</xsd:simpleType>

</xsd:schema>
Expand Up @@ -17,6 +17,7 @@
use Symfony\Component\Security\Http\Authentication\CustomAuthenticationSuccessHandler;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler;
use Symfony\Component\Security\Http\EventListener\ClearSiteDataLogoutListener;
use Symfony\Component\Security\Http\EventListener\CookieClearingLogoutListener;
use Symfony\Component\Security\Http\EventListener\DefaultLogoutListener;
use Symfony\Component\Security\Http\EventListener\SessionLogoutListener;
Expand Down Expand Up @@ -64,6 +65,9 @@
->set('security.logout.listener.session', SessionLogoutListener::class)
->abstract()

->set('security.logout.listener.clear_site_data', ClearSiteDataLogoutListener::class)
->abstract()

->set('security.logout.listener.cookie_clearing', CookieClearingLogoutListener::class)
->abstract()

Expand Down
Expand Up @@ -181,6 +181,7 @@ public function testFirewalls()
'invalidate_session' => true,
'delete_cookies' => [],
'enable_csrf' => null,
'clear_site_data' => [],
],
],
[
Expand Down Expand Up @@ -708,6 +709,13 @@ public function testFirewallListenerWithProvider()
$this->addToAssertionCount(1);
}

public function testFirewallLogoutClearSiteData()
{
$container = $this->getContainer('logout_clear_site_data');
$ClearSiteDataConfig = $container->getDefinition('security.firewall.map.config.main')->getArgument(12)['clear_site_data'];
$this->assertSame(['cookies', 'executionContexts'], $ClearSiteDataConfig);
}

protected function getContainer($file)
{
$file .= '.'.$this->getFileExtension();
Expand Down
@@ -0,0 +1,20 @@
<?php

$container->loadFromExtension('security', [
'providers' => [
'default' => ['id' => 'foo'],
],

'firewalls' => [
'main' => [
'provider' => 'default',
'form_login' => true,
'logout' => [
'clear-site-data' => [
'cookies',
'executionContexts',
],
],
],
],
]);
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>

<srv:container xmlns="http://symfony.com/schema/dic/security"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:srv="http://symfony.com/schema/dic/services"
xsi:schemaLocation="http://symfony.com/schema/dic/services
https://symfony.com/schema/dic/services/services-1.0.xsd
http://symfony.com/schema/dic/security
https://symfony.com/schema/dic/security/security-1.0.xsd">

<config>
<provider name="default" id="foo" />

<firewall name="main" provider="default">
<form-login />
<logout>
<clear-site-data>cookies</clear-site-data>
<clear-site-data>executionContexts</clear-site-data>
</logout>
</firewall>
</config>
</srv:container>
@@ -0,0 +1,13 @@
security:
providers:
default:
id: foo

firewalls:
main:
provider: default
form_login: true
logout:
clear_site_data:
- cookies
- executionContexts
Expand Up @@ -848,6 +848,48 @@ public function testConfigureCustomFirewallListener()
$this->assertContains('custom_firewall_listener_id', $firewallListeners);
}

public function testClearSiteDataLogoutListenerEnabled()
{
$container = $this->getRawContainer();

$firewallId = 'logout_firewall';
$container->loadFromExtension('security', [
'firewalls' => [
$firewallId => [
'logout' => [
'clear_site_data' => ['*'],
],
],
],
]);

$container->compile();

$this->assertTrue($container->has('security.logout.listener.clear_site_data.'.$firewallId));
$listenerArgument = $container->getDefinition('security.logout.listener.clear_site_data.'.$firewallId)->getArgument(0);
$this->assertSame(['*'], $listenerArgument);
}

public function testClearSiteDataLogoutListenerDisabled()
{
$container = $this->getRawContainer();

$firewallId = 'logout_firewall';
$container->loadFromExtension('security', [
'firewalls' => [
$firewallId => [
'logout' => [
'clear_site_data' => [],
],
],
],
]);

$container->compile();

$this->assertFalse($container->has('security.logout.listener.clear_site_data.'.$firewallId));
}

/**
* @group legacy
*/
Expand Down
@@ -0,0 +1,49 @@
<?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\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;

/**
* Handler for Clear-Site-Data header during logout.
*
* @author Max Beckers <beckers.maximilian@gmail.com>
*
* @final
*/
class ClearSiteDataLogoutListener implements EventSubscriberInterface
{
private const HEADER_NAME = 'Clear-Site-Data';

/**
* @param string[] $cookieValue The value for the Clear-Site-Data header.
* Can be '*' or a subset of 'cache', 'cookies', 'storage', 'executionContexts'.
*/
public function __construct(private readonly array $cookieValue)
{
}

public function onLogout(LogoutEvent $event): void
{
if (!$event->getResponse()?->headers->has(static::HEADER_NAME)) {
$event->getResponse()->headers->set(static::HEADER_NAME, implode(', ', array_map(fn ($v) => '"'.$v.'"', $this->cookieValue)));
}
}

public static function getSubscribedEvents(): array
{
return [
LogoutEvent::class => 'onLogout',
];
}
}
@@ -0,0 +1,48 @@
<?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\EventListener;

use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Event\LogoutEvent;
use Symfony\Component\Security\Http\EventListener\ClearSiteDataLogoutListener;

class ClearSiteDataLogoutListenerTest extends TestCase
{
/**
* @dataProvider provideClearSiteDataConfig
*/
public function testLogout(array $clearSiteDataConfig, string $expectedHeader)
{
$response = new Response();
$event = new LogoutEvent(new Request(), null);
$event->setResponse($response);

$listener = new ClearSiteDataLogoutListener($clearSiteDataConfig);

$headerCountBefore = $response->headers->count();

$listener->onLogout($event);

$this->assertEquals(++$headerCountBefore, $response->headers->count());

$this->assertNotNull($response->headers->get('Clear-Site-Data'));
$this->assertEquals($expectedHeader, $response->headers->get('Clear-Site-Data'));
}

public static function provideClearSiteDataConfig(): iterable
{
yield [['*'], '"*"'];
yield [['cache', 'cookies', 'storage', 'executionContexts'], '"cache", "cookies", "storage", "executionContexts"'];
}
}

0 comments on commit 5c99187

Please sign in to comment.