security: headers and safe loading
security checks the HTTP response headers your pages are served with and the markup that decides what those pages load. A missing Strict-Transport-Security or clickjacking header is invisible in the page and easy to lose in a hosting migration; an http:// script or a CDN file without integrity is one careless edit away. It is not in the default set.
Needs
Nothing to install. It reads the build (dist/) and starts no server. The header checks need a source of headers, see below; the HTML checks always run.
The HTML checks always read the shipped files, also with render.mode on: they are about what the server sends.
Run it
npx vidimus security # headers from dist/_headers
npx vidimus security --origin https://preview.example.com # headers from a live siteWhere headers come from
Static hosts set headers outside the HTML, so vidimus reads them from one of two places:
- A live origin, when
--origin(or theoriginconfig key) is set. vidimus sends aHEADrequest for every built page to the origin plus the page path, and retries withGETif the server answers405. Redirects on the same origin (such as/aboutto/about/) are followed, up to 5; a redirect to another origin is not, and its own headers are checked. A request that fails or takes longer than 20 seconds is an error finding,could not fetch headers, with the reason as detail. - A headers file in the build,
dist/_headersby default (security.file), in the Netlify and Cloudflare Pages format. Put it in the folder your framework copies to the build as is (public/in most of them).
With neither, the log says header checks skipped: no _headers in the build and no --origin to fetch headers from, the summary says headers from nowhere (skipped), and only the HTML checks run. The server.headers of the built-in static server are not used by this audit.
The _headers format
A line starting at column 0 is a URL pattern; the indented lines below it are Name: value headers for the paths it matches. Lines starting with # are comments.
# every page
/*
X-Content-Type-Options: nosniff
# one segment: /blog/hello/, not /blog/2024/hello/
/blog/:slug
Cache-Control: public, max-age=600
# full URLs work too; only the path is used
https://example.com/admin/*
! X-Frame-Options
Content-Security-Policy: frame-ancestors 'self' https://cms.example.com*matches anything, including/.:namematches one path segment.- A trailing slash is optional:
/aboutalso matches/about/. - Every rule that matches a path applies, in file order. When two rules set the same header, the values are joined with
,. - An indented
! Nameremoves a header that an earlier rule set for that path.
GitHub Pages and other hosts without custom headers
GitHub Pages cannot set custom response headers, and a <meta http-equiv> cannot replace HSTS, X-Content-Type-Options or frame-ancestors. Put the site behind a CDN that can (Cloudflare, for example) and audit it with --origin, or turn the header checks off and keep the HTML ones:
export default defineConfig({
security: {
require: {
'strict-transport-security': false,
'x-content-type-options': false,
'referrer-policy': false,
'permissions-policy': false,
},
clickjacking: false,
},
});What it checks
Headers (when a source exists)
Required headers (security.require) is a map of header name to a regular expression the value must match (case-insensitive). A missing header fails with missing <name> header; a value that does not match fails with <name> header does not match <regex> and shows the value. '' requires only presence, false turns a header off. For the four defaults the fix quotes a recommended value:
| Header | Must match | Recommended |
|---|---|---|
strict-transport-security | max-age of at least 31000000 seconds (about a year) | max-age=31536000; includeSubDomains |
x-content-type-options | exactly nosniff | nosniff |
referrer-policy | anything except unsafe-url and no-referrer-when-downgrade | strict-origin-when-cross-origin |
permissions-policy | any value | camera=(), microphone=(), geolocation=() |
The other header checks:
- Clickjacking (
security.clickjacking): aContent-Security-Policyheader with aframe-ancestorsdirective, orX-Frame-Options: DENYorSAMEORIGIN. Without either:no clickjacking protection: add CSP frame-ancestors or X-Frame-Options. A<meta>CSP does not count, since browsers ignoreframe-ancestorsthere. - Stack leaks (live origin only): an
X-Powered-Byheader (x-powered-by header leaks the stack) and aServerheader containing a version number (server header leaks a version) are warnings.
HTML (always)
- Unsafe CSP (
security.unsafeInline): thescript-srcof every CSP, from headers and<meta http-equiv="content-security-policy">, falling back todefault-src.'unsafe-eval'is a warning.'unsafe-inline'is a warning unless the same list has a nonce or asha256-/sha384-/sha512-hash, in which case browsers ignore it. The csp audit checks that those hashes match your inline scripts. - Mixed content (
security.mixedContent): anyhttp://URL in<script src>,<img src|srcset>,<source src|srcset>,<iframe src>,<video src|poster>,<audio src>,<object data>,<embed src>,<form action>, and<link href>withrelstylesheet,icon,preload,modulepreloadormanifest. Each URL is an error,mixed content: http://…. Plain<a href="http://…">links are not mixed content. - Subresource integrity (
security.sri): a<script src>or<link rel="stylesheet">on another origin (notsiteUrl) without anintegrityattribute is a warning,cross-origin <script> without integrity: https://…. Protocol-relative//cdn…URLs count.
security.txt (security.securityTxt)
RFC 9116 asks every site to publish /.well-known/security.txt so researchers know where to report a vulnerability. vidimus reads it from the build:
- Without
.well-known/security.txtor a rootsecurity.txt:no /.well-known/security.txt(warning). - Only a root
security.txt:security.txt is not under /.well-known/(warning). The root location is a legacy fallback. - No
Contactfield, or one that is not a URI (security@example.comwithoutmailto:, or anhttp://URL): error. - No
Expiresfield, more than one, one that is not a date, or one in the past (security.txt expired on …): error. AnExpiresmore than a year away is a warning, since the RFC recommends reviewing the file at least yearly.
A PGP-signed file is read as is; the signature block is ignored. When siteUrl has a path (https://example.github.io/project/), the check is skipped with a log line: the file belongs at the origin root, which the build does not own.
Example output
─── security ────────────────────────────────────────────────────
✖ missing strict-transport-security header
on: / /about/ /blog/ +37 more
→ Add "Strict-Transport-Security: max-age=31536000; includeSubDomains" to /* in _headers, or set security.require["strict-transport-security"] to false to skip.
✖ no clickjacking protection: add CSP frame-ancestors or X-Frame-Options
on: / /about/ /blog/ +37 more
→ Send "Content-Security-Policy: frame-ancestors 'self'" (or X-Frame-Options: DENY) as a header; a <meta> CSP can't set frame-ancestors.
⚠ CSP allows 'unsafe-inline' scripts
on: /contact/
→ Drop 'unsafe-inline' from script-src and allow inline scripts by 'sha256-…' hash or nonce, or move them to files.
⚠ cross-origin <script> without integrity: https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js
on: /contact/
→ Add integrity="sha384-…" and crossorigin="anonymous", or self-host the file.
✖ security: 40 pages, headers from _headers, 4 problem(s) (0.1s)With --origin, the fixes say "your host's header config" instead of /* in _headers.
Options
| Key | Default | Description |
|---|---|---|
security.file | '_headers' | headers file, relative to the build output |
security.require | the four headers above | header name → regex its value must match; '' requires presence, false skips |
security.clickjacking | true | require frame-ancestors or X-Frame-Options |
security.unsafeInline | true | warn on 'unsafe-inline' without hashes or nonces, and on 'unsafe-eval' |
security.mixedContent | true | fail on resources loaded over http:// |
security.sri | true | warn on cross-origin scripts and stylesheets without integrity |
security.securityTxt | true | check /.well-known/security.txt; warn when it is missing |
security.exclude | [] | URL path patterns to skip |
Top-level keys used: origin, siteUrl, exclude. Objects merge deeply, so adding a key to security.require keeps the defaults:
import { defineConfig } from 'vidimus';
export default defineConfig({
siteUrl: 'https://example.com',
audits: ['i18n', 'csp', 'a11y', 'links', 'r12s', 'security'],
security: {
require: {
'cross-origin-opener-policy': '^same-origin$',
'permissions-policy': 'camera=\\(\\)',
},
exclude: ['^/embed/'],
},
});Common fixes
A recommended dist/_headers that passes every default check. Adjust the CSP to what your pages load:
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: frame-ancestors 'self'
X-Frame-Options: DENYX-Frame-Options is redundant next to frame-ancestors in current browsers; keep it for old ones or drop it. If a page must be embeddable, detach and replace the header for it:
/embed/*
! X-Frame-Options
! Content-Security-Policy
Content-Security-Policy: frame-ancestors https://partner.example.comAdd integrity to a CDN script, or better, self-host it:
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"
integrity="sha384-…" crossorigin="anonymous" defer></script>An integrity hash only works with a pinned version: the file behind @3 changes with every release. Generate it with curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A.
For a live server, remove X-Powered-By in the framework (app.disable('x-powered-by') in Express) and hide the version with server_tokens off; in nginx or ServerTokens Prod in Apache. See CI for auditing a preview deployment with --origin.
A security.txt that passes, in public/.well-known/security.txt (renew Expires before it lapses):
Contact: mailto:security@example.com
Expires: 2027-06-30T00:00:00Z
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt