@alxia/secure-headers 0.4.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js.map CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/nonce.ts", "../src/secure-headers.ts"],
3
+ "sources": ["../src/clear-site-data.ts", "../src/csp.ts", "../src/nonce.ts", "../src/page.ts", "../src/secure-headers.ts"],
4
4
  "sourcesContent": [
5
+ "/**\n * `clearSiteData`: `Clear-Site-Data` on a successful, same-site sign-out, as a\n * middleware for the sign-out path.\n */\nimport {\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\tmarkFactory,\n\tsettle,\n\twithHeaders,\n} from '@alxia/core';\n\nconst TYPES = [\n\t'cache',\n\t'cookies',\n\t'storage',\n\t'executionContexts',\n\t'clientHints',\n\t'prefetchCache',\n\t'prerenderCache',\n\t'*',\n] as const;\n\n/** A value of the `Clear-Site-Data` header, without its quotes. */\nexport type ClearSiteDataType = (typeof TYPES)[number];\n\nexport interface ClearSiteDataOptions {\n\t/** Defaults to `['POST']`: a GET to a sign-out URL clears nothing. */\n\treadonly methods?: readonly string[];\n\t/**\n\t * Also clear on a request another site made. Defaults to `false`: a\n\t * request whose `Sec-Fetch-Site` is not `same-origin` or `none`, or whose\n\t * `Origin` is not the request's own, clears nothing.\n\t */\n\treadonly crossSite?: boolean;\n\t/**\n\t * The origin a request must come from, such as `https://app.example.com`:\n\t * required behind a TLS-terminating proxy, where the request's own URL\n\t * says `http:`, for a browser that sends no `Sec-Fetch-Site`. A getter is\n\t * read at each request. Else the request's own origin.\n\t */\n\treadonly origin?: string | (() => string | undefined);\n}\n\n/**\n * What `clearSiteData()` returns: a middleware, for `app.use`, that adds\n * nothing to the context.\n */\nexport type ClearSiteData = Middleware<Empty, Promise<Response>>;\n\n/** `\"a\", \"b\"`: each name quoted, as the header and the messages write them. */\nfunction quoted(names: readonly string[]): string {\n\treturn names.map((name) => `\"${name}\"`).join(', ');\n}\n\n/**\n * True when the request came from another site, as far as it says.\n * `Sec-Fetch-Site`, which the browser sets and a proxy leaves alone, decides\n * alone when present; else `Origin` is compared with the app's own.\n */\nfunction isCrossSite(\n\trequest: Request,\n\town: string | (() => string | undefined) | undefined,\n): boolean {\n\tconst site = request.headers.get('sec-fetch-site');\n\tif (site !== null) return site !== 'same-origin' && site !== 'none';\n\tconst origin = request.headers.get('origin');\n\tif (origin === null) return false;\n\tconst set = typeof own === 'function' ? own() : own;\n\treturn origin !== (set ?? new URL(request.url).origin);\n}\n\n/**\n * `Clear-Site-Data` on a 2xx or 3xx answer to one of `methods` (`POST` by\n * default). A request clears nothing when it is refused (a 403, a failure) or\n * when another site made it (`Sec-Fetch-Site` other than `same-origin` or\n * `none`; without that header, a foreign `Origin`), unless `crossSite` is `true`; a request\n * with neither header, such as curl's, is cleared. Give it to `use(path, …)` for the sign-out path; React Router's\n * single fetch posts `.data` too, so mount both:\n *\n * ```ts\n * const cleared = clearSiteData(['cache']);\n * app.use('/sign-out', cleared).use('/sign-out.data', cleared);\n * ```\n *\n * Throws a `TypeError` at declaration on an empty `types`, a type that is not\n * one of `ClearSiteDataType`, and an empty `methods` or an empty method name.\n */\nexport function clearSiteData(\n\ttypes: readonly ClearSiteDataType[],\n\toptions: ClearSiteDataOptions = {},\n): ClearSiteData {\n\tif (types.length === 0) {\n\t\tthrow new TypeError(\n\t\t\t'clearSiteData(): no type is given: write at least one, such as [\"cache\"]',\n\t\t);\n\t}\n\tfor (const type of types) {\n\t\tif (!(TYPES as readonly string[]).includes(type)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`clearSiteData(): \"${type}\" is not a type: write one of ${quoted(TYPES)}`,\n\t\t\t);\n\t\t}\n\t}\n\tconst value = quoted(types);\n\tconst given = options.methods ?? ['POST'];\n\tif (given.length === 0 || given.some((method) => method === '')) {\n\t\tthrow new TypeError(\n\t\t\t'clearSiteData(): methods is empty or holds an empty method: write at least one, such as [\"POST\"], or leave methods out',\n\t\t);\n\t}\n\tconst methods = new Set(given.map((method) => method.toUpperCase()));\n\tconst crossSite = options.crossSite === true;\n\treturn defineMiddleware(async function clearSiteData(ctx, next) {\n\t\tconst response = await settle(ctx, next());\n\t\tif (!methods.has(ctx.request.method)) return response;\n\t\tif (response.status < 200 || response.status >= 400) return response;\n\t\tif (!crossSite && isCrossSite(ctx.request, options.origin)) return response;\n\t\treturn withHeaders(response, (headers) => {\n\t\t\tif (!headers.has('clear-site-data'))\n\t\t\t\theaders.set('clear-site-data', value);\n\t\t});\n\t});\n}\n\nmarkFactory(clearSiteData);\n",
6
+ "/**\n * `csp`: a `Content-Security-Policy` written as directives, checked once at\n * startup, instead of one long string.\n */\n\n/**\n * A directive's value: its sources; `true` for a bare directive\n * (`upgrade-insecure-requests`); `false` to leave it out.\n */\nexport type CspValue = readonly string[] | boolean;\n\n/** The directives a policy usually names, for completion. */\nexport type CspDirectiveName =\n\t| 'default-src'\n\t| 'script-src'\n\t| 'script-src-elem'\n\t| 'script-src-attr'\n\t| 'style-src'\n\t| 'style-src-elem'\n\t| 'style-src-attr'\n\t| 'img-src'\n\t| 'font-src'\n\t| 'connect-src'\n\t| 'media-src'\n\t| 'object-src'\n\t| 'frame-src'\n\t| 'child-src'\n\t| 'worker-src'\n\t| 'manifest-src'\n\t| 'base-uri'\n\t| 'form-action'\n\t| 'frame-ancestors'\n\t| 'upgrade-insecure-requests'\n\t| 'sandbox';\n\n/** A policy's directives: the known names complete, any other name is allowed. */\nexport type CspDirectives = {\n\treadonly [K in CspDirectiveName]?: CspValue | undefined;\n} & {\n\treadonly [name: string]: CspValue | undefined;\n};\n\nconst KEYWORDS = new Set([\n\t'self',\n\t'none',\n\t'unsafe-inline',\n\t'unsafe-eval',\n\t'unsafe-hashes',\n\t'strict-dynamic',\n\t'wasm-unsafe-eval',\n\t'report-sample',\n\t'inline-speculation-rules',\n]);\n\n/** A keyword, or a nonce or hash, written without its quotes. */\nfunction isUnquotedKeyword(source: string): boolean {\n\tconst lower = source.toLowerCase();\n\treturn (\n\t\tKEYWORDS.has(lower) ||\n\t\t/^(nonce|sha256|sha384|sha512)-[a-z0-9+/_-]+=*$/.test(lower)\n\t);\n}\n\n/**\n * The policy, written in insertion order: `name src src; name src`. `NONCE`\n * passes through as it is, for `secureHeaders({ nonce: true })` to replace.\n * Throws a `TypeError` at startup on a policy a browser would misread, such\n * as a keyword written without its quotes (`self` for `'self'`).\n *\n * ```ts\n * secureHeaders({\n * nonce: true,\n * contentSecurityPolicy: csp({\n * 'default-src': [\"'self'\"],\n * 'script-src': [\"'self'\", NONCE],\n * 'upgrade-insecure-requests': true,\n * }),\n * });\n * ```\n */\nexport function csp(directives: CspDirectives): string {\n\tconst written: string[] = [];\n\tfor (const [name, value] of Object.entries(directives)) {\n\t\tif (!/^[a-z][a-z0-9-]*$/.test(name)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`csp(): \"${name}\" is not a directive name: write lowercase letters, digits and hyphens, starting with a letter, as in \"script-src\"`,\n\t\t\t);\n\t\t}\n\t\tif (value === undefined || value === false) continue;\n\t\tif (value === true) {\n\t\t\twritten.push(name);\n\t\t\tcontinue;\n\t\t}\n\t\tif (value.length === 0) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`csp(): ${name} has no source: write [\"'none'\"] to allow nothing, or false to leave it out`,\n\t\t\t);\n\t\t}\n\t\tfor (const source of value) {\n\t\t\tif (/[;,\\s]/.test(source) || source === '') {\n\t\t\t\tthrow new TypeError(\n\t\t\t\t\t`csp(): ${name} has the source \"${source}\", which is empty or holds \";\", \",\" or a space: a source is one token, so split a list into several sources, and write a comma inside a URL as %2C`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t\tfor (const source of value) {\n\t\t\tif (isUnquotedKeyword(source)) {\n\t\t\t\tthrow new TypeError(\n\t\t\t\t\t`csp(): ${source} in ${name} is a keyword: write it quoted, '${source}'`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t\twritten.push(`${name} ${value.join(' ')}`);\n\t}\n\tif (written.length === 0) {\n\t\tthrow new TypeError(\n\t\t\t'csp(): no directive is left: give at least one, such as { \"default-src\": [\"\\'none\\'\"] }',\n\t\t);\n\t}\n\treturn written.join('; ');\n}\n",
5
7
  "/**\n * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes\n * in the policy, and how one is made.\n */\n\n/**\n * Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes\n * `'nonce-<value>'`, the request's own value. Without it, `nonce: true`\n * adds the nonce to `script-src` and `script-src-elem`.\n *\n * ```ts\n * secureHeaders({\n * nonce: true,\n * contentSecurityPolicy: `default-src 'self'; style-src 'self' ${NONCE}`,\n * });\n * ```\n */\nexport const NONCE = \"'nonce-{alxia}'\";\n\n/** What `secureHeaders({ nonce: true })` adds to the context of the routes after it. */\nexport interface NonceContext {\n\t/**\n\t * This request's nonce, base64, the one its `Content-Security-Policy`\n\t * allows: what a page puts on its `<script nonce>`.\n\t */\n\treadonly nonce: string;\n}\n\n/** The directives the nonce is added to when the policy does not place it. */\nconst SCRIPT_DIRECTIVES = new Set(['script-src', 'script-src-elem']);\n\n/**\n * The policy as a function of the nonce: split where `NONCE` stands, or\n * where it is added to the script directives. Throws when there is\n * nowhere to put it.\n */\nexport function policyWithNonce(policy: string): (nonce: string) => string {\n\tconst placed = policy.includes(NONCE)\n\t\t? policy\n\t\t: policy\n\t\t\t\t.split(';')\n\t\t\t\t.map((directive) => {\n\t\t\t\t\tconst name = directive.trim().split(/\\s+/)[0]?.toLowerCase() ?? '';\n\t\t\t\t\treturn SCRIPT_DIRECTIVES.has(name)\n\t\t\t\t\t\t? `${directive.trimEnd()} ${NONCE}`\n\t\t\t\t\t\t: directive;\n\t\t\t\t})\n\t\t\t\t.join(';');\n\tif (!placed.includes(NONCE)) {\n\t\tthrow new TypeError(\n\t\t\t`secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: write one, or place NONCE where the nonce goes (the policy is \"${policy}\")`,\n\t\t);\n\t}\n\tconst parts = placed.split(NONCE);\n\treturn (nonce) => parts.join(`'nonce-${nonce}'`);\n}\n\n/** 128 random bits, base64: a fresh nonce. */\nexport function freshNonce(): string {\n\tconst bytes = crypto.getRandomValues(new Uint8Array(16));\n\treturn btoa(String.fromCharCode(...bytes));\n}\n",
6
- "import {\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\tmarkFactory,\n\ttype Next,\n\tsettle,\n\twithHeaders,\n} from '@alxia/core';\nimport { freshNonce, NONCE, type NonceContext, policyWithNonce } from './nonce';\n\n/** A header's value, or `false` to leave it out. An empty value is refused. */\nexport type Setting = string | false;\n\nexport interface SecureHeadersOptions {\n\t/** Defaults to a policy for an API: nothing loads, nothing frames it. */\n\treadonly contentSecurityPolicy?: Setting;\n\treadonly strictTransportSecurity?: Setting;\n\treadonly xContentTypeOptions?: Setting;\n\treadonly xFrameOptions?: Setting;\n\treadonly referrerPolicy?: Setting;\n\treadonly crossOriginOpenerPolicy?: Setting;\n\treadonly crossOriginResourcePolicy?: Setting;\n\treadonly crossOriginEmbedderPolicy?: Setting;\n\treadonly originAgentCluster?: Setting;\n\treadonly xDnsPrefetchControl?: Setting;\n\treadonly xPermittedCrossDomainPolicies?: Setting;\n\t/** Off by default: a policy is the app's to write. */\n\treadonly permissionsPolicy?: Setting;\n\t/** Whether `X-Powered-By` and `Server` are removed. On by default. */\n\treadonly hidePoweredBy?: boolean;\n}\n\n/**\n * The nonce switch, beside `SecureHeadersOptions` rather than in it, so that\n * options typed by that interface still pick the plugin without a nonce.\n */\ninterface NonceOption {\n\t/**\n\t * A fresh nonce for each request, in `contentSecurityPolicy` and on the\n\t * context as `nonce`. Placed where the policy names `NONCE`, or added to\n\t * its `script-src`. Off by default.\n\t */\n\treadonly nonce?: boolean;\n}\n\n/**\n * What `secureHeaders()` returns: a middleware, for `app.use`, that sets\n * the headers on every response after it.\n */\nexport type SecureHeaders = Middleware<Empty, Promise<Response>>;\n\n/**\n * What `secureHeaders({ nonce: true })` returns: a middleware, for\n * `app.use`, whose routes after it read `nonce`, and which sets the header\n * with the same one.\n */\nexport type NonceMiddleware = Middleware<Empty, Promise<Next<NonceContext>>>;\n\nconst DEFAULTS = {\n\t'content-security-policy':\n\t\t\"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n\t'strict-transport-security': 'max-age=31536000; includeSubDomains',\n\t'x-content-type-options': 'nosniff',\n\t'x-frame-options': 'DENY',\n\t'referrer-policy': 'no-referrer',\n\t'cross-origin-opener-policy': 'same-origin',\n\t'cross-origin-resource-policy': 'same-origin',\n\t'cross-origin-embedder-policy': false,\n\t'origin-agent-cluster': '?1',\n\t'x-dns-prefetch-control': 'off',\n\t'x-permitted-cross-domain-policies': 'none',\n\t'permissions-policy': false,\n} as const satisfies Record<string, Setting>;\n\nconst OPTION: Record<keyof typeof DEFAULTS, keyof SecureHeadersOptions> = {\n\t'content-security-policy': 'contentSecurityPolicy',\n\t'strict-transport-security': 'strictTransportSecurity',\n\t'x-content-type-options': 'xContentTypeOptions',\n\t'x-frame-options': 'xFrameOptions',\n\t'referrer-policy': 'referrerPolicy',\n\t'cross-origin-opener-policy': 'crossOriginOpenerPolicy',\n\t'cross-origin-resource-policy': 'crossOriginResourcePolicy',\n\t'cross-origin-embedder-policy': 'crossOriginEmbedderPolicy',\n\t'origin-agent-cluster': 'originAgentCluster',\n\t'x-dns-prefetch-control': 'xDnsPrefetchControl',\n\t'x-permitted-cross-domain-policies': 'xPermittedCrossDomainPolicies',\n\t'permissions-policy': 'permissionsPolicy',\n};\n\n/**\n * Secure headers on every response, as a middleware: each response of the\n * routes after it and of a request no route matches — a 404, an error —\n * gets them. A header a route set itself is kept: a page that needs its\n * own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.\n *\n * ```ts\n * app.use(secureHeaders({ contentSecurityPolicy: \"default-src 'self'\" }));\n * ```\n *\n * With `nonce: true`, each request gets a nonce of its own, in the policy\n * and on the context of the routes declared after it:\n *\n * ```ts\n * app\n * .use(secureHeaders({ nonce: true, contentSecurityPolicy: \"script-src 'self'\" }))\n * .get('/', ({ nonce, reply }) => reply(200, `<script nonce=\"${nonce}\">…</script>`));\n * ```\n */\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & { readonly nonce: true },\n): NonceMiddleware;\nexport function secureHeaders(\n\toptions?: SecureHeadersOptions & { readonly nonce?: false },\n): SecureHeaders;\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & NonceOption = {},\n): SecureHeaders | NonceMiddleware {\n\tconst headers = fixedHeaders(options);\n\tconst hide = options.hidePoweredBy ?? true;\n\tconst policy = headers.get('content-security-policy');\n\tif (!options.nonce) {\n\t\tif (policy?.includes(NONCE)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true',\n\t\t\t);\n\t\t}\n\t\tconst set = setter(headers, hide);\n\t\treturn defineMiddleware(async function secureHeaders(ctx, next) {\n\t\t\treturn set(await settle(ctx, next()));\n\t\t});\n\t}\n\tif (policy === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header',\n\t\t);\n\t}\n\theaders.delete('content-security-policy');\n\tconst withNonce = policyWithNonce(policy);\n\tconst set = setter(headers, hide);\n\treturn defineMiddleware(async function secureHeaders(ctx, next) {\n\t\tconst added: NonceContext = { nonce: freshNonce() };\n\t\tconst response = await settle(ctx, next(added));\n\t\treturn set(response, withNonce(added.nonce)) as typeof response;\n\t});\n}\n\n/** Each header the options leave in, by name, with its value. Throws on an empty one. */\nfunction fixedHeaders(options: SecureHeadersOptions): Map<string, string> {\n\tconst headers = new Map<string, string>();\n\tfor (const [name, fallback] of Object.entries(DEFAULTS) as [\n\t\tkeyof typeof DEFAULTS,\n\t\tSetting,\n\t][]) {\n\t\tconst setting = options[OPTION[name]];\n\t\tif (typeof setting === 'string' && setting.trim() === '') {\n\t\t\t// An empty header says nothing a browser can act on: leave it out.\n\t\t\tthrow new TypeError(\n\t\t\t\t`secureHeaders: ${OPTION[name]} is empty; give false to leave the ${name} header out`,\n\t\t\t);\n\t\t}\n\t\tconst value = setting === undefined ? fallback : setting;\n\t\tif (typeof value === 'string') headers.set(name, value);\n\t}\n\treturn headers;\n}\n\n/** Every header the response lacks, then the policy given per request. */\nfunction setter(headers: ReadonlyMap<string, string>, hide: boolean) {\n\treturn (response: Response, policy?: string) =>\n\t\twithHeaders(response, (current) => {\n\t\t\tfor (const [name, value] of headers) {\n\t\t\t\tif (!current.has(name)) current.set(name, value);\n\t\t\t}\n\t\t\tif (policy !== undefined && !current.has('content-security-policy')) {\n\t\t\t\tcurrent.set('content-security-policy', policy);\n\t\t\t}\n\t\t\tif (hide) {\n\t\t\t\tcurrent.delete('x-powered-by');\n\t\t\t\tcurrent.delete('server');\n\t\t\t}\n\t\t});\n}\n\nmarkFactory(secureHeaders);\n"
8
+ "/**\n * `pageHeaders`: the headers of a server-rendered page that carries a\n * nonce, as one preset over `secureHeaders`.\n */\nimport { markFactory } from '@alxia/core';\nimport { type CspDirectives, csp } from './csp';\nimport { NONCE } from './nonce';\nimport {\n\ttype NonceMiddleware,\n\ttype SecureHeadersOptions,\n\tsecureHeaders,\n} from './secure-headers';\n\n/**\n * A strict policy for server-rendered pages with a nonce, one entry per\n * directive, each with why it holds what it holds. A new source goes in the\n * directive that loads it (through `pageHeaders`' `directives`), never in\n * `default-src`, and never `'unsafe-inline'` or `'unsafe-eval'` in `script-src`.\n */\nconst PAGE_POLICY = {\n\t// Anything a directive below does not name: this site only.\n\t'default-src': [\"'self'\"],\n\t// Only the scripts this response's nonce marks (the framework's inline\n\t// ones and its entry module), and whatever they load ('strict-dynamic':\n\t// the route chunks they import). 'self' is a fallback for browsers\n\t// without 'strict-dynamic'; they ignore it when a nonce is present.\n\t'script-src': [\"'self'\", NONCE, \"'strict-dynamic'\"],\n\t// <style> elements: this site's stylesheets and the ones that carry this\n\t// response's nonce (a UI library's nonced style elements, a dev client's).\n\t// No 'unsafe-inline' here: an injected <style> is the CSS attack\n\t// (attribute selectors reading a value out).\n\t'style-src-elem': [\"'self'\", NONCE],\n\t// `style` attributes (custom properties, positions, a UI library's and\n\t// React's own): no nonce covers an attribute, 'unsafe-inline' is the only\n\t// way. It allows no <style> element and no script.\n\t'style-src-attr': [\"'unsafe-inline'\"],\n\t// The icons, sprites and logo. No data: or blob: until a feature needs one.\n\t'img-src': [\"'self'\"],\n\t// Self-hosted fonts.\n\t'font-src': [\"'self'\"],\n\t// The framework's data requests, only to this site. In development, the\n\t// dev server's WebSocket too: 'self' covers ws: on the page's own host.\n\t'connect-src': [\"'self'\"],\n\t// The service worker, served by this site.\n\t'worker-src': [\"'self'\"],\n\t// The web app manifest, a route of this site.\n\t'manifest-src': [\"'self'\"],\n\t// No plugin content.\n\t'object-src': [\"'none'\"],\n\t// No `<base>`: a relative URL always means this site.\n\t'base-uri': [\"'none'\"],\n\t// Forms post to this site only (a submit before hydration included).\n\t'form-action': [\"'self'\"],\n\t// No page frames this one (clickjacking); X-Frame-Options covers older\n\t// browsers.\n\t'frame-ancestors': [\"'none'\"],\n} as const satisfies CspDirectives;\n\n/** `PAGE_POLICY`, frozen with each of its lists. */\nexport const PAGE_DIRECTIVES: typeof PAGE_POLICY = Object.freeze(\n\tObject.fromEntries(\n\t\tObject.entries(PAGE_POLICY).map(([name, sources]) => [\n\t\t\tname,\n\t\t\tObject.freeze(sources),\n\t\t]),\n\t),\n) as typeof PAGE_POLICY;\n\n/** Directives a page policy cannot do without. */\nconst REQUIRED = {\n\t'default-src': 'it covers frame, media and child sources',\n\t'base-uri':\n\t\t'it has no fallback, so an injected <base> would redirect nonced relative scripts',\n\t'object-src': 'it blocks plugin content',\n} as const;\n\nexport interface PageHeadersOptions\n\textends Omit<SecureHeadersOptions, 'contentSecurityPolicy'> {\n\t/**\n\t * Served over HTTPS: sends HSTS and adds `upgrade-insecure-requests`.\n\t * Required, and never read from the environment: `import.meta.env.PROD`\n\t * or your own flag.\n\t */\n\treadonly https: boolean;\n\t/**\n\t * Merged over `PAGE_DIRECTIVES`: a list replaces the preset's, `false`\n\t * removes a directive. An `upgrade-insecure-requests` given here wins\n\t * over `https`.\n\t */\n\treadonly directives?: CspDirectives;\n}\n\n/**\n * `secureHeaders({ nonce: true, … })` for server-rendered pages: the\n * `PAGE_DIRECTIVES` policy with a nonce per request, HSTS and\n * `upgrade-insecure-requests` only with `https`, `Referrer-Policy:\n * strict-origin-when-cross-origin`, and `Cache-Control: private, no-store`\n * (a cache must never store a nonce). Any other option passes through to\n * `secureHeaders`, and wins over the preset's.\n *\n * ```ts\n * app.use(pageHeaders({ https: import.meta.env.PROD }));\n * ```\n *\n * Throws a `TypeError` when `directives` removes `default-src`, `base-uri`\n * or `object-src`, and when the merged `script-src`, or a `script-src-elem`\n * given as a list, lacks `NONCE`: the page would not hydrate.\n */\nexport function pageHeaders(options: PageHeadersOptions): NonceMiddleware {\n\tconst { https, directives: asGiven = {}, ...given } = options;\n\tconst defined = (entries: object) =>\n\t\tObject.fromEntries(\n\t\t\tObject.entries(entries).filter(([, value]) => value !== undefined),\n\t\t);\n\tconst rest = defined(given) as typeof given;\n\tconst directives: CspDirectives = defined(asGiven);\n\tfor (const [name, why] of Object.entries(REQUIRED)) {\n\t\tconst value = directives[name];\n\t\tif (value === false || value === true) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`pageHeaders(): ${name} cannot be removed from a page policy: ${why}, so give it a list of sources`,\n\t\t\t);\n\t\t}\n\t}\n\tconst merged: CspDirectives = {\n\t\t...PAGE_DIRECTIVES,\n\t\t...directives,\n\t\t'upgrade-insecure-requests':\n\t\t\tdirectives['upgrade-insecure-requests'] ?? https,\n\t};\n\tconst scripts = merged['script-src'];\n\tif (!Array.isArray(scripts) || !scripts.includes(NONCE)) {\n\t\tthrow new TypeError(\n\t\t\t\"pageHeaders(): script-src has no NONCE: a page script runs only with the response's nonce, so write script-src with NONCE in directives, such as [\\\"'self'\\\", NONCE, \\\"'strict-dynamic'\\\"]\",\n\t\t);\n\t}\n\tconst elements = merged['script-src-elem'];\n\tif (Array.isArray(elements) && !elements.includes(NONCE)) {\n\t\tthrow new TypeError(\n\t\t\t'pageHeaders(): script-src-elem has no NONCE: script elements follow it instead of script-src, so add NONCE to it, or give false to remove it',\n\t\t);\n\t}\n\treturn secureHeaders({\n\t\t...(https ? {} : { strictTransportSecurity: false }),\n\t\treferrerPolicy: 'strict-origin-when-cross-origin',\n\t\tcacheControl: 'private, no-store',\n\t\t...rest,\n\t\tnonce: true,\n\t\tcontentSecurityPolicy: csp(merged),\n\t});\n}\n\nmarkFactory(pageHeaders);\n",
9
+ "import {\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\tmarkFactory,\n\ttype Next,\n\tsettle,\n\twithHeaders,\n} from '@alxia/core';\nimport { freshNonce, NONCE, type NonceContext, policyWithNonce } from './nonce';\n\n/** A header's value, or `false` to leave it out. An empty value is refused. */\nexport type Setting = string | false;\n\nexport interface SecureHeadersOptions {\n\t/** Defaults to a policy for an API: nothing loads, nothing frames it. */\n\treadonly contentSecurityPolicy?: Setting;\n\treadonly strictTransportSecurity?: Setting;\n\treadonly xContentTypeOptions?: Setting;\n\treadonly xFrameOptions?: Setting;\n\treadonly referrerPolicy?: Setting;\n\treadonly crossOriginOpenerPolicy?: Setting;\n\treadonly crossOriginResourcePolicy?: Setting;\n\treadonly crossOriginEmbedderPolicy?: Setting;\n\treadonly originAgentCluster?: Setting;\n\treadonly xDnsPrefetchControl?: Setting;\n\treadonly xPermittedCrossDomainPolicies?: Setting;\n\t/** Off by default: a policy is the app's to write. */\n\treadonly permissionsPolicy?: Setting;\n\t/**\n\t * Off by default. `'private, no-store'` for pages that carry a nonce or a\n\t * viewer's data: a cache must never store them.\n\t */\n\treadonly cacheControl?: Setting;\n\t/** Whether `X-Powered-By` and `Server` are removed. On by default. */\n\treadonly hidePoweredBy?: boolean;\n}\n\n/**\n * The nonce switch, beside `SecureHeadersOptions` rather than in it, so that\n * options typed by that interface still pick the plugin without a nonce.\n */\ninterface NonceOption {\n\t/**\n\t * A fresh nonce for each request, in `contentSecurityPolicy` and on the\n\t * context as `nonce`. Placed where the policy names `NONCE`, or added to\n\t * its `script-src`. Off by default.\n\t */\n\treadonly nonce?: boolean;\n}\n\n/**\n * What `secureHeaders()` returns: a middleware, for `app.use`, that sets\n * the headers on every response after it.\n */\nexport type SecureHeaders = Middleware<Empty, Promise<Response>>;\n\n/**\n * What `secureHeaders({ nonce: true })` returns: a middleware, for\n * `app.use`, whose routes after it read `nonce`, and which sets the header\n * with the same one.\n */\nexport type NonceMiddleware = Middleware<Empty, Promise<Next<NonceContext>>>;\n\nconst DEFAULTS = {\n\t'content-security-policy':\n\t\t\"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n\t'strict-transport-security': 'max-age=31536000; includeSubDomains',\n\t'x-content-type-options': 'nosniff',\n\t'x-frame-options': 'DENY',\n\t'referrer-policy': 'no-referrer',\n\t'cross-origin-opener-policy': 'same-origin',\n\t'cross-origin-resource-policy': 'same-origin',\n\t'cross-origin-embedder-policy': false,\n\t'origin-agent-cluster': '?1',\n\t'x-dns-prefetch-control': 'off',\n\t'x-permitted-cross-domain-policies': 'none',\n\t'permissions-policy': false,\n\t'cache-control': false,\n} as const satisfies Record<string, Setting>;\n\nconst OPTION: Record<keyof typeof DEFAULTS, keyof SecureHeadersOptions> = {\n\t'content-security-policy': 'contentSecurityPolicy',\n\t'strict-transport-security': 'strictTransportSecurity',\n\t'x-content-type-options': 'xContentTypeOptions',\n\t'x-frame-options': 'xFrameOptions',\n\t'referrer-policy': 'referrerPolicy',\n\t'cross-origin-opener-policy': 'crossOriginOpenerPolicy',\n\t'cross-origin-resource-policy': 'crossOriginResourcePolicy',\n\t'cross-origin-embedder-policy': 'crossOriginEmbedderPolicy',\n\t'origin-agent-cluster': 'originAgentCluster',\n\t'x-dns-prefetch-control': 'xDnsPrefetchControl',\n\t'x-permitted-cross-domain-policies': 'xPermittedCrossDomainPolicies',\n\t'permissions-policy': 'permissionsPolicy',\n\t'cache-control': 'cacheControl',\n};\n\n/**\n * Secure headers on every response, as a middleware: each response of the\n * routes after it and of a request no route matches — a 404, an error —\n * gets them. A header a route set itself is kept: a page that needs its\n * own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.\n *\n * ```ts\n * app.use(secureHeaders({ contentSecurityPolicy: \"default-src 'self'\" }));\n * ```\n *\n * With `nonce: true`, each request gets a nonce of its own, in the policy\n * and on the context of the routes declared after it:\n *\n * ```ts\n * app\n * .use(secureHeaders({ nonce: true, contentSecurityPolicy: \"script-src 'self'\" }))\n * .get('/', ({ nonce, reply }) => reply(200, `<script nonce=\"${nonce}\">…</script>`));\n * ```\n */\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & { readonly nonce: true },\n): NonceMiddleware;\nexport function secureHeaders(\n\toptions?: SecureHeadersOptions & { readonly nonce?: false },\n): SecureHeaders;\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & NonceOption = {},\n): SecureHeaders | NonceMiddleware {\n\tconst headers = fixedHeaders(options);\n\tconst hide = options.hidePoweredBy ?? true;\n\tconst policy = headers.get('content-security-policy');\n\tif (!options.nonce) {\n\t\tif (policy?.includes(NONCE)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true',\n\t\t\t);\n\t\t}\n\t\tconst set = setter(headers, hide);\n\t\treturn defineMiddleware(async function secureHeaders(ctx, next) {\n\t\t\treturn set(await settle(ctx, next()));\n\t\t});\n\t}\n\tif (policy === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header',\n\t\t);\n\t}\n\theaders.delete('content-security-policy');\n\tconst withNonce = policyWithNonce(policy);\n\tconst set = setter(headers, hide);\n\treturn defineMiddleware(async function secureHeaders(ctx, next) {\n\t\tconst added: NonceContext = { nonce: freshNonce() };\n\t\tconst response = await settle(ctx, next(added));\n\t\treturn set(response, withNonce(added.nonce)) as typeof response;\n\t});\n}\n\n/** Each header the options leave in, by name, with its value. Throws on an empty one. */\nfunction fixedHeaders(options: SecureHeadersOptions): Map<string, string> {\n\tconst headers = new Map<string, string>();\n\tfor (const [name, fallback] of Object.entries(DEFAULTS) as [\n\t\tkeyof typeof DEFAULTS,\n\t\tSetting,\n\t][]) {\n\t\tconst setting = options[OPTION[name]];\n\t\tif (typeof setting === 'string' && setting.trim() === '') {\n\t\t\t// An empty header says nothing a browser can act on: leave it out.\n\t\t\tthrow new TypeError(\n\t\t\t\t`secureHeaders: ${OPTION[name]} is empty; give false to leave the ${name} header out`,\n\t\t\t);\n\t\t}\n\t\tconst value = setting === undefined ? fallback : setting;\n\t\tif (typeof value === 'string') headers.set(name, value);\n\t}\n\treturn headers;\n}\n\n/** Every header the response lacks, then the policy given per request. */\nfunction setter(headers: ReadonlyMap<string, string>, hide: boolean) {\n\treturn (response: Response, policy?: string) =>\n\t\twithHeaders(response, (current) => {\n\t\t\tfor (const [name, value] of headers) {\n\t\t\t\tif (!current.has(name)) current.set(name, value);\n\t\t\t}\n\t\t\tif (policy !== undefined && !current.has('content-security-policy')) {\n\t\t\t\tcurrent.set('content-security-policy', policy);\n\t\t\t}\n\t\t\tif (hide) {\n\t\t\t\tcurrent.delete('x-powered-by');\n\t\t\t\tcurrent.delete('server');\n\t\t\t}\n\t\t});\n}\n\nmarkFactory(secureHeaders);\n"
7
10
  ],
8
- "mappings": ";AAiBO,IAAM,QAAQ;AAYrB,IAAM,oBAAoB,IAAI,IAAI,CAAC,cAAc,iBAAiB,CAAC;AAO5D,SAAS,eAAe,CAAC,QAA2C;AAAA,EAC1E,MAAM,SAAS,OAAO,SAAS,KAAK,IACjC,SACA,OACC,MAAM,GAAG,EACT,IAAI,CAAC,cAAc;AAAA,IACnB,MAAM,OAAO,UAAU,KAAK,EAAE,MAAM,KAAK,EAAE,IAAI,YAAY,KAAK;AAAA,IAChE,OAAO,kBAAkB,IAAI,IAAI,IAC9B,GAAG,UAAU,QAAQ,KAAK,UAC1B;AAAA,GACH,EACA,KAAK,GAAG;AAAA,EACZ,IAAI,CAAC,OAAO,SAAS,KAAK,GAAG;AAAA,IAC5B,MAAM,IAAI,UACT,8JAA8J,UAC/J;AAAA,EACD;AAAA,EACA,MAAM,QAAQ,OAAO,MAAM,KAAK;AAAA,EAChC,OAAO,CAAC,UAAU,MAAM,KAAK,UAAU,QAAQ;AAAA;AAIzC,SAAS,UAAU,GAAW;AAAA,EACpC,MAAM,QAAQ,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AAAA,EACvD,OAAO,KAAK,OAAO,aAAa,GAAG,KAAK,CAAC;AAAA;;AC5D1C;AAAA;AAAA;AAAA;AAAA;AAAA;AA2DA,IAAM,WAAW;AAAA,EAChB,2BACC;AAAA,EACD,6BAA6B;AAAA,EAC7B,0BAA0B;AAAA,EAC1B,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,8BAA8B;AAAA,EAC9B,gCAAgC;AAAA,EAChC,gCAAgC;AAAA,EAChC,wBAAwB;AAAA,EACxB,0BAA0B;AAAA,EAC1B,qCAAqC;AAAA,EACrC,sBAAsB;AACvB;AAEA,IAAM,SAAoE;AAAA,EACzE,2BAA2B;AAAA,EAC3B,6BAA6B;AAAA,EAC7B,0BAA0B;AAAA,EAC1B,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,8BAA8B;AAAA,EAC9B,gCAAgC;AAAA,EAChC,gCAAgC;AAAA,EAChC,wBAAwB;AAAA,EACxB,0BAA0B;AAAA,EAC1B,qCAAqC;AAAA,EACrC,sBAAsB;AACvB;AA2BO,SAAS,aAAa,CAC5B,UAA8C,CAAC,GACb;AAAA,EAClC,MAAM,UAAU,aAAa,OAAO;AAAA,EACpC,MAAM,OAAO,QAAQ,iBAAiB;AAAA,EACtC,MAAM,SAAS,QAAQ,IAAI,yBAAyB;AAAA,EACpD,IAAI,CAAC,QAAQ,OAAO;AAAA,IACnB,IAAI,QAAQ,SAAS,KAAK,GAAG;AAAA,MAC5B,MAAM,IAAI,UACT,sFACD;AAAA,IACD;AAAA,IACA,MAAM,MAAM,OAAO,SAAS,IAAI;AAAA,IAChC,OAAO,iBAAiB,eAAe,aAAa,CAAC,KAAK,MAAM;AAAA,MAC/D,OAAO,IAAI,MAAM,OAAO,KAAK,KAAK,CAAC,CAAC;AAAA,KACpC;AAAA,EACF;AAAA,EACA,IAAI,WAAW,WAAW;AAAA,IACzB,MAAM,IAAI,UACT,iIACD;AAAA,EACD;AAAA,EACA,QAAQ,OAAO,yBAAyB;AAAA,EACxC,MAAM,YAAY,gBAAgB,MAAM;AAAA,EACxC,MAAM,MAAM,OAAO,SAAS,IAAI;AAAA,EAChC,OAAO,iBAAiB,eAAe,aAAa,CAAC,KAAK,MAAM;AAAA,IAC/D,MAAM,QAAsB,EAAE,OAAO,WAAW,EAAE;AAAA,IAClD,MAAM,WAAW,MAAM,OAAO,KAAK,KAAK,KAAK,CAAC;AAAA,IAC9C,OAAO,IAAI,UAAU,UAAU,MAAM,KAAK,CAAC;AAAA,GAC3C;AAAA;AAIF,SAAS,YAAY,CAAC,SAAoD;AAAA,EACzE,MAAM,UAAU,IAAI;AAAA,EACpB,YAAY,MAAM,aAAa,OAAO,QAAQ,QAAQ,GAGjD;AAAA,IACJ,MAAM,UAAU,QAAQ,OAAO;AAAA,IAC/B,IAAI,OAAO,YAAY,YAAY,QAAQ,KAAK,MAAM,IAAI;AAAA,MAEzD,MAAM,IAAI,UACT,kBAAkB,OAAO,2CAA2C,iBACrE;AAAA,IACD;AAAA,IACA,MAAM,QAAQ,YAAY,YAAY,WAAW;AAAA,IACjD,IAAI,OAAO,UAAU;AAAA,MAAU,QAAQ,IAAI,MAAM,KAAK;AAAA,EACvD;AAAA,EACA,OAAO;AAAA;AAIR,SAAS,MAAM,CAAC,SAAsC,MAAe;AAAA,EACpE,OAAO,CAAC,UAAoB,WAC3B,YAAY,UAAU,CAAC,YAAY;AAAA,IAClC,YAAY,MAAM,UAAU,SAAS;AAAA,MACpC,IAAI,CAAC,QAAQ,IAAI,IAAI;AAAA,QAAG,QAAQ,IAAI,MAAM,KAAK;AAAA,IAChD;AAAA,IACA,IAAI,WAAW,aAAa,CAAC,QAAQ,IAAI,yBAAyB,GAAG;AAAA,MACpE,QAAQ,IAAI,2BAA2B,MAAM;AAAA,IAC9C;AAAA,IACA,IAAI,MAAM;AAAA,MACT,QAAQ,OAAO,cAAc;AAAA,MAC7B,QAAQ,OAAO,QAAQ;AAAA,IACxB;AAAA,GACA;AAAA;AAGH,YAAY,aAAa;",
9
- "debugId": "DA6BBAE79DDA945364756E2164756E21",
11
+ "mappings": "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",
12
+ "debugId": "544F13C33DDC4CC564756E2164756E21",
10
13
  "names": []
11
14
  }
package/dist/page.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { type CspDirectives } from './csp';
2
+ import { type NonceMiddleware, type SecureHeadersOptions } from './secure-headers';
3
+ /**
4
+ * A strict policy for server-rendered pages with a nonce, one entry per
5
+ * directive, each with why it holds what it holds. A new source goes in the
6
+ * directive that loads it (through `pageHeaders`' `directives`), never in
7
+ * `default-src`, and never `'unsafe-inline'` or `'unsafe-eval'` in `script-src`.
8
+ */
9
+ declare const PAGE_POLICY: {
10
+ readonly 'default-src': readonly ["'self'"];
11
+ readonly 'script-src': readonly ["'self'", "'nonce-{alxia}'", "'strict-dynamic'"];
12
+ readonly 'style-src-elem': readonly ["'self'", "'nonce-{alxia}'"];
13
+ readonly 'style-src-attr': readonly ["'unsafe-inline'"];
14
+ readonly 'img-src': readonly ["'self'"];
15
+ readonly 'font-src': readonly ["'self'"];
16
+ readonly 'connect-src': readonly ["'self'"];
17
+ readonly 'worker-src': readonly ["'self'"];
18
+ readonly 'manifest-src': readonly ["'self'"];
19
+ readonly 'object-src': readonly ["'none'"];
20
+ readonly 'base-uri': readonly ["'none'"];
21
+ readonly 'form-action': readonly ["'self'"];
22
+ readonly 'frame-ancestors': readonly ["'none'"];
23
+ };
24
+ /** `PAGE_POLICY`, frozen with each of its lists. */
25
+ export declare const PAGE_DIRECTIVES: typeof PAGE_POLICY;
26
+ export interface PageHeadersOptions extends Omit<SecureHeadersOptions, 'contentSecurityPolicy'> {
27
+ /**
28
+ * Served over HTTPS: sends HSTS and adds `upgrade-insecure-requests`.
29
+ * Required, and never read from the environment: `import.meta.env.PROD`
30
+ * or your own flag.
31
+ */
32
+ readonly https: boolean;
33
+ /**
34
+ * Merged over `PAGE_DIRECTIVES`: a list replaces the preset's, `false`
35
+ * removes a directive. An `upgrade-insecure-requests` given here wins
36
+ * over `https`.
37
+ */
38
+ readonly directives?: CspDirectives;
39
+ }
40
+ /**
41
+ * `secureHeaders({ nonce: true, … })` for server-rendered pages: the
42
+ * `PAGE_DIRECTIVES` policy with a nonce per request, HSTS and
43
+ * `upgrade-insecure-requests` only with `https`, `Referrer-Policy:
44
+ * strict-origin-when-cross-origin`, and `Cache-Control: private, no-store`
45
+ * (a cache must never store a nonce). Any other option passes through to
46
+ * `secureHeaders`, and wins over the preset's.
47
+ *
48
+ * ```ts
49
+ * app.use(pageHeaders({ https: import.meta.env.PROD }));
50
+ * ```
51
+ *
52
+ * Throws a `TypeError` when `directives` removes `default-src`, `base-uri`
53
+ * or `object-src`, and when the merged `script-src`, or a `script-src-elem`
54
+ * given as a list, lacks `NONCE`: the page would not hydrate.
55
+ */
56
+ export declare function pageHeaders(options: PageHeadersOptions): NonceMiddleware;
57
+ export {};
58
+ //# sourceMappingURL=page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,aAAa,EAAO,MAAM,OAAO,CAAC;AAEhD,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,oBAAoB,EAEzB,MAAM,kBAAkB,CAAC;AAE1B;;;;;GAKG;AACH,QAAA,MAAM,WAAW;;;;;;;;;;;;;;CAqCiB,CAAC;AAEnC,oDAAoD;AACpD,eAAO,MAAM,eAAe,EAAE,OAAO,WAOd,CAAC;AAUxB,MAAM,WAAW,kBAChB,SAAQ,IAAI,CAAC,oBAAoB,EAAE,uBAAuB,CAAC;IAC3D;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAC;CACpC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,eAAe,CA0CxE"}
@@ -17,6 +17,11 @@ export interface SecureHeadersOptions {
17
17
  readonly xPermittedCrossDomainPolicies?: Setting;
18
18
  /** Off by default: a policy is the app's to write. */
19
19
  readonly permissionsPolicy?: Setting;
20
+ /**
21
+ * Off by default. `'private, no-store'` for pages that carry a nonce or a
22
+ * viewer's data: a cache must never store them.
23
+ */
24
+ readonly cacheControl?: Setting;
20
25
  /** Whether `X-Powered-By` and `Server` are removed. On by default. */
21
26
  readonly hidePoweredBy?: boolean;
22
27
  }
