@alxia/secure-headers 0.1.0 → 0.2.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 CHANGED
@@ -21,6 +21,54 @@ app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'", xFrameOptio
21
21
  A header a route sets itself is kept: a page that needs its own policy sets
22
22
  it on its reply.
23
23
 
24
+ ## A nonce per request
25
+
26
+ `nonce: true` makes a fresh nonce for each request (128 random bits,
27
+ base64), puts it in the policy as `'nonce-…'`, and gives it to the routes
28
+ declared after the plugin as `ctx.nonce`. The header and the context always
29
+ carry the same one, so a page's inline scripts need no `'unsafe-inline'`.
30
+
31
+ ```ts
32
+ import { alxia } from '@alxia/core';
33
+ import { secureHeaders } from '@alxia/secure-headers';
34
+
35
+ const app = alxia()
36
+ .use(
37
+ secureHeaders({
38
+ nonce: true,
39
+ // The nonce is added to script-src (and script-src-elem).
40
+ contentSecurityPolicy: "default-src 'self'; script-src 'self'",
41
+ }),
42
+ )
43
+ .get('/', ({ nonce, reply }) =>
44
+ reply(200, `<script nonce="${nonce}">console.log('allowed')</script>`, {
45
+ headers: { 'content-type': 'text/html' },
46
+ }),
47
+ );
48
+ // content-security-policy: default-src 'self'; script-src 'self' 'nonce-3q2+7w…=='
49
+ ```
50
+
51
+ To place it yourself, in `style-src` say, write `NONCE` where it goes;
52
+ each occurrence becomes the request's `'nonce-…'`, and `script-src` is then
53
+ left as written:
54
+
55
+ ```ts
56
+ import { NONCE, secureHeaders } from '@alxia/secure-headers';
57
+
58
+ app.use(
59
+ secureHeaders({
60
+ nonce: true,
61
+ contentSecurityPolicy: `default-src 'self'; script-src 'self' ${NONCE}; style-src 'self' ${NONCE}`,
62
+ }),
63
+ );
64
+ ```
65
+
66
+ A policy with nowhere to put the nonce (no `script-src`, no `NONCE`) is
67
+ refused at startup, the default policy included: give `contentSecurityPolicy`
68
+ with `nonce: true`. Without `nonce: true`, the headers are what they always
69
+ were. With `@alxia/react-router`, `nonceOf(loadContext)` hands it to
70
+ `entry.server.tsx` ([its README](https://github.com/softistx/alxia/tree/develop/packages/react-router#a-csp-nonce)).
71
+
24
72
  ## Defaults
25
73
 
26
74
  | header | default | option |
@@ -39,19 +87,22 @@ it on its reply.
39
87
  | `Permissions-Policy` | not sent | `permissionsPolicy` |
40
88
 
41
89
  Each option takes a value, or `false` to leave the header out; an empty
42
- value is refused at startup.
90
+ value is refused at startup. `nonce` is off by default.
43
91
  `X-Powered-By` and `Server` are removed unless `hidePoweredBy: false`.
44
92
 
45
93
  ## API
46
94
 
47
95
  | export | |
48
96
  | --- | --- |
49
- | `secureHeaders(options?)` | the plugin |
50
- | `SecureHeadersOptions` | its options |
97
+ | `secureHeaders(options?)` | the plugin: a function `Plugin`, or with `nonce: true` a `NoncePlugin` |
98
+ | `SecureHeadersOptions` | its options: the headers and `hidePoweredBy`; `nonce` is added by each overload |
51
99
  | `Setting` | a header option's type: its value, or `false` to leave it out |
100
+ | `NONCE` | where the nonce goes in `contentSecurityPolicy`, each time it is named |
101
+ | `NonceContext` | what `nonce: true` adds to the context: `nonce`, a string |
102
+ | `NoncePlugin` | what `secureHeaders({ nonce: true })` returns: an app plugin adding `NonceContext` |
52
103
 
53
104
  ## Documentation
54
105
 
55
- - [Guide](https://github.com/softistx/alxia/tree/develop/packages/secure-headers/docs): every option and its default, which responses get the headers, a page's own policy, the order with other hooks, and recipes.
106
+ - [Guide](https://github.com/softistx/alxia/tree/develop/packages/secure-headers/docs): every option and its default, the per-request nonce, which responses get the headers, a page's own policy, the order with other hooks, and recipes.
56
107
  - [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/secure-headers/docs/troubleshooting.md): an error message or a browser refusal, and what to do about it.
57
108
  - [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/secure-headers/docs/roadmap.md): what is coming, and what is not planned.
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- export { type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
1
+ export { NONCE, type NonceContext } from './nonce';
2
+ export { type NoncePlugin, type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,37 @@
1
+ // src/nonce.ts
2
+ var NONCE = "'nonce-{alxia}'";
3
+ var SCRIPT_DIRECTIVES = new Set(["script-src", "script-src-elem"]);
4
+ function policyWithNonce(policy) {
5
+ const placed = policy.includes(NONCE) ? policy : policy.split(";").map((directive) => {
6
+ const name = directive.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
7
+ return SCRIPT_DIRECTIVES.has(name) ? `${directive.trimEnd()} ${NONCE}` : directive;
8
+ }).join(";");
9
+ if (!placed.includes(NONCE)) {
10
+ throw new TypeError(`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}")`);
11
+ }
12
+ const parts = placed.split(NONCE);
13
+ return (nonce) => parts.join(`'nonce-${nonce}'`);
14
+ }
15
+ function freshNonce() {
16
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
17
+ return btoa(String.fromCharCode(...bytes));
18
+ }
19
+ function nonceStore() {
20
+ const nonces = new WeakMap;
21
+ return (url) => {
22
+ let nonce = nonces.get(url);
23
+ if (nonce === undefined) {
24
+ nonce = freshNonce();
25
+ nonces.set(url, nonce);
26
+ }
27
+ return nonce;
28
+ };
29
+ }
1
30
  // src/secure-headers.ts
2
- import { withHeaders } from "@alxia/core";
31
+ import {
32
+ definePlugin,
33
+ withHeaders
34
+ } from "@alxia/core";
3
35
  var DEFAULTS = {
4
36
  "content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
5
37
  "strict-transport-security": "max-age=31536000; includeSubDomains",
@@ -29,7 +61,28 @@ var OPTION = {
29
61
  "permissions-policy": "permissionsPolicy"
30
62
  };
31
63
  function secureHeaders(options = {}) {
32
- const headers = [];
64
+ const headers = fixedHeaders(options);
65
+ const hide = options.hidePoweredBy ?? true;
66
+ const policy = headers.get("content-security-policy");
67
+ if (!options.nonce) {
68
+ if (policy?.includes(NONCE)) {
69
+ throw new TypeError("secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true");
70
+ }
71
+ const set = setter(headers, hide);
72
+ return (app) => app.onResponse((response) => set(response));
73
+ }
74
+ if (policy === undefined) {
75
+ throw new TypeError("secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header");
76
+ }
77
+ headers.delete("content-security-policy");
78
+ const withNonce = policyWithNonce(policy);
79
+ const nonceOf = nonceStore();
80
+ const set = setter(headers, hide);
81
+ const plugin = definePlugin()((app) => app.onResponse((response, ctx) => set(response, withNonce(nonceOf(ctx.url)))).derive(({ url }) => ({ nonce: nonceOf(url) })));
82
+ return plugin;
83
+ }
84
+ function fixedHeaders(options) {
85
+ const headers = new Map;
33
86
  for (const [name, fallback] of Object.entries(DEFAULTS)) {
34
87
  const setting = options[OPTION[name]];
35
88
  if (typeof setting === "string" && setting.trim() === "") {
@@ -37,23 +90,29 @@ function secureHeaders(options = {}) {
37
90
  }
38
91
  const value = setting === undefined ? fallback : setting;
39
92
  if (typeof value === "string")
40
- headers.push([name, value]);
93
+ headers.set(name, value);
41
94
  }
42
- const hide = options.hidePoweredBy ?? true;
43
- return (app) => app.onResponse((response) => withHeaders(response, (current) => {
95
+ return headers;
96
+ }
97
+ function setter(headers, hide) {
98
+ return (response, policy) => withHeaders(response, (current) => {
44
99
  for (const [name, value] of headers) {
45
100
  if (!current.has(name))
46
101
  current.set(name, value);
47
102
  }
103
+ if (policy !== undefined && !current.has("content-security-policy")) {
104
+ current.set("content-security-policy", policy);
105
+ }
48
106
  if (hide) {
49
107
  current.delete("x-powered-by");
50
108
  current.delete("server");
51
109
  }
52
- }));
110
+ });
53
111
  }
54
112
  export {
113
+ NONCE,
55
114
  secureHeaders
56
115
  };
57
116
 
58
- //# debugId=DD4A7A8B39A4596664756E2164756E21
117
+ //# debugId=3F73DEF9D20E37B064756E2164756E21
59
118
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/secure-headers.ts"],
3
+ "sources": ["../src/nonce.ts", "../src/secure-headers.ts"],
4
4
  "sourcesContent": [
5
- "import { type Plugin, withHeaders } from '@alxia/core';\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\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 plugin. A header a route set\n * itself is kept: a page that needs its own `Content-Security-Policy` —\n * `@alxia/openapi`'s reference page — sets it.\n *\n * ```ts\n * app.use(secureHeaders({ contentSecurityPolicy: \"default-src 'self'\" }));\n * ```\n */\nexport function secureHeaders(options: SecureHeadersOptions = {}): Plugin {\n\tconst headers: [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.push([name, value]);\n\t}\n\tconst hide = options.hidePoweredBy ?? true;\n\treturn (app) =>\n\t\tapp.onResponse((response) =>\n\t\t\twithHeaders(response, (current) => {\n\t\t\t\tfor (const [name, value] of headers) {\n\t\t\t\t\tif (!current.has(name)) current.set(name, value);\n\t\t\t\t}\n\t\t\t\tif (hide) {\n\t\t\t\t\tcurrent.delete('x-powered-by');\n\t\t\t\t\tcurrent.delete('server');\n\t\t\t\t}\n\t\t\t}),\n\t\t);\n}\n"
5
+ "/**\n * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes\n * in the policy, how one is made, and how the context and the header read\n * the same one.\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\n/**\n * One nonce per request, made when first asked for. Keyed by the request's\n * `URL`: the one object every hook and the route of a request share, where\n * a `bodyLimit` hands the route a `Request` of its own.\n */\nexport function nonceStore(): (url: URL) => string {\n\tconst nonces = new WeakMap<URL, string>();\n\treturn (url) => {\n\t\tlet nonce = nonces.get(url);\n\t\tif (nonce === undefined) {\n\t\t\tnonce = freshNonce();\n\t\t\tnonces.set(url, nonce);\n\t\t}\n\t\treturn nonce;\n\t};\n}\n",
6
+ "import {\n\ttype Alxia,\n\tdefinePlugin,\n\ttype Empty,\n\ttype Plugin,\n\ttype Requiring,\n\twithHeaders,\n} from '@alxia/core';\nimport { NONCE, type NonceContext, nonceStore, 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({ nonce: true })` returns: an app plugin, whose routes\n * after it read `nonce`, and whose hook sets the header with the same one.\n */\nexport type NoncePlugin = Alxia<NonceContext, Empty, '', never> &\n\tRequiring<Empty>;\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 plugin. A header a route set\n * itself is kept: a page that needs its own `Content-Security-Policy` —\n * `@alxia/openapi`'s reference page — 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): NoncePlugin;\nexport function secureHeaders(\n\toptions?: SecureHeadersOptions & { readonly nonce?: false },\n): Plugin;\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & NonceOption = {},\n): Plugin | NoncePlugin {\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 (app) => app.onResponse((response) => set(response));\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 nonceOf = nonceStore();\n\tconst set = setter(headers, hide);\n\tconst plugin: NoncePlugin = definePlugin()((app) =>\n\t\tapp\n\t\t\t.onResponse((response, ctx) => set(response, withNonce(nonceOf(ctx.url))))\n\t\t\t.derive(({ url }): NonceContext => ({ nonce: nonceOf(url) })),\n\t);\n\treturn plugin;\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/** The `onResponse` hook: 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"
6
7
  ],
7
- "mappings": ";AAAA;AAwBA,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;AAWO,SAAS,aAAa,CAAC,UAAgC,CAAC,GAAW;AAAA,EACzE,MAAM,UAA8B,CAAC;AAAA,EACrC,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,KAAK,CAAC,MAAM,KAAK,CAAC;AAAA,EAC1D;AAAA,EACA,MAAM,OAAO,QAAQ,iBAAiB;AAAA,EACtC,OAAO,CAAC,QACP,IAAI,WAAW,CAAC,aACf,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,MAAM;AAAA,MACT,QAAQ,OAAO,cAAc;AAAA,MAC7B,QAAQ,OAAO,QAAQ;AAAA,IACxB;AAAA,GACA,CACF;AAAA;",
8
- "debugId": "DD4A7A8B39A4596664756E2164756E21",
8
+ "mappings": "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",
9
+ "debugId": "3F73DEF9D20E37B064756E2164756E21",
9
10
  "names": []
10
11
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes
3
+ * in the policy, how one is made, and how the context and the header read
4
+ * the same one.
5
+ */
6
+ /**
7
+ * Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes
8
+ * `'nonce-<value>'`, the request's own value. Without it, `nonce: true`
9
+ * adds the nonce to `script-src` and `script-src-elem`.
10
+ *
11
+ * ```ts
12
+ * secureHeaders({
13
+ * nonce: true,
14
+ * contentSecurityPolicy: `default-src 'self'; style-src 'self' ${NONCE}`,
15
+ * });
16
+ * ```
17
+ */
18
+ export declare const NONCE = "'nonce-{alxia}'";
19
+ /** What `secureHeaders({ nonce: true })` adds to the context of the routes after it. */
20
+ export interface NonceContext {
21
+ /**
22
+ * This request's nonce, base64, the one its `Content-Security-Policy`
23
+ * allows: what a page puts on its `<script nonce>`.
24
+ */
25
+ readonly nonce: string;
26
+ }
27
+ /**
28
+ * The policy as a function of the nonce: split where `NONCE` stands, or
29
+ * where it is added to the script directives. Throws when there is
30
+ * nowhere to put it.
31
+ */
32
+ export declare function policyWithNonce(policy: string): (nonce: string) => string;
33
+ /** 128 random bits, base64: a fresh nonce. */
34
+ export declare function freshNonce(): string;
35
+ /**
36
+ * One nonce per request, made when first asked for. Keyed by the request's
37
+ * `URL`: the one object every hook and the route of a request share, where
38
+ * a `bodyLimit` hands the route a `Request` of its own.
39
+ */
40
+ export declare function nonceStore(): (url: URL) => string;
41
+ //# sourceMappingURL=nonce.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,KAAK,oBAAoB,CAAC;AAEvC,wFAAwF;AACxF,MAAM,WAAW,YAAY;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAKD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAmBzE;AAED,8CAA8C;AAC9C,wBAAgB,UAAU,IAAI,MAAM,CAGnC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,MAAM,CAUjD"}
@@ -1,4 +1,5 @@
1
- import { type Plugin } from '@alxia/core';
1
+ import { type Alxia, type Empty, type Plugin, type Requiring } from '@alxia/core';
2
+ import { type NonceContext } from './nonce';
2
3
  /** A header's value, or `false` to leave it out. An empty value is refused. */
3
4
  export type Setting = string | false;
4
5
  export interface SecureHeadersOptions {
@@ -19,6 +20,11 @@ export interface SecureHeadersOptions {
19
20
  /** Whether `X-Powered-By` and `Server` are removed. On by default. */
20
21
  readonly hidePoweredBy?: boolean;
21
22
  }
23
+ /**
24
+ * What `secureHeaders({ nonce: true })` returns: an app plugin, whose routes
25
+ * after it read `nonce`, and whose hook sets the header with the same one.
26
+ */
27
+ export type NoncePlugin = Alxia<NonceContext, Empty, '', never> & Requiring<Empty>;
22
28
  /**
23
29
  * Secure headers on every response, as a plugin. A header a route set
24
30
  * itself is kept: a page that needs its own `Content-Security-Policy` —
@@ -27,6 +33,20 @@ export interface SecureHeadersOptions {
27
33
  * ```ts
28
34
  * app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'" }));
29
35
  * ```
36
+ *
37
+ * With `nonce: true`, each request gets a nonce of its own, in the policy
38
+ * and on the context of the routes declared after it:
39
+ *
40
+ * ```ts
41
+ * app
42
+ * .use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
43
+ * .get('/', ({ nonce, reply }) => reply(200, `<script nonce="${nonce}">…</script>`));
44
+ * ```
30
45
  */
31
- export declare function secureHeaders(options?: SecureHeadersOptions): Plugin;
46
+ export declare function secureHeaders(options: SecureHeadersOptions & {
47
+ readonly nonce: true;
48
+ }): NoncePlugin;
49
+ export declare function secureHeaders(options?: SecureHeadersOptions & {
50
+ readonly nonce?: false;
51
+ }): Plugin;
32
52
  //# sourceMappingURL=secure-headers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAe,MAAM,aAAa,CAAC;AAEvD,+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;AAiCD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,MAAM,CA6BxE"}
1
+ {"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,KAAK,EAEV,KAAK,KAAK,EACV,KAAK,MAAM,EACX,KAAK,SAAS,EAEd,MAAM,aAAa,CAAC;AACrB,OAAO,EAAS,KAAK,YAAY,EAA+B,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,WAAW,GAAG,KAAK,CAAC,YAAY,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,CAAC,GAC9D,SAAS,CAAC,KAAK,CAAC,CAAC;AAiClB;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GACtD,WAAW,CAAC;AACf,wBAAgB,aAAa,CAC5B,OAAO,CAAC,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACzD,MAAM,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, giving a page its own policy, ordering it with other hooks, or testing the headers |
9
+ | [Guide](guide.md) | changing or dropping a header, a nonce per request, giving a page its own policy, ordering it with other hooks, 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
@@ -1,8 +1,8 @@
1
1
  # Guide
2
2
 
3
3
  This page covers `secureHeaders`: what it sends, how to change or drop each
4
- header, which responses it reaches, and how a page or another plugin sets
5
- its own policy.
4
+ header, the per-request nonce, which responses it reaches, and how a page or
5
+ another plugin sets its own policy.
6
6
 
7
7
  ```ts
8
8
  import { alxia } from '@alxia/core';
@@ -21,7 +21,8 @@ Every response of that app now carries the headers below, and none carries
21
21
  ## The signature
22
22
 
23
23
  ```ts
24
- function secureHeaders(options?: SecureHeadersOptions): Plugin;
24
+ function secureHeaders(options: SecureHeadersOptions & { readonly nonce: true }): NoncePlugin;
25
+ function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }): Plugin;
25
26
 
26
27
  interface SecureHeadersOptions {
27
28
  readonly contentSecurityPolicy?: string | false;
@@ -40,6 +41,9 @@ interface SecureHeadersOptions {
40
41
  }
41
42
  ```
42
43
 
44
+ `nonce` is not in `SecureHeadersOptions`: each overload adds it, so options
45
+ typed by that interface still give the plain `Plugin`.
46
+
43
47
  `secureHeaders` returns a function `Plugin` from `@alxia/core`: give it to
44
48
  `use`, called, and the app keeps its type. It adds one global `onResponse`
45
49
  hook, so where it sits in the chain does not matter for which routes it
@@ -49,7 +53,13 @@ app's. To vary a header for some routes, set it on their replies
49
53
  ([below](#a-header-a-route-sets-is-kept)).
50
54
 
51
55
  The options are read once, when `secureHeaders(…)` is called; the header
52
- values are fixed from then on.
56
+ values are fixed from then on, but for the nonce, which is new on every
57
+ request.
58
+
59
+ With `nonce: true` it returns a `NoncePlugin` instead: an app plugin, still
60
+ given to `use` called, whose hook covers every route as above, and which
61
+ adds `nonce` to the context of the routes declared after it
62
+ ([below](#a-nonce-per-request)).
53
63
 
54
64
  ## The options
55
65
 
@@ -74,6 +84,7 @@ leave the header out. An option left out keeps the default.
74
84
  | Option | Type | Default | Effect |
75
85
  | --- | --- | --- | --- |
76
86
  | `hidePoweredBy` | `boolean` | `true` | removes `X-Powered-By` and `Server` from every response, a route's own included |
87
+ | `nonce` | `boolean` | `false` | a fresh nonce per request, in the policy and on the context: [A nonce per request](#a-nonce-per-request) |
77
88
 
78
89
  ### A value
79
90
 
@@ -166,6 +177,110 @@ That is how `@alxia/openapi`'s reference page and `@alxia/graphql`'s IDE
166
177
  load under the strict default: each sets the `Content-Security-Policy` it
167
178
  needs, and `secureHeaders` keeps it.
168
179
 
180
+ ## A nonce per request
181
+
182
+ A policy that allows a page's inline scripts without `'unsafe-inline'`
183
+ names a nonce: `script-src 'nonce-…'`, and the same value on each
184
+ `<script nonce="…">`. It must be unguessable and new on every response.
185
+ `nonce: true` does both halves.
186
+
187
+ ```ts
188
+ import { alxia } from '@alxia/core';
189
+ import { secureHeaders } from '@alxia/secure-headers';
190
+
191
+ const app = alxia()
192
+ .use(
193
+ secureHeaders({
194
+ nonce: true,
195
+ contentSecurityPolicy: "default-src 'self'; script-src 'self'; frame-ancestors 'none'",
196
+ }),
197
+ )
198
+ .get('/', ({ nonce, reply }) =>
199
+ reply(
200
+ 200,
201
+ `<!doctype html><script nonce="${nonce}">document.title = 'ready'</script>`,
202
+ { headers: { 'content-type': 'text/html; charset=utf-8' } },
203
+ ),
204
+ );
205
+
206
+ const response = await app.request('/');
207
+ response.headers.get('content-security-policy');
208
+ // "default-src 'self'; script-src 'self' 'nonce-Gm1y0S3h1JZcVvXQ4l6x0A=='; frame-ancestors 'none'"
209
+ await response.text();
210
+ // "<!doctype html><script nonce=\"Gm1y0S3h1JZcVvXQ4l6x0A==\">…"
211
+ ```
212
+
213
+ ### What it is
214
+
215
+ 16 bytes from `crypto.getRandomValues`, base64: 24 characters, `==` at the
216
+ end. Each request makes its own, when the first hook or route asks for it,
217
+ and every reader of that request — the route, its hooks, the header — gets
218
+ the same one. Two requests never share one.
219
+
220
+ ### Where it goes in the policy
221
+
222
+ | The policy | The nonce goes |
223
+ | --- | --- |
224
+ | names `NONCE` | where `NONCE` stands, each time, and nowhere else |
225
+ | has no `NONCE`, has `script-src` or `script-src-elem` | at the end of each of those directives (their name in any case) |
226
+ | has neither, as the default policy | nowhere: `secureHeaders()` throws at startup; give `contentSecurityPolicy` with `nonce: true` |
227
+
228
+ `NONCE`, exported, is a placeholder string (`'nonce-{alxia}'`) that no real
229
+ policy contains. Use it to put the nonce in `style-src`, or in a directive
230
+ of your own choosing:
231
+
232
+ ```ts
233
+ import { NONCE, secureHeaders } from '@alxia/secure-headers';
234
+
235
+ app.use(
236
+ secureHeaders({
237
+ nonce: true,
238
+ contentSecurityPolicy: [
239
+ "default-src 'self'",
240
+ `script-src 'self' ${NONCE} 'strict-dynamic'`,
241
+ `style-src 'self' ${NONCE}`,
242
+ ].join('; '),
243
+ }),
244
+ );
245
+ ```
246
+
247
+ Leaving `style-src` alone is the default on purpose: once a directive holds
248
+ a nonce, browsers ignore its `'unsafe-inline'`, and `style="…"` attributes,
249
+ which take no nonce, stop applying
250
+ ([troubleshooting](troubleshooting.md#inline-style-attributes-stop-applying-once-style-src-has-the-nonce)).
251
+
252
+ ### Who reads it
253
+
254
+ The header covers every response, as without the nonce: 404s, 500s and the
255
+ routes declared before the plugin get a policy with a nonce of their own.
256
+ `ctx.nonce` is typed and set on the routes declared **after** `use`, in
257
+ their hooks and their handler, as any plugin's context:
258
+
259
+ ```ts
260
+ alxia()
261
+ .get('/early', ({ reply }) => reply(200, 'policy with a nonce, no ctx.nonce'))
262
+ .use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
263
+ .derive(({ nonce }) => ({ scriptTag: (code: string) => `<script nonce="${nonce}">${code}</script>` }))
264
+ .get('/late', ({ scriptTag, reply }) => reply(200, scriptTag('…')));
265
+ ```
266
+
267
+ A route that sets its own `Content-Security-Policy` keeps it, as
268
+ [above](#a-header-a-route-sets-is-kept); its `ctx.nonce` is then in no
269
+ header, unless it writes it into its own policy.
270
+
271
+ ### With React Router
272
+
273
+ `@alxia/react-router` reads the nonce from the context, if one is there,
274
+ with `nonceOf(loadContext)` in `entry.server.tsx`. Neither package depends
275
+ on the other. See
276
+ [its guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide.md#a-csp-nonce).
277
+
278
+ ### Without `nonce: true`
279
+
280
+ Nothing changes: the same headers, the same values, no `nonce` on the
281
+ context. A policy that names `NONCE` without `nonce: true` is refused at
282
+ startup, so the placeholder never reaches a browser.
283
+
169
284
  ## Which responses get the headers
170
285
 
171
286
  The plugin runs as an `onResponse` hook, so it reaches what the app's
@@ -259,6 +374,8 @@ app.use(
259
374
 
260
375
  Or keep the strict default for the API and give the pages their policy on
261
376
  their reply, as in [A header a route sets is kept](#a-header-a-route-sets-is-kept).
377
+ When the pages run inline scripts, add `nonce: true` rather than
378
+ `'unsafe-inline'`: [A nonce per request](#a-nonce-per-request).
262
379
 
263
380
  ### Framed by your own origin
264
381
 
package/docs/roadmap.md CHANGED
@@ -25,6 +25,15 @@ Nothing scheduled yet.
25
25
 
26
26
  ## Shipped
27
27
 
28
+ ### Next release
29
+
30
+ - **A nonce per request.** `secureHeaders({ nonce: true })` makes a fresh,
31
+ random nonce for each request, adds it to the policy's `script-src`, or
32
+ wherever the policy names `NONCE`, and gives the same one to the routes
33
+ after it as `ctx.nonce`, typed. A page's inline scripts run without
34
+ `'unsafe-inline'`; `@alxia/react-router`'s `nonceOf` hands it to React
35
+ Router. Without the option, nothing changes.
36
+
28
37
  ### 0.1.0
29
38
 
30
39
  - **Secure headers on every response.** `secureHeaders()` is a plugin for
@@ -2,25 +2,33 @@
2
2
 
3
3
  Each entry is headed by the text you see: a TypeScript error, an exception,
4
4
  a browser console message, or a header that is or is not on a response.
5
- `secureHeaders` itself throws nothing; what goes wrong is a type, a
6
- browser refusing what a header forbids, or a header in the wrong place.
5
+ `secureHeaders` throws only at startup, on options it cannot send; what
6
+ goes wrong otherwise is a type, a browser refusing what a header forbids,
7
+ or a header in the wrong place.
7
8
 
8
9
  **Types**
9
10
 
10
11
  - [`Type 'true' is not assignable to type 'Setting | undefined'`](#type-true-is-not-assignable-to-type-setting--undefined)
11
- - [`… is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`](#-is-not-assignable-to-parameter-of-type-secureheadersoptions-with-exactoptionalpropertytypes-true)
12
- - [`Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions'`](#object-literal-may-only-specify-known-properties-and--does-not-exist-in-type-secureheadersoptions)
13
- - [`No overload matches this call` … `has no properties in common with type 'SecureHeadersOptions'`](#no-overload-matches-this-call--has-no-properties-in-common-with-type-secureheadersoptions)
12
+ - [`No overload matches this call` … `is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`](#no-overload-matches-this-call--is-not-assignable-to-type-secureheadersoptions-with-exactoptionalpropertytypes-true)
13
+ - [`No overload matches this call` … `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions & …'`](#no-overload-matches-this-call--object-literal-may-only-specify-known-properties-and--does-not-exist-in-type-secureheadersoptions--)
14
+ - [`No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, Empty, "", never>'`](#no-overload-matches-this-call--property-nonce-is-missing-in-type-alxiaempty-empty--never)
15
+ - [`Property 'nonce' does not exist on type 'Context<…>'`](#property-nonce-does-not-exist-on-type-context)
16
+ - [`No overload matches this call` … `Type 'boolean' is not assignable to type 'true'`](#no-overload-matches-this-call--type-boolean-is-not-assignable-to-type-true)
14
17
 
15
18
  **Runtime**
16
19
 
17
20
  - [`TypeError: secureHeaders: … is empty; give false to leave the … header out`](#typeerror-secureheaders--is-empty-give-false-to-leave-the--header-out)
18
21
  - [`TypeError: alxia().use(secureHeaders).get is not a function`](#typeerror-alxiausesecureheadersget-is-not-a-function)
22
+ - [`TypeError: secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: …`](#typeerror-secureheaders-nonce-is-on-but-the-content-security-policy-has-no-script-src-to-add-it-to-)
23
+ - [`TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`](#typeerror-secureheaders-nonce-is-on-but-contentsecuritypolicy-is-false-)
24
+ - [`TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`](#typeerror-secureheaders-contentsecuritypolicy-names-nonce-but-nonce-is-off-give-nonce-true)
19
25
 
20
26
  **In the browser**
21
27
 
22
28
  - [`Refused to load the … because it violates the following Content Security Policy directive: "default-src 'none'"`](#refused-to-load-the--because-it-violates-the-following-content-security-policy-directive-default-src-none)
23
29
  - [`Refused to send form data to '…' because it violates the following Content Security Policy directive: "form-action 'none'"`](#refused-to-send-form-data-to--because-it-violates-the-following-content-security-policy-directive-form-action-none)
30
+ - [`Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)
31
+ - [Inline style attributes stop applying once `style-src` has the nonce](#inline-style-attributes-stop-applying-once-style-src-has-the-nonce)
24
32
  - [`Refused to frame '…' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'"`](#refused-to-frame--because-an-ancestor-violates-the-following-content-security-policy-directive-frame-ancestors-none)
25
33
  - [`net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin`](#neterr_blocked_by_responsenotsameorigin)
26
34
  - [`window.opener` is `null` in a sign-in popup](#windowopener-is-null-in-a-sign-in-popup)
@@ -40,10 +48,16 @@ browser refusing what a header forbids, or a header in the wrong place.
40
48
  **When:** a header option is given `true`, an object or an array.
41
49
 
42
50
  ```text
43
- error TS2322: Type 'true' is not assignable to type 'Setting | undefined'.
44
- error TS2322: Type '{ defaultSrc: string[]; }' is not assignable to type 'Setting | undefined'.
51
+ error TS2769: No overload matches this call.
52
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
53
+ Type 'true' is not assignable to type 'Setting'.
54
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
55
+ Type 'true' is not assignable to type 'Setting | undefined'.
45
56
  ```
46
57
 
58
+ An object, `{ defaultSrc: [...] }`, reads the same with its own type in
59
+ place of `'true'`.
60
+
47
61
  `hidePoweredBy` given anything but a boolean is the same error, against
48
62
  `'boolean | undefined'`.
49
63
 
@@ -63,13 +77,18 @@ app.use(
63
77
  );
64
78
  ```
65
79
 
66
- ### `… is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`
80
+ ### `No overload matches this call` … `is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`
67
81
 
68
82
  **When:** an option is set to `undefined`, often from a condition, with
69
83
  `exactOptionalPropertyTypes` on.
70
84
 
71
85
  ```text
72
- error TS2379: Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
86
+ error TS2769: No overload matches this call.
87
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
88
+ Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce: true; }' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
89
+ Type '{ xFrameOptions: undefined; }' is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
90
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
91
+ Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce?: false; }' with 'exactOptionalPropertyTypes: true'. …
73
92
  ```
74
93
 
75
94
  **Why:** the options are optional, not `undefined`-able. Under that flag a
@@ -83,17 +102,21 @@ const production = Bun.env.NODE_ENV === 'production';
83
102
  app.use(secureHeaders({ ...(production ? {} : { strictTransportSecurity: false }) }));
84
103
  ```
85
104
 
86
- ### `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions'`
105
+ ### `No overload matches this call` … `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions & …'`
87
106
 
88
107
  **When:** an option names a header the plugin does not send, or uses
89
108
  another library's name for one.
90
109
 
91
110
  ```text
92
- error TS2353: Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions'.
111
+ error TS2769: No overload matches this call.
112
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
113
+ Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce: true; }'.
114
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
115
+ Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce?: false; }'.
93
116
  ```
94
117
 
95
118
  **Why:** the options are the twelve headers in the
96
- [guide](guide.md#the-options), plus `hidePoweredBy`. `X-Powered-By` and
119
+ [guide](guide.md#the-options), plus `hidePoweredBy` and `nonce`. `X-Powered-By` and
97
120
  `Server` are governed by `hidePoweredBy`; anything else —
98
121
  `Content-Security-Policy-Report-Only`, `X-XSS-Protection` — is not sent by
99
122
  the plugin.
@@ -114,20 +137,22 @@ const app = alxia()
114
137
  );
115
138
  ```
116
139
 
117
- ### `No overload matches this call` … `has no properties in common with type 'SecureHeadersOptions'`
140
+ ### `No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, Empty, "", never>'`
118
141
 
119
142
  **When:** `secureHeaders` is given to `use` without being called.
120
143
 
121
144
  ```text
122
145
  error TS2769: No overload matches this call.
123
146
  Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
124
- Argument of type '(options?: SecureHeadersOptions | undefined) => Plugin' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => AnyAlxia'.
147
+ Argument of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin; (options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin; }' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => AnyAlxia'.
125
148
  Types of parameters 'options' and 'app' are incompatible.
126
- Type 'Alxia<Empty, Empty, "", never>' has no properties in common with type 'SecureHeadersOptions'.
149
+ Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'.
150
+ Property 'nonce' is missing in type 'Alxia<Empty, Empty, "", never>' but required in type '{ readonly nonce: true; }'.
127
151
  ```
128
152
 
129
153
  **Why:** `secureHeaders` makes the plugin from its options; the plugin is
130
- what it returns.
154
+ what it returns. TypeScript tries the app as the options of its first
155
+ overload, the nonce one, and says what that lacks.
131
156
 
132
157
  **Fix:** call it, with or without options:
133
158
 
@@ -135,6 +160,54 @@ what it returns.
135
160
  app.use(secureHeaders());
136
161
  ```
137
162
 
163
+ ### `Property 'nonce' does not exist on type 'Context<…>'`
164
+
165
+ **When:** a route or a hook reads `nonce` from its context.
166
+
167
+ ```text
168
+ error TS2339: Property 'nonce' does not exist on type 'Context<Empty, "/", Empty>'.
169
+ ```
170
+
171
+ **Why:** only `secureHeaders({ nonce: true })` adds it, and only to the
172
+ routes declared after its `use`. Without `nonce: true`, or on a route
173
+ declared before it, there is none.
174
+
175
+ **Fix:** turn it on, and declare the routes that read it after it:
176
+
177
+ ```ts
178
+ alxia()
179
+ .use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
180
+ .get('/', ({ nonce, reply }) => reply(200, nonce));
181
+ ```
182
+
183
+ ### `No overload matches this call` … `Type 'boolean' is not assignable to type 'true'`
184
+
185
+ **When:** `nonce` is given a `boolean` that is not a literal: a flag from
186
+ the environment, say.
187
+
188
+ ```text
189
+ error TS2769: No overload matches this call.
190
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
191
+ Type 'boolean' is not assignable to type 'true'.
192
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
193
+ Type 'boolean' is not assignable to type 'false'.
194
+ ```
195
+
196
+ **Why:** `nonce: true` changes the plugin's type, since it adds `nonce`
197
+ to the context. A `boolean` leaves TypeScript unable to say whether the
198
+ routes read one.
199
+
200
+ **Fix:** pick the call by the flag, so each has a literal:
201
+
202
+ ```ts
203
+ const policy = "default-src 'self'; script-src 'self'";
204
+ const app = useNonce
205
+ ? alxia().use(secureHeaders({ nonce: true, contentSecurityPolicy: policy }))
206
+ : alxia().use(secureHeaders({ contentSecurityPolicy: policy }));
207
+ ```
208
+
209
+ Or keep it on everywhere: the nonce costs one random draw per request.
210
+
138
211
  ## Runtime
139
212
 
140
213
  ### `TypeError: secureHeaders: … is empty; give false to leave the … header out`
@@ -173,6 +246,65 @@ const app = alxia()
173
246
  .get('/', ({ reply }) => reply(200, 'ok'));
174
247
  ```
175
248
 
249
+ ### `TypeError: secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: …`
250
+
251
+ **When:** `nonce: true`, with a policy that has neither a `script-src` (or
252
+ `script-src-elem`) nor `NONCE` — the default policy among them. It throws
253
+ at startup:
254
+
255
+ ```text
256
+ TypeError: 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 "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
257
+ ```
258
+
259
+ **Why:** the nonce goes in a directive. Without `script-src`, scripts fall
260
+ back on `default-src`, and adding a `script-src` of the plugin's own would
261
+ change what else they may load.
262
+
263
+ **Fix:** write the `script-src` you want, or say where the nonce goes:
264
+
265
+ ```ts
266
+ import { NONCE, secureHeaders } from '@alxia/secure-headers';
267
+
268
+ app.use(secureHeaders({ nonce: true, contentSecurityPolicy: "default-src 'self'; script-src 'self'" }));
269
+ app.use(secureHeaders({ nonce: true, contentSecurityPolicy: `default-src 'self' ${NONCE}` }));
270
+ ```
271
+
272
+ ### `TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`
273
+
274
+ **When:** `nonce: true` and `contentSecurityPolicy: false` together.
275
+
276
+ ```text
277
+ TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header
278
+ ```
279
+
280
+ **Why:** a browser only honours a nonce the response's policy names; with
281
+ no policy there is nothing for it to allow.
282
+
283
+ **Fix:** give a policy, or drop `nonce`. A route that writes its own policy
284
+ with `ctx.nonce` still needs the plugin's: give the app one, and let the
285
+ route's replace it ([guide](guide.md#who-reads-it)).
286
+
287
+ ### `TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`
288
+
289
+ **When:** the policy holds `NONCE`, and `nonce` is left out or `false`.
290
+ It throws at startup:
291
+
292
+ ```text
293
+ TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true
294
+ ```
295
+
296
+ **Why:** without `nonce: true` the placeholder would go out as it is,
297
+ `'nonce-{alxia}'`, which allows nothing and reads as a mistake in a
298
+ browser's console.
299
+
300
+ **Fix:**
301
+
302
+ ```ts
303
+ import { NONCE, secureHeaders } from '@alxia/secure-headers';
304
+
305
+ app.use(secureHeaders({ nonce: true, contentSecurityPolicy: `script-src 'self' ${NONCE}` }));
306
+ ```
307
+
176
308
  ## In the browser
177
309
 
178
310
  These messages are Chrome's wording; Firefox and Safari report the same
@@ -220,6 +352,51 @@ form, even to itself.
220
352
  const PAGE_POLICY = "default-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'";
221
353
  ```
222
354
 
355
+ ### `Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`
356
+
357
+ **When:** `nonce: true` is on, and a page's inline script does not run.
358
+ Chrome adds `Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a
359
+ nonce ('nonce-...') is required to enable inline execution.`
360
+
361
+ **Why:** the script carries no nonce, or another request's: one cached with
362
+ the page, or written into the HTML at build time. Each response allows only
363
+ its own.
364
+
365
+ **Fix:** write the request's `ctx.nonce` on each `<script>`, and never
366
+ cache a page whose HTML holds one:
367
+
368
+ ```ts
369
+ .get('/', ({ nonce, reply }) =>
370
+ reply(200, `<script nonce="${nonce}">…</script>`, {
371
+ headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
372
+ }),
373
+ )
374
+ ```
375
+
376
+ With React Router, `nonceOf(loadContext)` in `entry.server.tsx` does it for
377
+ every script it renders; see
378
+ [its troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#a-script-of-the-page-has-no-nonce).
379
+
380
+ ### Inline style attributes stop applying once `style-src` has the nonce
381
+
382
+ **When:** `NONCE` is put in `style-src`, which also has `'unsafe-inline'`,
383
+ and every `style="…"` attribute is ignored: Chrome prints `Refused to apply
384
+ inline style because it violates the following Content Security Policy
385
+ directive: "style-src 'self' 'unsafe-inline' 'nonce-…'". Note that
386
+ 'unsafe-inline' is ignored if either a hash or nonce value is present in
387
+ the source list.`
388
+
389
+ **Why:** a nonce in a directive turns its `'unsafe-inline'` off, and an
390
+ attribute cannot carry a nonce. That is why `nonce: true` alone leaves
391
+ `style-src` as written.
392
+
393
+ **Fix:** keep the nonce out of `style-src`, or allow the attributes in a
394
+ directive of their own:
395
+
396
+ ```ts
397
+ contentSecurityPolicy: `default-src 'self'; script-src 'self'; style-src-elem 'self' ${NONCE}; style-src-attr 'unsafe-inline'`,
398
+ ```
399
+
223
400
  ### `Refused to frame '…' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'"`
224
401
 
225
402
  **When:** a page puts a response of the app in an `<iframe>`. With the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/secure-headers",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Secure HTTP headers for alxia: HSTS, CSP, nosniff and the rest, each one adjustable",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,13 +41,13 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/client": "^0.1.0",
45
- "@alxia/core": "^0.1.0",
44
+ "@alxia/client": "^0.2.1",
45
+ "@alxia/core": "^0.3.0",
46
46
  "@types/bun": "^1.4.2",
47
47
  "zod": "^4.2.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@alxia/core": "^0.1.0",
50
+ "@alxia/core": "^0.3.0",
51
51
  "typescript": "^6.0.3 || ^7.0.0"
52
52
  }
53
53
  }