@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/README.md +108 -2
- package/dist/clear-site-data.d.ts +49 -0
- package/dist/clear-site-data.d.ts.map +1 -0
- package/dist/csp.d.ts +36 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +191 -13
- package/dist/index.js.map +7 -4
- package/dist/page.d.ts +58 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/secure-headers.d.ts +5 -0
- package/dist/secure-headers.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide.md +252 -5
- package/docs/roadmap.md +26 -6
- package/docs/troubleshooting.md +334 -12
- package/package.json +3 -3
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
|
-
"
|
|
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": ";
|
|
9
|
-
"debugId": "
|
|
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"}
|
package/dist/secure-headers.d.ts
CHANGED
|
@@ -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;
|
|
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
|
|
124
|
-
|
|
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.
|
|
279
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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,
|