@alxia/secure-headers 0.1.1 → 0.3.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
@@ -18,8 +18,58 @@ app.use(secureHeaders());
18
18
  app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'", xFrameOptions: false }));
19
19
  ```
20
20
 
21
- A header a route sets itself is kept: a page that needs its own policy sets
22
- it on its reply.
21
+ `secureHeaders()` is a middleware: every response that comes back through
22
+ it carries the headers, a 404's, an error's and a refused preflight's
23
+ included. A header a route sets itself is kept: a page that needs its own
24
+ policy sets it on its reply.
25
+
26
+ ## A nonce per request
27
+
28
+ `nonce: true` makes a fresh nonce for each request (128 random bits,
29
+ base64), puts it in the policy as `'nonce-…'`, and gives it to the routes
30
+ declared after the middleware as `ctx.nonce`. The header and the context always
31
+ carry the same one, so a page's inline scripts need no `'unsafe-inline'`.
32
+
33
+ ```ts
34
+ import { alxia } from '@alxia/core';
35
+ import { secureHeaders } from '@alxia/secure-headers';
36
+
37
+ const app = alxia()
38
+ .use(
39
+ secureHeaders({
40
+ nonce: true,
41
+ // The nonce is added to script-src (and script-src-elem).
42
+ contentSecurityPolicy: "default-src 'self'; script-src 'self'",
43
+ }),
44
+ )
45
+ .get('/', ({ nonce, reply }) =>
46
+ reply(200, `<script nonce="${nonce}">console.log('allowed')</script>`, {
47
+ headers: { 'content-type': 'text/html' },
48
+ }),
49
+ );
50
+ // content-security-policy: default-src 'self'; script-src 'self' 'nonce-3q2+7w…=='
51
+ ```
52
+
53
+ To place it yourself, in `style-src` say, write `NONCE` where it goes;
54
+ each occurrence becomes the request's `'nonce-…'`, and `script-src` is then
55
+ left as written:
56
+
57
+ ```ts
58
+ import { NONCE, secureHeaders } from '@alxia/secure-headers';
59
+
60
+ app.use(
61
+ secureHeaders({
62
+ nonce: true,
63
+ contentSecurityPolicy: `default-src 'self'; script-src 'self' ${NONCE}; style-src 'self' ${NONCE}`,
64
+ }),
65
+ );
66
+ ```
67
+
68
+ A policy with nowhere to put the nonce (no `script-src`, no `NONCE`) is
69
+ refused at startup, the default policy included: give `contentSecurityPolicy`
70
+ with `nonce: true`. Without `nonce: true`, the headers are what they always
71
+ were. With `@alxia/react-router`, `nonceOf(loadContext)` hands it to
72
+ `entry.server.tsx` ([its README](https://github.com/softistx/alxia/tree/develop/packages/react-router#a-csp-nonce)).
23
73
 
24
74
  ## Defaults
25
75
 
@@ -39,19 +89,37 @@ it on its reply.
39
89
  | `Permissions-Policy` | not sent | `permissionsPolicy` |
40
90
 
41
91
  Each option takes a value, or `false` to leave the header out; an empty
42
- value is refused at startup.
92
+ value is refused at startup. `nonce` is off by default.
43
93
  `X-Powered-By` and `Server` are removed unless `hidePoweredBy: false`.
44
94
 
45
95
  ## API
46
96
 
47
97
  | export | |
48
98
  | --- | --- |
49
- | `secureHeaders(options?)` | the plugin |
50
- | `SecureHeadersOptions` | its options |
99
+ | `secureHeaders(options?)` | the middleware, for `app.use`: a `SecureHeaders`, or with `nonce: true` a `NonceMiddleware` |
100
+ | `SecureHeadersOptions` | its options: the headers and `hidePoweredBy`; `nonce` is added by each overload |
51
101
  | `Setting` | a header option's type: its value, or `false` to leave it out |
102
+ | `NONCE` | where the nonce goes in `contentSecurityPolicy`, each time it is named |
103
+ | `NonceContext` | what `nonce: true` adds to the context: `nonce`, a string |
104
+ | `SecureHeaders` | what `secureHeaders()` returns: a middleware that adds nothing to the context |
105
+ | `NonceMiddleware` | what `secureHeaders({ nonce: true })` returns: a middleware adding `NonceContext` |
106
+ | `NoncePlugin` | deprecated: the former name of `NonceMiddleware` |
107
+
108
+ ## Traps
109
+
110
+ `use` it first: a route declared before `app.use(secureHeaders())` gets no
111
+ header, and `ctx.nonce` exists only on routes declared after it. It
112
+ settles `next()` without swallowing the error: a `try`/`catch` middleware
113
+ catches it wherever it stands, but only one declared after
114
+ `secureHeaders()` has the headers on its reply. Declare error-handling
115
+ middleware after it.
116
+
117
+ ```ts
118
+ const app = alxia().use(secureHeaders()).use(errorHandler).get(...);
119
+ ```
52
120
 
53
121
  ## Documentation
54
122
 
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.
123
+ - [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 middlewares, and recipes.
56
124
  - [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
125
  - [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 NonceMiddleware, type NoncePlugin, type SecureHeaders, 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,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,27 @@
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
+ }
1
19
  // src/secure-headers.ts
2
- import { withHeaders } from "@alxia/core";
20
+ import {
21
+ defineMiddleware,
22
+ settle,
23
+ withHeaders
24
+ } from "@alxia/core";
3
25
  var DEFAULTS = {
4
26
  "content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
5
27
  "strict-transport-security": "max-age=31536000; includeSubDomains",
@@ -29,7 +51,30 @@ var OPTION = {
29
51
  "permissions-policy": "permissionsPolicy"
30
52
  };
31
53
  function secureHeaders(options = {}) {
32
- const headers = [];
54
+ const headers = fixedHeaders(options);
55
+ const hide = options.hidePoweredBy ?? true;
56
+ const policy = headers.get("content-security-policy");
57
+ if (!options.nonce) {
58
+ if (policy?.includes(NONCE)) {
59
+ throw new TypeError("secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true");
60
+ }
61
+ const set = setter(headers, hide);
62
+ return defineMiddleware(async (ctx, next) => set(await settle(ctx, next())));
63
+ }
64
+ if (policy === undefined) {
65
+ throw new TypeError("secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header");
66
+ }
67
+ headers.delete("content-security-policy");
68
+ const withNonce = policyWithNonce(policy);
69
+ const set = setter(headers, hide);
70
+ return defineMiddleware(async (ctx, next) => {
71
+ const added = { nonce: freshNonce() };
72
+ const response = await settle(ctx, next(added));
73
+ return set(response, withNonce(added.nonce));
74
+ });
75
+ }
76
+ function fixedHeaders(options) {
77
+ const headers = new Map;
33
78
  for (const [name, fallback] of Object.entries(DEFAULTS)) {
34
79
  const setting = options[OPTION[name]];
35
80
  if (typeof setting === "string" && setting.trim() === "") {
@@ -37,23 +82,29 @@ function secureHeaders(options = {}) {
37
82
  }
38
83
  const value = setting === undefined ? fallback : setting;
39
84
  if (typeof value === "string")
40
- headers.push([name, value]);
85
+ headers.set(name, value);
41
86
  }
42
- const hide = options.hidePoweredBy ?? true;
43
- return (app) => app.onResponse((response) => withHeaders(response, (current) => {
87
+ return headers;
88
+ }
89
+ function setter(headers, hide) {
90
+ return (response, policy) => withHeaders(response, (current) => {
44
91
  for (const [name, value] of headers) {
45
92
  if (!current.has(name))
46
93
  current.set(name, value);
47
94
  }
95
+ if (policy !== undefined && !current.has("content-security-policy")) {
96
+ current.set("content-security-policy", policy);
97
+ }
48
98
  if (hide) {
49
99
  current.delete("x-powered-by");
50
100
  current.delete("server");
51
101
  }
52
- }));
102
+ });
53
103
  }
54
104
  export {
105
+ NONCE,
55
106
  secureHeaders
56
107
  };
57
108
 
58
- //# debugId=DD4A7A8B39A4596664756E2164756E21
109
+ //# debugId=73869EA605B3C65B64756E2164756E21
59
110
  //# 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, and how one is made.\n */\n\n/**\n * Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes\n * `'nonce-<value>'`, the request's own value. Without it, `nonce: true`\n * adds the nonce to `script-src` and `script-src-elem`.\n *\n * ```ts\n * secureHeaders({\n * nonce: true,\n * contentSecurityPolicy: `default-src 'self'; style-src 'self' ${NONCE}`,\n * });\n * ```\n */\nexport const NONCE = \"'nonce-{alxia}'\";\n\n/** What `secureHeaders({ nonce: true })` adds to the context of the routes after it. */\nexport interface NonceContext {\n\t/**\n\t * This request's nonce, base64, the one its `Content-Security-Policy`\n\t * allows: what a page puts on its `<script nonce>`.\n\t */\n\treadonly nonce: string;\n}\n\n/** The directives the nonce is added to when the policy does not place it. */\nconst SCRIPT_DIRECTIVES = new Set(['script-src', 'script-src-elem']);\n\n/**\n * The policy as a function of the nonce: split where `NONCE` stands, or\n * where it is added to the script directives. Throws when there is\n * nowhere to put it.\n */\nexport function policyWithNonce(policy: string): (nonce: string) => string {\n\tconst placed = policy.includes(NONCE)\n\t\t? policy\n\t\t: policy\n\t\t\t\t.split(';')\n\t\t\t\t.map((directive) => {\n\t\t\t\t\tconst name = directive.trim().split(/\\s+/)[0]?.toLowerCase() ?? '';\n\t\t\t\t\treturn SCRIPT_DIRECTIVES.has(name)\n\t\t\t\t\t\t? `${directive.trimEnd()} ${NONCE}`\n\t\t\t\t\t\t: directive;\n\t\t\t\t})\n\t\t\t\t.join(';');\n\tif (!placed.includes(NONCE)) {\n\t\tthrow new TypeError(\n\t\t\t`secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: write one, or place NONCE where the nonce goes (the policy is \"${policy}\")`,\n\t\t);\n\t}\n\tconst parts = placed.split(NONCE);\n\treturn (nonce) => parts.join(`'nonce-${nonce}'`);\n}\n\n/** 128 random bits, base64: a fresh nonce. */\nexport function freshNonce(): string {\n\tconst bytes = crypto.getRandomValues(new Uint8Array(16));\n\treturn btoa(String.fromCharCode(...bytes));\n}\n",
6
+ "import {\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Next,\n\tsettle,\n\twithHeaders,\n} from '@alxia/core';\nimport { freshNonce, NONCE, type NonceContext, policyWithNonce } from './nonce';\n\n/** A header's value, or `false` to leave it out. An empty value is refused. */\nexport type Setting = string | false;\n\nexport interface SecureHeadersOptions {\n\t/** Defaults to a policy for an API: nothing loads, nothing frames it. */\n\treadonly contentSecurityPolicy?: Setting;\n\treadonly strictTransportSecurity?: Setting;\n\treadonly xContentTypeOptions?: Setting;\n\treadonly xFrameOptions?: Setting;\n\treadonly referrerPolicy?: Setting;\n\treadonly crossOriginOpenerPolicy?: Setting;\n\treadonly crossOriginResourcePolicy?: Setting;\n\treadonly crossOriginEmbedderPolicy?: Setting;\n\treadonly originAgentCluster?: Setting;\n\treadonly xDnsPrefetchControl?: Setting;\n\treadonly xPermittedCrossDomainPolicies?: Setting;\n\t/** Off by default: a policy is the app's to write. */\n\treadonly permissionsPolicy?: Setting;\n\t/** Whether `X-Powered-By` and `Server` are removed. On by default. */\n\treadonly hidePoweredBy?: boolean;\n}\n\n/**\n * The nonce switch, beside `SecureHeadersOptions` rather than in it, so that\n * options typed by that interface still pick the plugin without a nonce.\n */\ninterface NonceOption {\n\t/**\n\t * A fresh nonce for each request, in `contentSecurityPolicy` and on the\n\t * context as `nonce`. Placed where the policy names `NONCE`, or added to\n\t * its `script-src`. Off by default.\n\t */\n\treadonly nonce?: boolean;\n}\n\n/**\n * What `secureHeaders()` returns: a middleware, for `app.use`, that sets\n * the headers on every response after it.\n */\nexport type SecureHeaders = Middleware<Empty, Promise<Response>> &\n\tMiddlewareMark;\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\tMiddlewareMark;\n\n/** @deprecated Renamed `NonceMiddleware`: it is a middleware. */\nexport type NoncePlugin = NonceMiddleware;\n\nconst DEFAULTS = {\n\t'content-security-policy':\n\t\t\"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n\t'strict-transport-security': 'max-age=31536000; includeSubDomains',\n\t'x-content-type-options': 'nosniff',\n\t'x-frame-options': 'DENY',\n\t'referrer-policy': 'no-referrer',\n\t'cross-origin-opener-policy': 'same-origin',\n\t'cross-origin-resource-policy': 'same-origin',\n\t'cross-origin-embedder-policy': false,\n\t'origin-agent-cluster': '?1',\n\t'x-dns-prefetch-control': 'off',\n\t'x-permitted-cross-domain-policies': 'none',\n\t'permissions-policy': false,\n} as const satisfies Record<string, Setting>;\n\nconst OPTION: Record<keyof typeof DEFAULTS, keyof SecureHeadersOptions> = {\n\t'content-security-policy': 'contentSecurityPolicy',\n\t'strict-transport-security': 'strictTransportSecurity',\n\t'x-content-type-options': 'xContentTypeOptions',\n\t'x-frame-options': 'xFrameOptions',\n\t'referrer-policy': 'referrerPolicy',\n\t'cross-origin-opener-policy': 'crossOriginOpenerPolicy',\n\t'cross-origin-resource-policy': 'crossOriginResourcePolicy',\n\t'cross-origin-embedder-policy': 'crossOriginEmbedderPolicy',\n\t'origin-agent-cluster': 'originAgentCluster',\n\t'x-dns-prefetch-control': 'xDnsPrefetchControl',\n\t'x-permitted-cross-domain-policies': 'xPermittedCrossDomainPolicies',\n\t'permissions-policy': 'permissionsPolicy',\n};\n\n/**\n * Secure headers on every response, as a middleware: each response of the\n * routes after it and of a request no route matches — a 404, an error —\n * gets them. A header a route set itself is kept: a page that needs its\n * own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.\n *\n * ```ts\n * app.use(secureHeaders({ contentSecurityPolicy: \"default-src 'self'\" }));\n * ```\n *\n * With `nonce: true`, each request gets a nonce of its own, in the policy\n * and on the context of the routes declared after it:\n *\n * ```ts\n * app\n * .use(secureHeaders({ nonce: true, contentSecurityPolicy: \"script-src 'self'\" }))\n * .get('/', ({ nonce, reply }) => reply(200, `<script nonce=\"${nonce}\">…</script>`));\n * ```\n */\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & { readonly nonce: true },\n): NonceMiddleware;\nexport function secureHeaders(\n\toptions?: SecureHeadersOptions & { readonly nonce?: false },\n): SecureHeaders;\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & NonceOption = {},\n): SecureHeaders | NonceMiddleware {\n\tconst headers = fixedHeaders(options);\n\tconst hide = options.hidePoweredBy ?? true;\n\tconst policy = headers.get('content-security-policy');\n\tif (!options.nonce) {\n\t\tif (policy?.includes(NONCE)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true',\n\t\t\t);\n\t\t}\n\t\tconst set = setter(headers, hide);\n\t\treturn defineMiddleware(async (ctx, next) =>\n\t\t\tset(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 (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"
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": "73869EA605B3C65B64756E2164756E21",
9
10
  "names": []
10
11
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes
3
+ * in the policy, and how one is made.
4
+ */
5
+ /**
6
+ * Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes
7
+ * `'nonce-<value>'`, the request's own value. Without it, `nonce: true`
8
+ * adds the nonce to `script-src` and `script-src-elem`.
9
+ *
10
+ * ```ts
11
+ * secureHeaders({
12
+ * nonce: true,
13
+ * contentSecurityPolicy: `default-src 'self'; style-src 'self' ${NONCE}`,
14
+ * });
15
+ * ```
16
+ */
17
+ export declare const NONCE = "'nonce-{alxia}'";
18
+ /** What `secureHeaders({ nonce: true })` adds to the context of the routes after it. */
19
+ export interface NonceContext {
20
+ /**
21
+ * This request's nonce, base64, the one its `Content-Security-Policy`
22
+ * allows: what a page puts on its `<script nonce>`.
23
+ */
24
+ readonly nonce: string;
25
+ }
26
+ /**
27
+ * The policy as a function of the nonce: split where `NONCE` stands, or
28
+ * where it is added to the script directives. Throws when there is
29
+ * nowhere to put it.
30
+ */
31
+ export declare function policyWithNonce(policy: string): (nonce: string) => string;
32
+ /** 128 random bits, base64: a fresh nonce. */
33
+ export declare function freshNonce(): string;
34
+ //# sourceMappingURL=nonce.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAAA;;;GAGG;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"}
@@ -1,4 +1,5 @@
1
- import { type Plugin } from '@alxia/core';
1
+ import { type Empty, type Middleware, type MiddlewareMark, type Next } 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 {
@@ -20,13 +21,41 @@ export interface SecureHeadersOptions {
20
21
  readonly hidePoweredBy?: boolean;
21
22
  }
22
23
  /**
23
- * Secure headers on every response, as a plugin. A header a route set
24
- * itself is kept: a page that needs its own `Content-Security-Policy` —
25
- * `@alxia/openapi`'s reference page — sets it.
24
+ * What `secureHeaders()` returns: a middleware, for `app.use`, that sets
25
+ * the headers on every response after it.
26
+ */
27
+ export type SecureHeaders = Middleware<Empty, Promise<Response>> & MiddlewareMark;
28
+ /**
29
+ * What `secureHeaders({ nonce: true })` returns: a middleware, for
30
+ * `app.use`, whose routes after it read `nonce`, and which sets the header
31
+ * with the same one.
32
+ */
33
+ export type NonceMiddleware = Middleware<Empty, Promise<Next<NonceContext>>> & MiddlewareMark;
34
+ /** @deprecated Renamed `NonceMiddleware`: it is a middleware. */
35
+ export type NoncePlugin = NonceMiddleware;
36
+ /**
37
+ * Secure headers on every response, as a middleware: each response of the
38
+ * routes after it and of a request no route matches — a 404, an error —
39
+ * gets them. A header a route set itself is kept: a page that needs its
40
+ * own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.
26
41
  *
27
42
  * ```ts
28
43
  * app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'" }));
29
44
  * ```
45
+ *
46
+ * With `nonce: true`, each request gets a nonce of its own, in the policy
47
+ * and on the context of the routes declared after it:
48
+ *
49
+ * ```ts
50
+ * app
51
+ * .use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
52
+ * .get('/', ({ nonce, reply }) => reply(200, `<script nonce="${nonce}">…</script>`));
53
+ * ```
30
54
  */
31
- export declare function secureHeaders(options?: SecureHeadersOptions): Plugin;
55
+ export declare function secureHeaders(options: SecureHeadersOptions & {
56
+ readonly nonce: true;
57
+ }): NonceMiddleware;
58
+ export declare function secureHeaders(options?: SecureHeadersOptions & {
59
+ readonly nonce?: false;
60
+ }): SecureHeaders;
32
61
  //# 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,EAEN,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,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,GAC/D,cAAc,CAAC;AAEhB;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,GAC3E,cAAc,CAAC;AAEhB,iEAAiE;AACjE,MAAM,MAAM,WAAW,GAAG,eAAe,CAAC;AAiC1C;;;;;;;;;;;;;;;;;;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, 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 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 |