Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save thibaut-decherit/357b5ad003898c7ceb49e09ccd42f166 to your computer and use it in GitHub Desktop.
Save thibaut-decherit/357b5ad003898c7ceb49e09ccd42f166 to your computer and use it in GitHub Desktop.
Symfony - Response Header Setter (static, CSP and response authenticity)

Features

  • Event listener triggered on each response through onKernelResponse() method
  • Adds custom headers to the response
  • Support for "static" headers specified in config/response_header_setter/response_headers.yaml
    • Currently includes security / privacy related headers:
      • Cross-Origin-Opener-Policy
      • Cross-Origin-Resource-Policy
      • Referrer-Policy
      • Strict-Transport-Security (remember to register the domain on https://hstspreload.org/ or preload will not work)
      • X-Content-Type-Options
      • X-Frame-Options
      • X-Robots-Tag
      • X-XSS-Protection
  • Support for "dynamic" headers generated according to specific parameters (app environment, requested route...)
    • Content Security Policy header generator and setter:
      • Allows you to protect your users from malicious resources (e.g. malicious JavaScript code that could end up in your dependencies, like this one)
      • Two policy levels:
        • base policy applied everywhere unless overridden for specific paths
        • overrides policies applied on specific paths matching given patterns. Overrides are partial, meaning directives from base that you don't override still apply, so you don't have to copy/paste from base every directive you don't want to override. It also means that if you want more permissive directives for a specific path than those from base, you have to override them.
      • Customizable directives for each policy level through the config file config/response_header_setter/content_security_policy.yaml (modify existing ones, add your own)
      • Supports report-uri, two modes:
        • plain: specify the URL of your report-uri logger endpoint
        • match: specify the route name, router will handle URL generation. Can only be used if your report-uri logger is part of the same application
      • Supports nonce sources (see below for an implementation example)
      • Dev environment directives to generate (less secure) directives allowing Symfony Profiler to work properly. The Profiler relies on inline JS and CSS, which you are strongly advised to block in production environment to counter XSS. Current whitelists block these by default in production environment.
    • Response authenticity header: Based on SessionTokenService, adds a header containing a session token which can later be verified e.g. client-side via JS to ensure XHR responses originate from the legitimate application. Useful e.g. if you have an Axios response interceptor which should only do something if the expected response comes from your app and not from a third-party website (do note that it might be possible and way simpler to ensure the response comes from the same origin than the current page).

Code

config/response_header_setter/response_headers.yaml (edit if needed)

parameters:
  app.response_headers:
    Cross-Origin-Opener-Policy: same-origin

    # Warning: Should not be used on Chromium by websites serving PDF files because of a bug, see
    # https://bugs.chromium.org/p/chromium/issues/detail?id=1074261.
    Cross-Origin-Resource-Policy: same-origin

    Referrer-Policy: same-origin

    # Remember to register the domain on https://hstspreload.org/ or preload will not work.
    Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

    X-Content-Type-Options: nosniff
    X-Frame-Options: DENY
    X-Robots-Tag: none
    X-XSS-Protection: 1; mode=block

config/response_header_setter/content_security_policy.yaml.dist (edit as needed)

parameters:
  # See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#Directives
  app.content_security_policy:

    # OPTIONAL
    report_uri:
      # Either match (data must contain route name, use that if your logger is included in the app)
      # or plain (data must contain URL)
      mode: # match|plain
      data: # route_name|http://example.com/csp-report-uri-endpoint

    # These directives apply everywhere unless overridden for specific paths.
    directives:
      base:
        base-uri:
          - "'self'"
        default-src:
          - "'none'"
        connect-src:
          - "'self'"
        font-src:
          - "'self'"
        form-action:
          - "'self'"
        frame-ancestors:
          - "'none'"
        img-src:
          - "'self'"
          - 'data:'  # Required for Symfony SVGs (e.g. times icon in form input with validation error)
        script-src:
          - "'self'"
        style-src:
          - "'self'"

      # OPTIONAL
      # These directives apply on paths matching the `paths` patterns. Overrides are partial, meaning directives from
      # `base` that you don't override still apply, so you don't have to copy/paste from `base` every directive you
      # don't want to override. It also means that if you want more permissive directives for a specific path than those
      # from `base`, you have to override them.
      # Like config/packages/security.yaml security.access_control, parsing stops at the FIRST matching path found.
      # Again, like config/packages/security.yaml security.access_control, you have to handle the locale in each
      # pattern.
      overrides:
        - paths:
            - ^/%app.locale_supported_pattern%/register
            - ^/%app.locale_supported_pattern%/password-reset
          directives:
            connect-src:
              - "'self'"
              - https://example.com
        - paths:
            - ^/%app.locale_supported_pattern%/login
          directives:
            script-src:
              - "'self'"
              - "'unsafe-inline'"

config/services.yaml

imports:
  - resource: response_header_setter/response_headers.yaml
  - resource: response_header_setter/content_security_policy.yaml

# ...

App\EventListener\ResponseHeaderSetter\ResponseHeaderSetter:
  arguments:
    $kernelEnvironment: '%kernel.environment%'
    $simpleHeaders: '%app.response_headers%'
    $cspConfig: '%app.content_security_policy%'
  tags:
    - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse }

src/EventListener/ResponseHeaderSetter/ResponseHeaderSetter.php

<?php

namespace App\EventListener\ResponseHeaderSetter;

use App\EventListener\ResponseHeaderSetter\DynamicResponseHeaderSetter\CspHeaderSetter;
use App\EventListener\ResponseHeaderSetter\DynamicResponseHeaderSetter\ResponseAuthenticityHeaderSetter;
use App\Service\SessionTokenService;
use Exception;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;

/**
 * Class ResponseHeaderSetter
 *
 * Adds custom headers to every response. Dynamic headers are generated and set in their dedicated class within
 * App\EventListener\ResponseHeaderSetter\DynamicResponseHeaderSetter namespace.
 *
 * @package App\EventListener\ResponseHeaderSetter
 */
class ResponseHeaderSetter implements EventSubscriberInterface
{
    private string $kernelEnvironment;

    private array $simpleHeaders;

    private RequestStack $requestStack;

    private array $cspConfig;

    private SessionTokenService $sessionTokenService;

    private RouterInterface $router;

    private SessionInterface $session;

    /**
     * ResponseHeaderSetter constructor
     *
     * @param string $kernelEnvironment
     * @param array $simpleHeaders
     * @param RequestStack $requestStack
     * @param array $cspConfig
     * @param SessionTokenService $sessionTokenService
     * @param RouterInterface $router
     * @param SessionInterface $session
     */
    public function __construct(
        string $kernelEnvironment,
        array $simpleHeaders,
        RequestStack $requestStack,
        array $cspConfig,
        SessionTokenService $sessionTokenService,
        RouterInterface $router,
        SessionInterface $session
    )
    {
        $this->kernelEnvironment = $kernelEnvironment;
        $this->simpleHeaders = $simpleHeaders;
        $this->requestStack = $requestStack;
        $this->cspConfig = $cspConfig;
        $this->sessionTokenService = $sessionTokenService;
        $this->router = $router;
        $this->session = $session;
    }

    /**
     * @param ResponseEvent $event
     * @throws Exception
     */
    public function onKernelResponse(ResponseEvent $event): void
    {
        if ($this->supports($event) === false) {
            return;
        }

        $this->setDynamicHeaders($event);
        $this->setStaticHeaders($event);
    }

    /**
     * @param ResponseEvent $event
     * @return bool
     */
    private function supports(ResponseEvent $event): bool
    {
        /*
         * Required to avoid wasting resources by triggering the listener on sub-requests (e.g. when embedding
         * controllers in templates).
         */
        if ($event->isMainRequest() === false) {
            return false;
        }

        /*
         * Failsafe, in some rare instances $this->requestStack->getMainRequest() might return null.
         */
        if (is_null($this->requestStack->getMainRequest())) {
            return false;
        }

        return true;
    }

    /**
     * Sets headers requiring a dedicated class to generate them according to specific parameters (e.g. app environment,
     * requested route...).
     *
     * @param ResponseEvent $event
     * @throws Exception
     */
    private function setDynamicHeaders(ResponseEvent $event): void
    {
        $responseHeaders = $event->getResponse()->headers;

        (new CspHeaderSetter(
            $this->kernelEnvironment,
            $this->requestStack,
            $responseHeaders,
            $this->cspConfig,
            $this->sessionTokenService,
            $this->router
        ))->set();

        (new ResponseAuthenticityHeaderSetter($responseHeaders, $this->sessionTokenService))->set();
    }

    /**
     * Sets headers specified in config.yml.
     *
     * @param ResponseEvent $event
     */
    private function setStaticHeaders(ResponseEvent $event): void
    {
        $responseHeaders = $event->getResponse()->headers;
        foreach ($this->simpleHeaders as $headerName => $headerValue) {
            $responseHeaders->set($headerName, $headerValue);
        }
    }
    /**
     * @return array<string, mixed>
     */
    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::RESPONSE => 'onKernelResponse'];
    }
}

CspHeaderSetter

src/EventListener/ResponseHeaderSetter/DynamicResponseHeaderSetter/CspHeaderSetter.php

<?php

namespace App\EventListener\ResponseHeaderSetter\DynamicResponseHeaderSetter;

use App\Helper\StringHelper;
use App\Service\SessionTokenService;
use Exception;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\RouterInterface;

/**
 * Class CspHeaderSetter
 *
 * Adds Content Security Policy header to a response.
 * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
 *
 * @package App\EventListener\ResponseHeaderSetter\DynamicResponseHeaderSetter
 */
class CspHeaderSetter
{
    private string $kernelEnvironment;
    private RequestStack $requestStack;
    private ResponseHeaderBag $responseHeaders;
    private array $cspConfig;
    private SessionTokenService $sessionTokenService;
    private RouterInterface $router;
    private array $directives;

    public function __construct(
        string $kernelEnvironment,
        RequestStack $requestStack,
        ResponseHeaderBag $responseHeaders,
        array $cspConfig,
        SessionTokenService $sessionTokenService,
        RouterInterface $router
    )
    {
        $this->kernelEnvironment = $kernelEnvironment;
        $this->requestStack = $requestStack;
        $this->responseHeaders = $responseHeaders;
        $this->cspConfig = $cspConfig;
        $this->sessionTokenService = $sessionTokenService;
        $this->router = $router;
        $this->directives = [];
    }

    /**
     * @throws Exception
     */
    public function set(): void
    {
        $this->responseHeaders->set('Content-Security-Policy', $this->generate());
    }

    /**
     * Generates Content Security Policy header value.
     * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
     * @return string
     * @throws Exception
     * @throws InvalidArgumentException
     */
    private function generate(): string
    {
        $this->parseDirectivesConfig();

        $this->addReportUri();

        $this->addDevDirectivesIfDevEnvironment();

        $headerValue = '';

        foreach ($this->getDirectives() as $directiveName => $directiveContent) {
            if (empty($directiveContent)) {
                throw new InvalidArgumentException("$directiveName: Directives cannot be empty");
            } elseif (!is_array($directiveContent)) {
                throw new InvalidArgumentException("$directiveName: Directives must be of type array");
            } elseif (in_array(null, $directiveContent) || in_array("", $directiveContent)) {
                throw new InvalidArgumentException("$directiveName: Directives cannot contain null or empty values");
            }

            $directiveContentString = '';

            foreach ($directiveContent as $key => $directiveSource) {
                // Generates nonce according to $directiveSource if necessary.
                if (StringHelper::startsWith($directiveSource, 'CSP-nonce/')) {
                    $nonceKey = $directiveSource;
                    $nonce = $this->sessionTokenService->get($nonceKey);

                    $directiveContent[$key] = "'nonce-$nonce'";

                    /*
                     * We have to refresh the nonce on every request or it would not be a proper nonce.
                     */
                    $this->sessionTokenService->refresh($nonceKey);
                }

                $directiveContentString .= ' ' . $directiveContent[$key];
            }

            $directiveContentString = trim($directiveContentString);

            $directive = "$directiveName $directiveContentString; ";

            $headerValue .= $directive;
        }

        return $headerValue;
    }

    /**
     * Sets $this->directives with base directive and potential overrides if current path matches.
     * @throws InvalidArgumentException
     */
    private function parseDirectivesConfig(): void
    {
        if (empty($this->cspConfig['directives']['base'])) {
            throw new InvalidArgumentException(
                'app.content_security_policy.directives.base: At least one base directive must be defined'
            );
        }

        $matchingOverride = [];

        if (!empty($this->cspConfig['directives']['overrides'])) {
            $pathInfo = $this->requestStack->getMainRequest()->getPathInfo();

            // Parses overrides to find a path matching current path ($pathInfo).
            foreach ($this->cspConfig['directives']['overrides'] as $key => $override) {
                if (!is_array($override['paths']) || empty($override['paths'])) {
                    throw new InvalidArgumentException(
                        "app.content_security_policy.directives.overrides.paths ($key): paths must be an array and not empty"
                    );
                }

                foreach ($override['paths'] as $path) {
                    $path = str_replace('/', '\/', $path);
                    if (preg_match("/$path/", $pathInfo)) {
                        if (
                            !is_array($this->cspConfig['directives']['overrides'][$key]['directives'])
                            && empty($this->cspConfig['directives']['overrides'][$key]['directives'])
                        ) {
                            throw new InvalidArgumentException(
                                "app.content_security_policy.directives.overrides ($key): directives must be an array and not empty"
                            );
                        }

                        $matchingOverride = $this->cspConfig['directives']['overrides'][$key]['directives'];

                        // A match has been found, no need to parse the remaining paths and overrides.
                        break 2;
                    }
                }
            }
        }

        $this->setDirectives(array_merge($this->cspConfig['directives']['base'], $matchingOverride));
    }

    /**
     * @throws InvalidArgumentException
     */
    private function addReportUri(): void
    {
        if (!isset($this->cspConfig['report_uri'])) {
            return;
        }

        if (empty($this->cspConfig['report_uri']['mode'])) {
            throw new InvalidArgumentException('app.content_security_policy.report_uri.mode is undefined or empty');
        } elseif (empty($this->cspConfig['report_uri']['data'])) {
            throw new InvalidArgumentException('app.content_security_policy.report_uri.data is undefined or empty');
        }

        $directivesArray = $this->getDirectives();

        $directivesArray['report-uri'][] = match ($this->cspConfig['report_uri']['mode']) {
            'plain' => $this->cspConfig['report_uri']['data'],
            'match' => $this->router->generate($this->cspConfig['report_uri']['data']),
            default => throw new InvalidArgumentException(
                "app.content_security_policy.report_uri.mode must be of type string and contain 'plain' or 'match'"
            )
        };

        $this->setDirectives($directivesArray);
    }

    /**
     * Adds dev only directives if app is running in dev environment.
     */
    private function addDevDirectivesIfDevEnvironment(): void
    {
        if ($this->kernelEnvironment !== 'dev') {
            return;
        }

        $directivesArray = $this->getDirectives();

        /*
         * In dev env 'self' === http://localhost:port, NOT 127.0.0.1. You need to whitelist this IP if you dev at
         * http://127.0.0.1:port and not at http://localhost:port.
         */
        $baseUrl = $this->requestStack->getMainRequest()->getSchemeAndHttpHost();

        $scriptSrcDevDirectiveContent = [
            $baseUrl,
            "'unsafe-eval'",
            "'unsafe-inline'"
        ];

        $styleSrcDevDirectiveContent = [
            $baseUrl,
            "'unsafe-inline'"
        ];

        $directivesArray['connect-src'][] = $baseUrl;
        $directivesArray['font-src'][] = $baseUrl;
        $directivesArray['form-action'][] = $baseUrl;

        /*
         * Allows Symfony Profiler to work properly as it relies on inline JS and CSS.
         * array_unique() prevents CSP duplicate source (e.g. 'unsafe-inline' is already in your script-src policy)
         * error on some browsers (e.g. Firefox).
         */
        $directivesArray['script-src'] = array_unique(
            array_merge($directivesArray['script-src'], $scriptSrcDevDirectiveContent)
        );
        $directivesArray['style-src'] = array_unique(
            array_merge($directivesArray['style-src'], $styleSrcDevDirectiveContent)
        );

        $this->setDirectives($directivesArray);
    }

    private function getDirectives(): array
    {
        return $this->directives;
    }

    private function setDirectives(array $directives): CspHeaderSetter
    {
        $this->directives = $directives;

        return $this;
    }
}

CSP nonce implementation example

Here for a <script> tag and a script-src directive but it will also work for inline style.

Note: The token ID should be something meaningful, MUST be unique and MUST start with CSP-nonce/.

templates/example-folder/exemple.html.twig

<script type="text/javascript" nonce="{{ session_token('CSP-nonce/script-src/example-folder/exemple.html.twig') }}">
    // Your inline code.
</script>

config/response_header_setter/content_security_policy.yaml

parameters:
  app.content_security_policy:
    # [...]
        # [...]
            # [...]
            script-src:
            # [...]
              - "CSP-nonce/script-src/example-folder/exemple.html.twig"
            # [...]

And done. The event listener will automatically add the nonce (the session token) to the CSP header and refresh it on each request.

ResponseAuthenticityHeaderSetter

src/EventListener/ResponseHeaderSetter/DynamicResponseHeaderSetter/ResponseAuthenticityHeaderSetter.php

<?php

namespace App\EventListener\ResponseHeaderSetter\DynamicResponseHeaderSetter;

use App\Service\SessionTokenService;
use Exception;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;

/**
 * Might be overkill if you already check that the response is from the expected origin.
 */
class ResponseAuthenticityHeaderSetter
{
    private ResponseHeaderBag $responseHeaders;
    private SessionTokenService $sessionTokenService;

    public function __construct(
        ResponseHeaderBag $responseHeaders,
        SessionTokenService $sessionTokenService
    )
    {
        $this->responseHeaders = $responseHeaders;
        $this->sessionTokenService = $sessionTokenService;
    }

    /**
     * @throws Exception
     */
    public function set(): void
    {
        $this->responseHeaders->set(
            'app-response-authenticity',
            $this->sessionTokenService->get('response_authenticity')
        );
    }
}

templates/_twig_to_js_data.html.twig

{% set twig_to_js_global_data = {
    misc: {
        sessionTokens: {
            responseAuthenticity: session_token('response_authenticity')
        }
    }
} %}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment