Skip to content

Response headers (CSP, HSTS, …) are dropped when a page is prerendered #2

Open

Description

@jnadaud

Thanks for the bundle, prerendering from routes rather than content is a great fit for Symfony apps.

What happens

PageRenderer::render() keeps the response body and its format, and RenderedPage only carries content and format. Every other header the application sets on the response is discarded during generation: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, whether they come from NelmioSecurityBundle or from a kernel.response listener.

So an app that ships a strict policy when served by PHP loses it silently once exported: the "no runtime to exploit" gain comes with losing the defence-in-depth headers. GitHub Pages cannot set custom headers at all, but Cloudflare Pages, Netlify, Caddy and nginx can, and the bundle already knows what they should be.

Proposal (opt-in, backward compatible)

  1. RenderedPage gains an optional array $headers = [], filled by PageRenderer from an allowlist: Content-Security-Policy (and -Report-Only), Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, and the three Cross-Origin-* policies. Never Set-Cookie, Cache-Control or Vary, which describe the dynamic response rather than the page.
  2. A headers_file option (default null, e.g. '_headers') writes them at the end of a run, in the format Cloudflare Pages and Netlify read:
    /blog
      Content-Security-Policy: default-src 'self'; …
      Strict-Transport-Security: max-age=31536000; includeSubDomains
    
  3. Docs: add the headers to the Caddy and nginx examples, and note that GitHub Pages cannot set headers at all. A <meta http-equiv="Content-Security-Policy"> fallback exists, but browsers ignore frame-ancestors, report-uri and sandbox there, and HSTS cannot be set that way.

I'm happy to open a PR along these lines, with tests, if the direction suits you. Two questions first: should the allowlist be configurable, and would you rather keep the headers-file writer as an event listener in the docs than as a bundle option?

Activity

  1. mtarld commented on Oct 21, 2026

    @mtarld
    Member

    Agreed, these headers shouldn't be lost, and RenderedPage is the right place to carry them.

    I'd store all the response headers in RenderedPage::$headers though, and filter only when writing them out, as listeners may need others (X-Robots-Tag, Link, ...). No configurable allowlist for now IMO.

    Writing the headers file is a common need, so I think it belongs in the bundle, as a listener enabled by your headers_file option. Some things to take care of:

    • Partial runs: a --route/--path run should merge with the existing file rather than overwrite it.
    • Rule limit: AFAIK, Cloudflare Pages caps _headers at 100 rules, so shared headers should go into a single /* rule, with per-path rules only for the differing ones.
    • Caddy and nginx: documenting the headers in the config examples is enough for now.

    #5 added a GenerationFinishedEvent, dispatched once every page has been processed. The listener can collect headers on PageRenderedEvent and write the file from there.

    Last thing worth documenting: a nonce-based CSP gets frozen at build time, so hashes should be used instead.

    WDYT? Your PR is more than welcome 🙂

  2. jnadaud commented on Oct 21, 2026

    @jnadaud
    Author

    Thanks, that all makes sense. I went with your approach:

    • every header kept in RenderedPage::$headers, filtered only when the file is written (fixed list, no option);
    • a listener behind headers_file, on PageRenderedEvent + your GenerationFinishedEvent;
    • shared headers folded into a single /* rule for the 100-rule cap, per-path rules only for what differs;
    • partial runs merge: the generated rules sit between two comment markers, so a filtered run re-reads the pages already listed and hand-written rules outside the markers are kept;
    • Caddy/nginx documented, plus the nonce-vs-hash note.

    PR: #6

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions