Skip to content

Security Headers 2.12 ​

Since 2.12 every web response of an Evolve site carries a baseline of security headers: the frontend, the panel and the image route alike. They are defence in depth. None of them fixes a vulnerability on its own, but together they stop the browser from guessing content types, stop other sites from framing the site, keep full URLs out of cross-origin referrers and pin live sites to https.

Default behaviour ​

The SecurityHeaders middleware is pushed into the web middleware group by the package. No project change is needed.

HeaderDefault valueSent on
X-Content-Type-Optionsnosniffevery web response
X-Frame-OptionsSAMEORIGINevery web response
Referrer-Policystrict-origin-when-cross-originevery web response
Strict-Transport-Securitymax-age=31536000https requests of a live environment only
Content-Security-Policynoneonly when EVOLVE_CSP is set

Rules the middleware follows:

  • Headers the app already set win. A header a controller, another middleware or a package set on the response is left alone. This is how a project overrides a single header for a single route.
  • Blank values are skipped. Setting a header's value to empty turns that header off.
  • HSTS only on live over https. Staging hosts often run on self-signed or preview certificates, and HSTS there would lock browsers out of the certificate click-through for a whole year. The environment type comes from EVOLVE_ENV_TYPE (or APP_ENV), see Environments. No includeSubDomains, so subdomains of the customer's domain are not affected.
  • Only the web group. Routes outside it, such as /api/health-check, get no headers from the middleware.

Configuration ​

Config lives under evolve.security-headers in config/evolve.php:

php
'security-headers' => [
    'enabled' => env('EVOLVE_SECURITY_HEADERS', true),
    'headers' => [
        'X-Content-Type-Options' => 'nosniff',
        'X-Frame-Options' => env('EVOLVE_FRAME_OPTIONS', 'SAMEORIGIN'),
        'Referrer-Policy' => 'strict-origin-when-cross-origin',
    ],
    'hsts' => env('EVOLVE_HSTS', 'max-age=31536000'),
    'content-security-policy' => env('EVOLVE_CSP'),
    'content-security-policy-report-only' => env('EVOLVE_CSP_REPORT_ONLY', false),
],
Config keyEnv variableDefaultEffect
security-headers.enabledEVOLVE_SECURITY_HEADERStruefalse switches the middleware off entirely.
security-headers.headers.X-Frame-OptionsEVOLVE_FRAME_OPTIONSSAMEORIGINEmpty disables the header, for sites embedded on another domain.
security-headers.hstsEVOLVE_HSTSmax-age=31536000The HSTS value on live. Empty disables HSTS.
security-headers.content-security-policyEVOLVE_CSPunsetThe policy. Unset sends no CSP header.
security-headers.content-security-policy-report-onlyEVOLVE_CSP_REPORT_ONLYfalsetrue sends the policy as Content-Security-Policy-Report-Only.
security-headers.headersthe three aboveAdd or change headers per project in a published config.

A project that publishes config/evolve.php can add any header to headers, for example a Permissions-Policy.

Embedding the site in an iframe ​

X-Frame-Options: SAMEORIGIN stops browsers from rendering the site inside an iframe on another origin. The panel's page preview is same-origin and keeps working. A site that is embedded elsewhere on purpose (a booking widget on a partner site, a microsite inside a customer portal) needs the header off:

ini
EVOLVE_FRAME_OPTIONS=

Check the .htaccess too

The evolve-starter public/.htaccess ends with Header set X-Content-Type-Options "nosniff" and Header set X-Frame-Options "SAMEORIGIN", and many older projects carry the same lines. Apache applies them to every response, including those from PHP. Depending on how PHP runs, they either replace what the app sent or, under PHP-FPM, add a second header next to it (X-Frame-Options: DENY and X-Frame-Options: SAMEORIGIN in one response). On such a site EVOLVE_FRAME_OPTIONS= alone changes nothing. Replace both lines with the block from Pages from the static page cache.

To allow only specific origins to frame the site, leave X-Frame-Options off and use the CSP directive frame-ancestors instead, which X-Frame-Options cannot express:

ini
EVOLVE_FRAME_OPTIONS=
EVOLVE_CSP="frame-ancestors 'self' https://portal.partner.ch"

Rolling out a Content-Security-Policy ​

A CSP is opt-in because Evolve themes rely on inline scripts and on tag managers that load further scripts at runtime. A strict policy set blindly breaks tracking, embeds and Alpine components. Roll it out in two steps:

  1. Report only. Set the policy with EVOLVE_CSP_REPORT_ONLY=true. Browsers enforce nothing and log every violation in the developer console (and send reports if the policy names a report-uri or report-to endpoint). Click through the site with consent granted, open pages with videos, maps, forms and the shop, and extend the policy until the console stays quiet.
  2. Enforce. Remove EVOLVE_CSP_REPORT_ONLY (or set it to false). The same policy is now sent as Content-Security-Policy.

A starting point for a site with Google Tag Manager and Vimeo embeds, to be tuned per site:

ini
EVOLVE_CSP="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://*.google-analytics.com https://player.vimeo.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com; frame-src https://player.vimeo.com https://www.youtube-nocookie.com; frame-ancestors 'self'"
EVOLVE_CSP_REPORT_ONLY=true

The policy is one header value, so quote it in the .env. 'unsafe-eval' is there because Alpine evaluates its expressions at runtime.

Pages from the static page cache ​

The page cache writes rendered pages to public/page-cache/<path>/index.html (and index.json for JSON requests), and the rewrite rules in public/.htaccess hand those files out directly. PHP never runs for a cache hit, so the middleware sets no headers on it. Images served from public/img bypass PHP the same way. On a site with APP_PAGE_CACHE=true the headers must also come from the web server.

Onlime runs Apache, so add this to public/.htaccess, outside the mod_rewrite block, and delete the starter's two Header set X-Content-Type-Options and Header set X-Frame-Options lines at the end of the file:

apache
<IfModule mod_headers.c>
    # Same baseline as the Evolve middleware, for responses PHP never sees
    # (page cache hits, public/img). PHP responses are skipped: the middleware
    # sets these itself, and Apache cannot see headers PHP-FPM sent.
    <If "%{REQUEST_FILENAME} !~ /\.php$/">
        Header setifempty X-Content-Type-Options "nosniff"
        Header setifempty X-Frame-Options "SAMEORIGIN"
        Header setifempty Referrer-Policy "strict-origin-when-cross-origin"

        # HSTS on the live domain over https only. Put the real host in.
        Header setifempty Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on' && %{SERVER_NAME} == 'www.example.ch'"
    </If>
</IfModule>
  • The <If> keeps the block off every request Apache hands to PHP (the resolved file is index.php), so PHP responses carry exactly the middleware's values and EVOLVE_FRAME_OPTIONS still decides for them. setifempty alone is not enough: under PHP-FPM (mod_proxy_fcgi) Apache keeps the headers PHP sent in a table that Header without always does not look at, so every header would go out twice.
  • setifempty still guards against a value set elsewhere in the Apache config. Needs Apache 2.4.10 or later (for expr=).
  • The .htaccess is deployed to every environment. The host condition keeps HSTS off staging, where the middleware would not send it either. SERVER_NAME is the requested host without a port, so www.example.ch:443 matches as well.
  • A site embedded in an iframe drops the X-Frame-Options line here as well.
  • Mirror an enforced CSP here once it is stable, with the same condition style.

Cache actions are POST only ​

In the same release the panel's cache actions stopped accepting GET requests. A GET that changes state can be triggered from any other site by a link or an image while an admin is logged in (cross-site request forgery).

Route namePath
evolve.cache.clearPOST /evolve/cache/clear
evolve.cache.flushPOST /evolve/cache/flush
evolve.cache.config.clearPOST /evolve/cache/config/clear
evolve.cache.image.clearPOST /evolve/cache/image/clear
evolve.cache.view.clearPOST /evolve/cache/view/clear
evolve.cache.route.clearPOST /evolve/cache/route/clear
evolve.cache.page.rebuildPOST /evolve/cache/page/rebuild
evolve.cache.page.clearPOST /evolve/cache/page/clear

The panel submits them through a hidden form with a CSRF token; the layout is unchanged. A GET to any old /evolve/cache/... URL redirects to the cache overview without doing anything, so links in views published before 2.12 do not fail, they just stop clearing. The info bar's cache icon on frontend pages opens the cache overview instead of flushing directly: the info bar is part of the page HTML, and a CSRF token there could end up in the static page cache.

A project with its own view or script that calls one of these routes must send a POST with the CSRF token:

blade
<form method="POST" action="{{ route('evolve.cache.page.clear') }}">
    @csrf
    <button type="submit">Clear page cache</button>
</form>

Server-side fetches ​

Two places fetched URLs on the server and were tightened in 2.12:

  • Util::toInlineStyles() only fetches https URLs whose host is listed in evolve.inline-styles-hosts (default fonts.googleapis.com), with timeouts and without following redirects. Any other URL, or a failed fetch, renders a plain <link rel="stylesheet">. Its argument must never be user input.
  • The store locator's geocoder rejects empty input and input over 255 characters without calling Google, times out after 5 seconds, and caches results under namespaced keys (locator.geocode.<sha1>). Hits are cached forever, "no results" for one hour, API errors not at all. Old cache entries are no longer read, so every address is geocoded once more after the update.
php
'inline-styles-hosts' => [
    'fonts.googleapis.com',
    'use.typekit.net',
],

Pitfalls ​

  • An iframe embed goes blank after the update. EVOLVE_FRAME_OPTIONS= in the server .env, and check the .htaccess (see above).
  • No HSTS on live. The environment type is not live (EVOLVE_ENV_TYPE unset and APP_ENV not production), or the request did not reach PHP as https. Behind a TLS-terminating proxy or load balancer, Laravel's trusted proxies must be configured, otherwise every request looks like http.
  • HSTS is sticky. Browsers remember it for the whole max-age and refuse plain http for that host until it expires. Make sure the live host keeps a valid certificate. To withdraw it, send EVOLVE_HSTS=max-age=0 for a while instead of removing the header.
  • The CSP breaks tracking silently. GTM loads scripts the policy may block, and the page itself looks fine. Always run report-only first and check that tags still fire (GTM preview, GA4 DebugView).
  • Web server and app disagree. A plain Header set in .htaccess replaces the value the app sent or, under PHP-FPM, adds a second header next to it, so .env changes seem to have no effect. Use the block above, which leaves PHP responses alone.
  • A header appears twice. curl -sI shows two X-Frame-Options or X-Content-Type-Options lines on a PHP page: the starter's Header set lines, or a setifempty block without the <If>, are still in the .htaccess.

Verifying on a server ​

bash
# A page rendered by PHP (the query string bypasses the page cache)
curl -sI "https://www.example.ch/?nocache=1" | grep -iE 'x-content-type|x-frame|referrer-policy|strict-transport|content-security'

# A page served from the page cache (request it twice; a cache hit sets no session cookie)
curl -sI https://www.example.ch/ | grep -iE 'x-content-type|x-frame|referrer-policy|strict-transport|set-cookie'

On live over https both show all four headers; on staging Strict-Transport-Security is missing, by design. securityheaders.com gives the same answer for a public URL.