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.
| Header | Default value | Sent on |
|---|---|---|
X-Content-Type-Options | nosniff | every web response |
X-Frame-Options | SAMEORIGIN | every web response |
Referrer-Policy | strict-origin-when-cross-origin | every web response |
Strict-Transport-Security | max-age=31536000 | https requests of a live environment only |
Content-Security-Policy | none | only 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(orAPP_ENV), see Environments. NoincludeSubDomains, so subdomains of the customer's domain are not affected. - Only the
webgroup. 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:
'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 key | Env variable | Default | Effect |
|---|---|---|---|
security-headers.enabled | EVOLVE_SECURITY_HEADERS | true | false switches the middleware off entirely. |
security-headers.headers.X-Frame-Options | EVOLVE_FRAME_OPTIONS | SAMEORIGIN | Empty disables the header, for sites embedded on another domain. |
security-headers.hsts | EVOLVE_HSTS | max-age=31536000 | The HSTS value on live. Empty disables HSTS. |
security-headers.content-security-policy | EVOLVE_CSP | unset | The policy. Unset sends no CSP header. |
security-headers.content-security-policy-report-only | EVOLVE_CSP_REPORT_ONLY | false | true sends the policy as Content-Security-Policy-Report-Only. |
security-headers.headers | the three above | Add 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:
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:
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:
- 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 areport-uriorreport-toendpoint). 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. - Enforce. Remove
EVOLVE_CSP_REPORT_ONLY(or set it tofalse). The same policy is now sent asContent-Security-Policy.
A starting point for a site with Google Tag Manager and Vimeo embeds, to be tuned per site:
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=trueThe 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:
<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 isindex.php), so PHP responses carry exactly the middleware's values andEVOLVE_FRAME_OPTIONSstill decides for them.setifemptyalone is not enough: under PHP-FPM (mod_proxy_fcgi) Apache keeps the headers PHP sent in a table thatHeaderwithoutalwaysdoes not look at, so every header would go out twice. setifemptystill guards against a value set elsewhere in the Apache config. Needs Apache 2.4.10 or later (forexpr=).- The
.htaccessis deployed to every environment. The host condition keeps HSTS off staging, where the middleware would not send it either.SERVER_NAMEis the requested host without a port, sowww.example.ch:443matches as well. - A site embedded in an iframe drops the
X-Frame-Optionsline 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 name | Path |
|---|---|
evolve.cache.clear | POST /evolve/cache/clear |
evolve.cache.flush | POST /evolve/cache/flush |
evolve.cache.config.clear | POST /evolve/cache/config/clear |
evolve.cache.image.clear | POST /evolve/cache/image/clear |
evolve.cache.view.clear | POST /evolve/cache/view/clear |
evolve.cache.route.clear | POST /evolve/cache/route/clear |
evolve.cache.page.rebuild | POST /evolve/cache/page/rebuild |
evolve.cache.page.clear | POST /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:
<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 fetcheshttpsURLs whose host is listed inevolve.inline-styles-hosts(defaultfonts.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.
'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_TYPEunset andAPP_ENVnotproduction), 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-ageand refuse plain http for that host until it expires. Make sure the live host keeps a valid certificate. To withdraw it, sendEVOLVE_HSTS=max-age=0for 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 setin.htaccessreplaces the value the app sent or, under PHP-FPM, adds a second header next to it, so.envchanges seem to have no effect. Use the block above, which leaves PHP responses alone. - A header appears twice.
curl -sIshows twoX-Frame-OptionsorX-Content-Type-Optionslines on a PHP page: the starter'sHeader setlines, or asetifemptyblock without the<If>, are still in the.htaccess.
Verifying on a server
# 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.