@@ -1 +1 @@
1
- {"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,KAAK,EACV,KAAK,UAAU,EAEf,KAAK,IAAI,EAGT,MAAM,aAAa,CAAC;AACrB,OAAO,EAAqB,KAAK,YAAY,EAAmB,MAAM,SAAS,CAAC;AAEhF,+EAA+E;AAC/E,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;AAErC,MAAM,WAAW,oBAAoB;IACpC,yEAAyE;IACzE,QAAQ,CAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IACzC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAClC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACjD,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC,sEAAsE;IACtE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CACjC;AAeD;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;AAEjE;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;AAiC7E;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GACtD,eAAe,CAAC;AACnB,wBAAgB,aAAa,CAC5B,OAAO,CAAC,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACzD,aAAa,CAAC"}
1
+ {"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,KAAK,EACV,KAAK,UAAU,EAEf,KAAK,IAAI,EAGT,MAAM,aAAa,CAAC;AACrB,OAAO,EAAqB,KAAK,YAAY,EAAmB,MAAM,SAAS,CAAC;AAEhF,+EAA+E;AAC/E,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;AAErC,MAAM,WAAW,oBAAoB;IACpC,yEAAyE;IACzE,QAAQ,CAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IACzC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAClC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACjD,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAChC,sEAAsE;IACtE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CACjC;AAeD;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;AAEjE;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;AAmC7E;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GACtD,eAAe,CAAC;AACnB,wBAAgB,aAAa,CAC5B,OAAO,CAAC,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACzD,aAAa,CAAC"}
package/docs/README.md CHANGED
@@ -6,6 +6,6 @@ headers, and what to do when a browser refuses what they forbid.
6
6
 
7
7
  | Page | Read it when |
8
8
  | --- | --- |
9
- | [Guide](guide.md) | changing or dropping a header, a nonce per request, giving a page its own policy, ordering it with other middlewares, or testing the headers |
9
+ | [Guide](guide.md) | changing or dropping a header, a nonce per request, writing the policy as directives, the page preset, giving a page its own policy, keeping a page out of shared caches, clearing the browser's cache on sign-out, ordering it with other middlewares, or testing the headers |
10
10
  | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message: a TypeScript error, a browser refusal, a header that is or is not there |
11
11
  | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
package/docs/guide.md CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  This page covers `secureHeaders`: what it sends, how to change or drop each
4
4
  header, the per-request nonce, which responses it reaches, and how a page or
5
- another middleware sets its own policy.
5
+ another middleware sets its own policy. It also covers the four additions for
6
+ server-rendered pages: `csp()` writes the policy from directives,
7
+ `pageHeaders()` is the preset for a page, `cacheControl` keeps pages out of
8
+ caches, and `clearSiteData()` empties the browser's cache on sign-out.
6
9
 
7
10
  ```ts
8
11
  import { alxia } from '@alxia/core';
@@ -37,6 +40,7 @@ interface SecureHeadersOptions {
37
40
  readonly xDnsPrefetchControl?: string | false;
38
41
  readonly xPermittedCrossDomainPolicies?: string | false;
39
42
  readonly permissionsPolicy?: string | false;
43
+ readonly cacheControl?: string | false;
40
44
  readonly hidePoweredBy?: boolean;
41
45
  }
42
46
  ```
@@ -83,6 +87,7 @@ leave the header out. An option left out keeps the default.
83
87
  | `xDnsPrefetchControl` | `X-DNS-Prefetch-Control` | `off` | the browser does not resolve the response's links ahead of time |
84
88
  | `xPermittedCrossDomainPolicies` | `X-Permitted-Cross-Domain-Policies` | `none` | Adobe clients load no cross-domain policy file |
85
89
  | `permissionsPolicy` | `Permissions-Policy` | not sent | — the policy is the app's to write |
90
+ | `cacheControl` | `Cache-Control` | not sent | — an API often wants its own caching; a page with a nonce must not be stored ([below](#pages-are-not-cached)) |
86
91
 
87
92
  | Option | Type | Default | Effect |
88
93
  | --- | --- | --- | --- |
@@ -120,8 +125,10 @@ Use `false` to drop a header.
120
125
  ### The defaults, explicitly
121
126
 
122
127
  With `exactOptionalPropertyTypes` on, an option set to `undefined` is a
123
- compile error; with it off, `undefined` means the default. To keep the
124
- default under a condition, leave the key out:
128
+ compile error; with it off, `undefined` means the default (under
129
+ `pageHeaders`, the preset's value: the key is ignored, so `cacheControl:
130
+ undefined` keeps `private, no-store`). To keep the default under a
131
+ condition, leave the key out:
125
132
 
126
133
  ```ts
127
134
  const production = Bun.env.NODE_ENV === 'production';
@@ -275,8 +282,9 @@ header, unless it writes it into its own policy.
275
282
 
276
283
  `@alxia/react-router` reads the nonce from the context, if one is there,
277
284
  with `nonceOf(loadContext)` in `entry.server.tsx`. Neither package depends
278
- on the other. See
279
- [its guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide.md#a-csp-nonce).
285
+ on the other. `pageHeaders({ https })` is the policy that goes with it
286
+ ([below](#pages-pageheaders)). See
287
+ [its guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/csp-nonce.md#a-csp-nonce).
280
288
 
281
289
  ### Without `nonce: true`
282
290
 
@@ -284,6 +292,205 @@ Nothing changes: the same headers, the same values, no `nonce` on the
284
292
  context. A policy that names `NONCE` without `nonce: true` is refused at
285
293
  startup, so the placeholder never reaches a browser.
286
294
 
295
+ ## Writing the policy as directives
296
+
297
+ A long policy string hides a typo until a browser refuses a page. `csp()`
298
+ takes the directives as an object and returns the string, so
299
+ `contentSecurityPolicy` still takes a string and nothing else changes.
300
+
301
+ ```ts
302
+ import { csp, NONCE, secureHeaders } from '@alxia/secure-headers';
303
+
304
+ app.use(
305
+ secureHeaders({
306
+ nonce: true,
307
+ contentSecurityPolicy: csp({
308
+ 'default-src': ["'self'"],
309
+ 'script-src': ["'self'", NONCE],
310
+ 'style-src-elem': ["'self'", NONCE],
311
+ 'frame-ancestors': ["'none'"],
312
+ 'upgrade-insecure-requests': true,
313
+ }),
314
+ }),
315
+ );
316
+ ```
317
+
318
+ - **Order.** Directives are written in the order of the object, so the
319
+ header reads as the code does.
320
+ - **A list is an array.** `'img-src': 'self'` is a type error: write
321
+ `["'self'"]`. The quotes are part of the source, as in a header.
322
+ - **`true`, `false`, `undefined`.** `true` writes a bare directive
323
+ (`upgrade-insecure-requests`, `sandbox`): a bare directive takes `true`, not an empty array. `false` and `undefined` leave it
324
+ out, so `'upgrade-insecure-requests': isProduction` works.
325
+ - **`NONCE`.** It passes through as it is, and `nonce: true` replaces each
326
+ one with the request's `'nonce-…'` ([above](#where-it-goes-in-the-policy)).
327
+ - **Tokens.** A directive that takes keywords is an array like any other:
328
+ `sandbox: ['allow-scripts', 'allow-forms']` is written
329
+ `sandbox allow-scripts allow-forms`.
330
+ - **Any name.** The known directives complete; another lowercase name
331
+ (`report-uri`) is written as given.
332
+ - **Checked at startup.** An empty source list (write `["'none'"]`), a name
333
+ that is not lowercase letters, digits and hyphens starting with a letter, a source that holds `;`, `,` or
334
+ a space, and a policy with no directive left throw a `TypeError` that
335
+ starts `csp():`, before the first request
336
+ ([troubleshooting](troubleshooting.md#runtime)).
337
+
338
+ ## Pages: `pageHeaders`
339
+
340
+ `pageHeaders({ https })` is `secureHeaders` with the settings a
341
+ server-rendered page with a nonce needs, so an app does not write the policy
342
+ itself.
343
+
344
+ ```ts
345
+ import { alxia } from '@alxia/core';
346
+ import { pageHeaders } from '@alxia/secure-headers';
347
+
348
+ const app = alxia()
349
+ .use(pageHeaders({ https: true }))
350
+ .get('/', ({ nonce, reply }) => reply(200, `<script nonce="${nonce}">…</script>`));
351
+ ```
352
+
353
+ What it sets:
354
+
355
+ - `nonce: true`, and the policy `PAGE_DIRECTIVES` with
356
+ `upgrade-insecure-requests` when `https` is true.
357
+ - `Strict-Transport-Security` when `https` is true, none otherwise.
358
+ - `Referrer-Policy: strict-origin-when-cross-origin`.
359
+ - `Cache-Control: private, no-store`, since a cache must never store a nonce.
360
+ - Every other `secureHeaders` option, as given: yours win over the preset's.
361
+ `contentSecurityPolicy` and `nonce` are not options (a type error): the
362
+ policy goes through `directives`.
363
+
364
+ `https` is required. Pass `import.meta.env.PROD` or your own flag; the
365
+ package never reads the environment, so a development server on plain HTTP
366
+ gets no HSTS.
367
+
368
+ ### The directives
369
+
370
+ | directive | holds | why |
371
+ | --- | --- | --- |
372
+ | `default-src` | `'self'` | anything a directive below does not name loads from this site only |
373
+ | `script-src` | `'self'`, `NONCE`, `'strict-dynamic'` | only the scripts this response's nonce marks, and what they load; `'self'` is a fallback for browsers without `'strict-dynamic'` |
374
+ | `style-src-elem` | `'self'`, `NONCE` | this site's stylesheets and the `<style>` elements that carry the nonce (a UI library's nonced style elements, a dev client's); no `'unsafe-inline'`, since an injected `<style>` can read a value out with attribute selectors |
375
+ | `style-src-attr` | `'unsafe-inline'` | `style` attributes (custom properties, positions); no nonce covers an attribute, and this allows no `<style>` element and no script |
376
+ | `img-src` | `'self'` | icons, sprites and logos; no `data:` or `blob:` until a feature needs one |
377
+ | `font-src` | `'self'` | self-hosted fonts |
378
+ | `connect-src` | `'self'` | the framework's data requests, to this site only; a development server's WebSocket on the page's own host |
379
+ | `worker-src` | `'self'` | the service worker |
380
+ | `manifest-src` | `'self'` | the web app manifest |
381
+ | `object-src` | `'none'` | no plugin content |
382
+ | `base-uri` | `'none'` | no `<base>`: a relative URL always means this site |
383
+ | `form-action` | `'self'` | forms post to this site only, a submit before hydration included |
384
+ | `frame-ancestors` | `'none'` | no page frames this one (clickjacking); `X-Frame-Options: DENY` covers older browsers |
385
+
386
+ Never add `'unsafe-eval'`, nor `'unsafe-inline'` to `script-src`.
387
+
388
+ ### Changing the policy
389
+
390
+ `directives` merges over `PAGE_DIRECTIVES`: a list replaces that directive's
391
+ sources (it does not append), `false` removes the directive, `undefined`
392
+ keeps the preset's value, and a name the preset lacks is added. Three cannot be removed, with `false` or `true`:
393
+ `default-src`, which covers frame, media and child sources, and `base-uri`,
394
+ which has no fallback, so an injected `<base>` would redirect nonced
395
+ relative scripts, and `object-src`, which blocks plugin content. They throw a
396
+ `TypeError` that starts `pageHeaders():`
397
+ ([troubleshooting](troubleshooting.md#typeerror-pageheaders-name-cannot-be-removed-from-a-page-policy-)); give a list to change one. A source goes in the directive that loads it, never in
398
+ `default-src`. Under `'strict-dynamic'` a third-party script is loaded by a
399
+ nonced script, not by allowlisting its host: browsers that support it ignore
400
+ host sources in `script-src`.
401
+
402
+ ```ts
403
+ pageHeaders({
404
+ https: true,
405
+ directives: {
406
+ 'img-src': ["'self'", 'https://img.example'],
407
+ 'font-src': ["'self'", 'https://fonts.example'],
408
+ 'manifest-src': false,
409
+ },
410
+ });
411
+ ```
412
+
413
+ An `upgrade-insecure-requests` given in `directives` wins over `https`. A
414
+ `script-src`, or a `script-src-elem` list, without `NONCE` throws at startup, a `TypeError` that starts
415
+ `pageHeaders():`: the nonce would reach no script and the page would not
416
+ hydrate ([troubleshooting](troubleshooting.md#typeerror-pageheaders-script-src-has-no-nonce-a-page-script-runs-only-with-the-responses-nonce-so-write-script-src-with-nonce-in-directives-such-as-self-nonce-strict-dynamic)).
417
+
418
+ ## Pages are not cached
419
+
420
+ A page that carries a nonce, or a viewer's data, must not be stored: a shared
421
+ cache (a CDN, a proxy) that kept it would hand one viewer's nonce, or
422
+ account, to the next. The nonce also stops protecting anything once it is
423
+ reused, since it is only worth something if an attacker cannot guess it.
424
+ `cacheControl` is off by default, because an API often wants its own caching.
425
+
426
+ ```ts
427
+ import { alxia } from '@alxia/core';
428
+ import { csp, NONCE, secureHeaders } from '@alxia/secure-headers';
429
+
430
+ const app = alxia();
431
+
432
+ app.use(
433
+ secureHeaders({
434
+ nonce: true,
435
+ contentSecurityPolicy: csp({ 'script-src': ["'self'", NONCE] }),
436
+ cacheControl: 'private, no-store',
437
+ }),
438
+ );
439
+ ```
440
+
441
+ It is set like every other header: a route's own `Cache-Control` is kept
442
+ (a static asset can stay cacheable), and a 404 or a thrown error gets it too.
443
+ An empty value is refused at startup; give `false` to leave it out. A page's
444
+ own `Cache-Control` must stay `private` or `no-store` while it carries a
445
+ nonce, never `public`.
446
+
447
+ ## Clear-Site-Data
448
+
449
+ After a sign-out, the browser's HTTP cache can still hold pages the viewer
450
+ saw, which matters on a shared device. `Clear-Site-Data` tells the browser to
451
+ empty it. `clearSiteData(types, options?)` sends the header on a successful
452
+ answer, and only on one:
453
+
454
+ ```ts
455
+ import { alxia } from '@alxia/core';
456
+ import { clearSiteData } from '@alxia/secure-headers';
457
+
458
+ const cleared = clearSiteData(['cache']);
459
+
460
+ const app = alxia()
461
+ .use('/sign-out', cleared)
462
+ .use('/sign-out.data', cleared);
463
+ ```
464
+
465
+ - **Only a sign-out that went through.** It sends the header on a 2xx or 3xx
466
+ answer to one of `methods` (`['POST']` by default). A GET, or a refused
467
+ request (a 403, a 415, a failure), signed nobody out and must not empty
468
+ their cache. Pass `{ methods: ['POST', 'DELETE'] }` to widen the rule.
469
+ - **Not for another site's request.** A form on another site can post to
470
+ `/sign-out` and get a redirect, which would wipe the viewer's cache or
471
+ cookies. So when `Sec-Fetch-Site` is present, it alone decides: anything
472
+ other than `same-origin` or `none` clears nothing (`same-site` too, since a
473
+ sibling subdomain may be attacker-controlled), and `Origin` is not
474
+ read. Only without it (an older browser) is an `Origin` compared with the
475
+ app's own origin. A request with neither header (curl) is cleared.
476
+ Modern browsers need nothing more behind a TLS-terminating proxy, since
477
+ the proxy does not rewrite `Sec-Fetch-Site`. For older browsers behind
478
+ one, where the request's URL says `http:` and `Origin` says `https:`, set
479
+ `origin: 'https://app.example.com'` (or a function returning it). Pass
480
+ `{ crossSite: true }` only to give the protection up.
481
+ - **A route's own header wins.** An answer that already carries
482
+ `Clear-Site-Data` keeps it, as `secureHeaders` does for its headers.
483
+ - **Several types** are written quoted and comma-separated:
484
+ `clearSiteData(['cache', 'cookies'])` sends `"cache", "cookies"`.
485
+ - **Both paths with React Router.** A form posts `/sign-out`, but the client
486
+ navigates with single fetch, which posts `/sign-out.data`. Its answer
487
+ carries a redirect's cookies, not its other headers, so the route cannot
488
+ set the header: the middleware does, on the way out. Mount it on both paths.
489
+ `/sign-outs` matches neither.
490
+ - **Checked at startup.** An empty list or an unknown type throws a
491
+ `TypeError` that starts `clearSiteData():`
492
+ ([troubleshooting](troubleshooting.md#runtime)).
493
+
287
494
  ## Which responses get the headers
288
495
 
289
496
  The middleware settles `next()`: it sets the headers on the response the
@@ -493,6 +700,46 @@ test('a 404 is covered too', async () => {
493
700
  });
494
701
  ```
495
702
 
703
+ ### A server-rendered page
704
+
705
+ The same app with `pageHeaders`, a font from another origin, and the spec
706
+ that holds the policy in place:
707
+
708
+ ```ts
709
+ // app.ts
710
+ import { alxia } from '@alxia/core';
711
+ import { pageHeaders } from '@alxia/secure-headers';
712
+
713
+ export const app = alxia()
714
+ .use(
715
+ pageHeaders({
716
+ https: Bun.env.NODE_ENV === 'production',
717
+ directives: { 'font-src': ["'self'", 'https://fonts.example'] },
718
+ }),
719
+ )
720
+ .get('/', ({ nonce, reply }) =>
721
+ reply(200, `<script nonce="${nonce}">console.log('hi')</script>`, {
722
+ headers: { 'content-type': 'text/html; charset=utf-8' },
723
+ }),
724
+ );
725
+ ```
726
+
727
+ ```ts
728
+ // app.spec.ts
729
+ import { expect, test } from 'bun:test';
730
+ import { app } from './app';
731
+
732
+ test('a page carries its nonce, is never cached, and may load the font', async () => {
733
+ const response = await app.request('/');
734
+ const html = await response.text();
735
+ const nonce = /nonce="([^"]+)"/.exec(html)?.[1];
736
+ const policy = response.headers.get('content-security-policy') ?? '';
737
+ expect(policy).toContain(`'nonce-${nonce}'`);
738
+ expect(policy).toContain("font-src 'self' https://fonts.example");
739
+ expect(response.headers.get('cache-control')).toBe('private, no-store');
740
+ });
741
+ ```
742
+
496
743
  ## See also
497
744
 
498
745
  - [Troubleshooting](troubleshooting.md): a browser refuses something, or
package/docs/roadmap.md CHANGED
@@ -7,11 +7,7 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
- - **Secure headers as a middleware.** `app.use(secureHeaders())`, declared
11
- first, sets the headers on every response that comes back through it, a
12
- 404's, an error's and a 500's included; `NonceMiddleware` gives `nonce` to the
13
- routes after it and `SecureHeaders` is the plain one.
14
- `app.plugin(secureHeaders())`, the deprecated alias, was removed in 0.5.
10
+ Nothing scheduled yet.
15
11
 
16
12
  ## Next
17
13
 
@@ -19,7 +15,11 @@ Nothing scheduled yet.
19
15
 
20
16
  ## Later
21
17
 
22
- Nothing scheduled yet.
18
+ - **Reporting (`report-to` / `Reporting-Endpoints`).** Name an endpoint for
19
+ policy violations, written as options next to the policy.
20
+ - **One error-message prefix.** `secureHeaders():` for the older
21
+ `secureHeaders:` messages, so every error of the package starts the same
22
+ way as `csp():`, `pageHeaders():` and `clearSiteData():`.
23
23
 
24
24
  ## Not planned
25
25
 
@@ -29,6 +29,26 @@ Nothing scheduled yet.
29
29
 
30
30
  ## Shipped
31
31
 
32
+ ### 0.5.0
33
+
34
+ - **Headers for server-rendered pages.** `csp()` writes a policy from
35
+ directives and refuses one a browser would misread, a keyword without its
36
+ quotes included. `pageHeaders({ https })` is the strict preset for a page
37
+ with a nonce (`PAGE_DIRECTIVES`, frozen), with HSTS and
38
+ `upgrade-insecure-requests` only over HTTPS; it refuses a `script-src`
39
+ without the nonce and the removal of `default-src`, `base-uri` or
40
+ `object-src`. `cacheControl` keeps a page out of caches, and
41
+ `clearSiteData()` empties the browser's cache on a successful sign-out, not
42
+ on a request another site made.
43
+
44
+ ### 0.4.0
45
+
46
+ - **Secure headers as a middleware.** `app.use(secureHeaders())`, declared
47
+ first, sets the headers on every response that comes back through it, a
48
+ 404's, an error's and a 500's included; `NonceMiddleware` gives `nonce` to the
49
+ routes after it and `SecureHeaders` is the plain one.
50
+ `app.plugin(secureHeaders())`, the deprecated alias, was removed in 0.5.
51
+
32
52
  ### 0.2.0
33
53
 
34
54
  - **A nonce per request.** `secureHeaders({ nonce: true })` makes a fresh,