Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dalini/f234522bc935f885d1eda04df50bd9db to your computer and use it in GitHub Desktop.
Save dalini/f234522bc935f885d1eda04df50bd9db to your computer and use it in GitHub Desktop.
Symfony Response Header Setter (static and CSP)

Response Header Setter (static and CSP)

Response Header Setter (static and CSP)

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:
      • 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-XSS-Protection
  • Support for "dynamic" headers generated according to specific parameters (app environment, requested route...)
    • Currently includes a 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. Override can be partial, meaning directives from base that you don't override still apply, you don't have to copy/paste from base every directive you don't want to override
      • 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.

Nonce implementation example

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

We use the CSRF token generator of Symfony.

Note: The token ID should be something meaningful, MUST be unique and MUST start with CSP-nonce/. template/example-folder/exemple.html.twig

<script language="javascript" type="text/javascript" nonce="{{ csrf_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 CSRF token) to the CSP header and refresh it on each request.

Code

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

parameters:
  app.response_headers:
    Referrer-Policy: strict-origin-when-cross-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-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. Override can be 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 for them to still apply.
      # 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%'
    $requestStack: '@request_stack'
    $cspConfig: '%app.content_security_policy%'
    $csrfTokenManager: '@security.csrf.token_manager'
  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 Exception;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Csrf\CsrfTokenManager;

/**
 * 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
{
    /**
     * @var string
     */
    private string $kernelEnvironment;

    /**
     * @var array
     */
    private array $simpleHeaders;

    /**
     * @var RequestStack
     */
    private RequestStack $requestStack;

    /**
     * @var array
     */
    private array $cspConfig;

    /**
     * @var CsrfTokenManager
     */
    private CsrfTokenManager $csrfTokenManager;

    /**
     * @var RouterInterface
     */
    private RouterInterface $router;

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

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

        $responseHeaders = $event->getResponse()->headers;

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

    /**
     * @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->isMasterRequest() === false) {
            return false;
        }

        /*
         * In some rare instances this might return null.
         */
        if (is_null($this->requestStack->getMasterRequest())) {
            return false;
        }

        return true;
    }

    /**
     * Sets headers requiring a dedicated class to generate them according to specific parameters (e.g. app environment,
     * requested route...).
     *
     * @param ResponseHeaderBag $responseHeaders
     * @throws Exception
     */
    private function setDynamicHeaders(ResponseHeaderBag $responseHeaders)
    {
        (new CspHeaderSetter(
            $this->kernelEnvironment,
            $this->requestStack,
            $responseHeaders,
            $this->cspConfig,
            $this->csrfTokenManager,
            $this->router
        ))->set();
    }

    /**
     * Sets headers specified in config.yml.
     *
     * @param ResponseHeaderBag $responseHeaders
     */
    private function setStaticHeaders(ResponseHeaderBag $responseHeaders)
    {
        foreach ($this->simpleHeaders as $headerName => $headerValue) {
            $responseHeaders->set($headerName, $headerValue);
        }
    }
}

src/EventListener/ResponseHeaderSetter/DynamicResponseHeaderSetter/CspHeaderSetter.php

<?php

namespace App\EventListener\ResponseHeaderSetter\DynamicResponseHeaderSetter;

use App\Helper\StringHelper;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Csrf\CsrfTokenManager;

/**
 * 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
{
    /**
     * @var string
     */
    private string $kernelEnvironment;

    /**
     * @var RequestStack
     */
    private RequestStack $requestStack;

    /**
     * @var ResponseHeaderBag
     */
    private ResponseHeaderBag $responseHeaders;

    /**
     * @var array
     */
    private array $cspConfig;

    /**
     * @var CsrfTokenManager
     */
    private CsrfTokenManager $csrfTokenManager;

    /**
     * @var RouterInterface
     */
    private RouterInterface $router;

    /**
     * @var array
     */
    private array $directives;

    /**
     * CspHeaderSetter constructor
     *
     * @param string $kernelEnvironment
     * @param RequestStack $requestStack
     * @param ResponseHeaderBag $responseHeaders
     * @param array $cspConfig
     * @param CsrfTokenManager $csrfTokenManager
     * @param RouterInterface $router
     */
    public function __construct(
        string $kernelEnvironment,
        RequestStack $requestStack,
        ResponseHeaderBag $responseHeaders,
        array $cspConfig,
        CsrfTokenManager $csrfTokenManager,
        RouterInterface $router
    )
    {
        $this->kernelEnvironment = $kernelEnvironment;
        $this->requestStack = $requestStack;
        $this->responseHeaders = $responseHeaders;
        $this->cspConfig = $cspConfig;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->router = $router;
        $this->directives = [];
    }

    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 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/')) {
                    $csrfTokenId = $directiveSource;
                    $nonce = $this->csrfTokenManager->getToken($csrfTokenId);

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

                    /*
                     * CSRF token is used as nonce so we have to refresh it on every request or it would not be a proper
                     * nonce.
                     */
                    $this->csrfTokenManager->refreshToken($csrfTokenId);
                }

                $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['base'])) {
            throw new InvalidArgumentException(
                'content_security_policy.base: At least one base directive must be defined'
            );
        }

        $matchingOverride = [];

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

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

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

                        $matchingOverride = $this->cspConfig['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('report_uri.mode is undefined or empty');
        } elseif (empty($this->cspConfig['report_uri']['data'])) {
            throw new InvalidArgumentException('report_uri.data is undefined or empty');
        }

        $reportUri = '';

        switch ($this->cspConfig['report_uri']['mode']) {
            case 'plain':
                $reportUri = $this->cspConfig['report_uri']['data'];

                break;

            case 'match':
                $reportUri = $this->router->generate($this->cspConfig['report_uri']['data']);

                break;

            default:
                throw new InvalidArgumentException(
                    "report_uri.mode must be of type string and contain 'plain' or 'match'"
                );
        }

        $directivesArray = $this->getDirectives();

        $directivesArray['report-uri'][] = $reportUri;

        $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->getMasterRequest()->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);
    }

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

    /**
     * @param array $directives
     * @return CspHeaderSetter
     */
    private function setDirectives(array $directives): CspHeaderSetter
    {
        $this->directives = $directives;

        return $this;
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment