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 site
Where 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: DENY
X-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.com
Add 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