emielburgman/symfony-security-headers

Security response headers and a nonce-based Content-Security-Policy for Symfony, with the whole policy as configuration.

Maintainers

Package info

bitbucket.org/emielburgman/symfony-security-headers

Type:symfony-bundle

pkg:composer/emielburgman/symfony-security-headers

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

v1.0.1 2026-07-30 09:26 UTC

This package is not auto-updated.

Last update: 2026-07-30 15:13:13 UTC


README

Security response headers and a nonce-based Content-Security-Policy for Symfony. The mechanism is the package; the entire policy is configuration.

MIT.

Why

Two of four Symfony projects on this host sent security headers at all, and the two that did had each written the same three things by hand: mint one nonce per request, assemble directives into a header, don't clobber what a controller already set. Everything else about their policies differs, legitimately — X-Frame-Options: SAMEORIGIN vs DENY, frame-ancestors 'self' vs 'none', Stripe in form-action for one of them, Trusted Types disabled on one app's admin because TinyMCE cannot run under it. A bundle that owned those values would be wrong for someone immediately, so it owns none of them.

The nonce implementation is the one place the package picks a winner. One app minted it in a KernelEvents::REQUEST listener at priority 255; the other generated it lazily on first call. Lazy wins: with a listener, anything asking before it runs gets an empty string, and an empty nonce="" in a template looks exactly like a correct one until a browser refuses the script.

Install

composer require emielburgman/symfony-security-headers
// config/bundles.php
Emielburgman\SecurityHeaders\SecurityHeadersBundle::class => ['all' => true],

Then put the policy in config/packages/security_headers.yaml and use {{ csp_nonce() }} on every <script> the templates emit — including encore_entry_script_tags('app', null, '_default', { nonce: csp_nonce() }) and <meta name="csp-nonce" content="{{ csp_nonce() }}"> if the app uses Turbo (without that meta, scripts Turbo activates after a navigation carry a nonce the loaded document's header never allowed).

Configuration

security_headers:
    headers:
        X-Content-Type-Options: nosniff
        X-Frame-Options: SAMEORIGIN
        Referrer-Policy: strict-origin-when-cross-origin
        Cross-Origin-Opener-Policy: same-origin
        Strict-Transport-Security: 'max-age=63072000; includeSubDomains; preload'

    # Withhold HSTS over plain HTTP. On by default: a max-age pinned into your
    # browser from a local dev domain is a genuinely annoying thing to undo.
    hsts_requires_secure: true

    csp:
        directives:
            default-src: ["'self'"]
            # {nonce} is replaced with 'nonce-<value>' per response. A policy
            # that never names it never gets one.
            script-src: ['{nonce}', "'strict-dynamic'"]
            style-src: ["'self'", "'unsafe-inline'"]
            object-src: ["'none'"]
            # An empty list renders a valueless directive.
            upgrade-insecure-requests: []
            require-trusted-types-for: ["'script'"]
            trusted-types: ['default', 'goog#html']

        # Applied in order to any request whose path starts with the prefix.
        overrides:
            - path_prefix: /admin
              remove: ['require-trusted-types-for', 'trusted-types']
            - path_prefix: /loves
              append:
                  style-src: ['https://fonts.googleapis.com']
                  font-src: ['https://fonts.gstatic.com']

        # Only ever applied in that kernel environment, so an HMR server cannot
        # reach production.
        environment_appends:
            dev:
                connect-src: ['http://localhost:5173', 'ws://localhost:5173']

Directives render in configured order. Headers the application already set are left alone, including Content-Security-Policy itself, so a controller that needs its own policy for one response can just set it.

Key spelling

Write directive and header names either way — script-src or script_src, X-Frame-Options or X_Frame_Options. Symfony's Config component rewrites - to _ in prototyped array keys before any normalization closure can see them, so the bundle accepts both and emits dashes. Neither a CSP directive nor an HTTP header name legitimately contains an underscore, so the mapping is unambiguous.

This is not cosmetic: without it a policy configured with dashes reached the browser as default_src, script_src, frame_ancestors — a header that looks entirely plausible, is accepted silently, and enforces nothing. A CSP that is quietly inert is worse than no CSP, because its presence is what stops people checking. ConfigurationTest runs YAML-shaped config through the real extension for exactly this reason; the pure CspPolicy tests never crossed that layer and could not have caught it.

Two things worth knowing before you write a policy

'strict-dynamic' makes CSP3 browsers ignore your host allowlist in script-src. Every static <script src> then needs the nonce — the entry bundle's own tags included. Trust propagates to scripts a trusted script creates via createElement, but not to ones injected with document.write, which is why some ad and tag-manager paths break under it while the loader itself works fine. Keep the host list as a fallback for pre-CSP3 browsers if you like; it costs nothing and CSP3 ignores it.

A nonce disables 'unsafe-inline'. Under CSP2+ the moment a nonce appears in script-src, 'unsafe-inline' is ignored — which is the point, but it means every inline on* attribute stops working silently. There is nowhere to hang a nonce on onclick=, so a delete button whose onclick="return confirm(…)" was the only confirmation will delete on the first click with no prompt. Both adopting apps had exactly that bug. Grep for handler attributes before switching a policy over, and consider keeping a test that does.

Tests

composer install && vendor/bin/phpunit

CspPolicy is pure — a policy, a path and a nonce in, a string out — because a CSP is the header where being subtly wrong is invisible in both directions: a missing source silently kills a third-party script in the visitor's browser with nothing logged server-side, and an over-broad source silently removes the protection. Both are cheap to assert against a pure function and awkward through a kernel.