@alxia/secure-headers 0.2.0 → 0.4.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,14 +18,16 @@ 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.
23
25
 
24
26
  ## A nonce per request
25
27
 
26
28
  `nonce: true` makes a fresh nonce for each request (128 random bits,
27
29
  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
30
+ declared after the middleware as `ctx.nonce`. The header and the context always
29
31
  carry the same one, so a page's inline scripts need no `'unsafe-inline'`.
30
32
 
31
33
  ```ts
@@ -94,15 +96,29 @@ value is refused at startup. `nonce` is off by default.
94
96
 
95
97
  | export | |
96
98
  | --- | --- |
97
- | `secureHeaders(options?)` | the plugin: a function `Plugin`, or with `nonce: true` a `NoncePlugin` |
99
+ | `secureHeaders(options?)` | the middleware, for `app.use`: a `SecureHeaders`, or with `nonce: true` a `NonceMiddleware` |
98
100
  | `SecureHeadersOptions` | its options: the headers and `hidePoweredBy`; `nonce` is added by each overload |
99
101
  | `Setting` | a header option's type: its value, or `false` to leave it out |
100
102
  | `NONCE` | where the nonce goes in `contentSecurityPolicy`, each time it is named |
101
103
  | `NonceContext` | what `nonce: true` adds to the context: `nonce`, a string |
102
- | `NoncePlugin` | what `secureHeaders({ nonce: true })` returns: an app plugin adding `NonceContext` |
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
+
107
+ ## Traps
108
+
109
+ `use` it first: a route declared before `app.use(secureHeaders())` gets no
110
+ header, and `ctx.nonce` exists only on routes declared after it. It
111
+ settles `next()` without swallowing the error: a `try`/`catch` middleware
112
+ catches it wherever it stands, but only one declared after
113
+ `secureHeaders()` has the headers on its reply. Declare error-handling
114
+ middleware after it.
115
+
116
+ ```ts
117
+ const app = alxia().use(secureHeaders()).use(errorHandler).get(...);
118
+ ```
103
119
 
104
120
  ## Documentation
105
121
 
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.
122
+ - [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.
107
123
  - [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.
108
124
  - [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,3 +1,3 @@
1
1
  export { NONCE, type NonceContext } from './nonce';
2
- export { type NoncePlugin, type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
2
+ export { type NonceMiddleware, type SecureHeaders, type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
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"}
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,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -16,20 +16,11 @@ function freshNonce() {
16
16
  const bytes = crypto.getRandomValues(new Uint8Array(16));
17
17
  return btoa(String.fromCharCode(...bytes));
18
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
- }
30
19
  // src/secure-headers.ts
31
20
  import {
32
- definePlugin,
21
+ defineMiddleware,
22
+ markFactory,
23
+ settle,
33
24
  withHeaders
34
25
  } from "@alxia/core";
35
26
  var DEFAULTS = {
@@ -69,17 +60,21 @@ function secureHeaders(options = {}) {
69
60
  throw new TypeError("secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true");
70
61
  }
71
62
  const set = setter(headers, hide);
72
- return (app) => app.onResponse((response) => set(response));
63
+ return defineMiddleware(async function secureHeaders(ctx, next) {
64
+ return set(await settle(ctx, next()));
65
+ });
73
66
  }
74
67
  if (policy === undefined) {
75
68
  throw new TypeError("secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header");
76
69
  }
77
70
  headers.delete("content-security-policy");
78
71
  const withNonce = policyWithNonce(policy);
79
- const nonceOf = nonceStore();
80
72
  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;
73
+ return defineMiddleware(async function secureHeaders(ctx, next) {
74
+ const added = { nonce: freshNonce() };
75
+ const response = await settle(ctx, next(added));
76
+ return set(response, withNonce(added.nonce));
77
+ });
83
78
  }
84
79
  function fixedHeaders(options) {
85
80
  const headers = new Map;
@@ -109,10 +104,11 @@ function setter(headers, hide) {
109
104
  }
110
105
  });
111
106
  }
107
+ markFactory(secureHeaders);
112
108
  export {
113
109
  NONCE,
114
110
  secureHeaders
115
111
  };
116
112
 
117
- //# debugId=3F73DEF9D20E37B064756E2164756E21
113
+ //# debugId=DA6BBAE79DDA945364756E2164756E21
118
114
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -2,10 +2,10 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/nonce.ts", "../src/secure-headers.ts"],
4
4
  "sourcesContent": [
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"
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\tmarkFactory,\n\ttype Next,\n\tsettle,\n\twithHeaders,\n} from '@alxia/core';\nimport { freshNonce, NONCE, type NonceContext, policyWithNonce } from './nonce';\n\n/** A header's value, or `false` to leave it out. An empty value is refused. */\nexport type Setting = string | false;\n\nexport interface SecureHeadersOptions {\n\t/** Defaults to a policy for an API: nothing loads, nothing frames it. */\n\treadonly contentSecurityPolicy?: Setting;\n\treadonly strictTransportSecurity?: Setting;\n\treadonly xContentTypeOptions?: Setting;\n\treadonly xFrameOptions?: Setting;\n\treadonly referrerPolicy?: Setting;\n\treadonly crossOriginOpenerPolicy?: Setting;\n\treadonly crossOriginResourcePolicy?: Setting;\n\treadonly crossOriginEmbedderPolicy?: Setting;\n\treadonly originAgentCluster?: Setting;\n\treadonly xDnsPrefetchControl?: Setting;\n\treadonly xPermittedCrossDomainPolicies?: Setting;\n\t/** Off by default: a policy is the app's to write. */\n\treadonly permissionsPolicy?: Setting;\n\t/** Whether `X-Powered-By` and `Server` are removed. On by default. */\n\treadonly hidePoweredBy?: boolean;\n}\n\n/**\n * The nonce switch, beside `SecureHeadersOptions` rather than in it, so that\n * options typed by that interface still pick the plugin without a nonce.\n */\ninterface NonceOption {\n\t/**\n\t * A fresh nonce for each request, in `contentSecurityPolicy` and on the\n\t * context as `nonce`. Placed where the policy names `NONCE`, or added to\n\t * its `script-src`. Off by default.\n\t */\n\treadonly nonce?: boolean;\n}\n\n/**\n * What `secureHeaders()` returns: a middleware, for `app.use`, that sets\n * the headers on every response after it.\n */\nexport type SecureHeaders = Middleware<Empty, Promise<Response>>;\n\n/**\n * What `secureHeaders({ nonce: true })` returns: a middleware, for\n * `app.use`, whose routes after it read `nonce`, and which sets the header\n * with the same one.\n */\nexport type NonceMiddleware = Middleware<Empty, Promise<Next<NonceContext>>>;\n\nconst DEFAULTS = {\n\t'content-security-policy':\n\t\t\"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n\t'strict-transport-security': 'max-age=31536000; includeSubDomains',\n\t'x-content-type-options': 'nosniff',\n\t'x-frame-options': 'DENY',\n\t'referrer-policy': 'no-referrer',\n\t'cross-origin-opener-policy': 'same-origin',\n\t'cross-origin-resource-policy': 'same-origin',\n\t'cross-origin-embedder-policy': false,\n\t'origin-agent-cluster': '?1',\n\t'x-dns-prefetch-control': 'off',\n\t'x-permitted-cross-domain-policies': 'none',\n\t'permissions-policy': false,\n} as const satisfies Record<string, Setting>;\n\nconst OPTION: Record<keyof typeof DEFAULTS, keyof SecureHeadersOptions> = {\n\t'content-security-policy': 'contentSecurityPolicy',\n\t'strict-transport-security': 'strictTransportSecurity',\n\t'x-content-type-options': 'xContentTypeOptions',\n\t'x-frame-options': 'xFrameOptions',\n\t'referrer-policy': 'referrerPolicy',\n\t'cross-origin-opener-policy': 'crossOriginOpenerPolicy',\n\t'cross-origin-resource-policy': 'crossOriginResourcePolicy',\n\t'cross-origin-embedder-policy': 'crossOriginEmbedderPolicy',\n\t'origin-agent-cluster': 'originAgentCluster',\n\t'x-dns-prefetch-control': 'xDnsPrefetchControl',\n\t'x-permitted-cross-domain-policies': 'xPermittedCrossDomainPolicies',\n\t'permissions-policy': 'permissionsPolicy',\n};\n\n/**\n * Secure headers on every response, as a middleware: each response of the\n * routes after it and of a request no route matches — a 404, an error —\n * gets them. A header a route set itself is kept: a page that needs its\n * own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.\n *\n * ```ts\n * app.use(secureHeaders({ contentSecurityPolicy: \"default-src 'self'\" }));\n * ```\n *\n * With `nonce: true`, each request gets a nonce of its own, in the policy\n * and on the context of the routes declared after it:\n *\n * ```ts\n * app\n * .use(secureHeaders({ nonce: true, contentSecurityPolicy: \"script-src 'self'\" }))\n * .get('/', ({ nonce, reply }) => reply(200, `<script nonce=\"${nonce}\">…</script>`));\n * ```\n */\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & { readonly nonce: true },\n): NonceMiddleware;\nexport function secureHeaders(\n\toptions?: SecureHeadersOptions & { readonly nonce?: false },\n): SecureHeaders;\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & NonceOption = {},\n): SecureHeaders | NonceMiddleware {\n\tconst headers = fixedHeaders(options);\n\tconst hide = options.hidePoweredBy ?? true;\n\tconst policy = headers.get('content-security-policy');\n\tif (!options.nonce) {\n\t\tif (policy?.includes(NONCE)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true',\n\t\t\t);\n\t\t}\n\t\tconst set = setter(headers, hide);\n\t\treturn defineMiddleware(async function secureHeaders(ctx, next) {\n\t\t\treturn set(await settle(ctx, next()));\n\t\t});\n\t}\n\tif (policy === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header',\n\t\t);\n\t}\n\theaders.delete('content-security-policy');\n\tconst withNonce = policyWithNonce(policy);\n\tconst set = setter(headers, hide);\n\treturn defineMiddleware(async function secureHeaders(ctx, next) {\n\t\tconst added: NonceContext = { nonce: freshNonce() };\n\t\tconst response = await settle(ctx, next(added));\n\t\treturn set(response, withNonce(added.nonce)) as typeof response;\n\t});\n}\n\n/** Each header the options leave in, by name, with its value. Throws on an empty one. */\nfunction fixedHeaders(options: SecureHeadersOptions): Map<string, string> {\n\tconst headers = new Map<string, string>();\n\tfor (const [name, fallback] of Object.entries(DEFAULTS) as [\n\t\tkeyof typeof DEFAULTS,\n\t\tSetting,\n\t][]) {\n\t\tconst setting = options[OPTION[name]];\n\t\tif (typeof setting === 'string' && setting.trim() === '') {\n\t\t\t// An empty header says nothing a browser can act on: leave it out.\n\t\t\tthrow new TypeError(\n\t\t\t\t`secureHeaders: ${OPTION[name]} is empty; give false to leave the ${name} header out`,\n\t\t\t);\n\t\t}\n\t\tconst value = setting === undefined ? fallback : setting;\n\t\tif (typeof value === 'string') headers.set(name, value);\n\t}\n\treturn headers;\n}\n\n/** Every header the response lacks, then the policy given per request. */\nfunction setter(headers: ReadonlyMap<string, string>, hide: boolean) {\n\treturn (response: Response, policy?: string) =>\n\t\twithHeaders(response, (current) => {\n\t\t\tfor (const [name, value] of headers) {\n\t\t\t\tif (!current.has(name)) current.set(name, value);\n\t\t\t}\n\t\t\tif (policy !== undefined && !current.has('content-security-policy')) {\n\t\t\t\tcurrent.set('content-security-policy', policy);\n\t\t\t}\n\t\t\tif (hide) {\n\t\t\t\tcurrent.delete('x-powered-by');\n\t\t\t\tcurrent.delete('server');\n\t\t\t}\n\t\t});\n}\n\nmarkFactory(secureHeaders);\n"
7
7
  ],
8
- "mappings": "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",
9
- "debugId": "3F73DEF9D20E37B064756E2164756E21",
8
+ "mappings": "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",
9
+ "debugId": "DA6BBAE79DDA945364756E2164756E21",
10
10
  "names": []
11
11
  }
package/dist/nonce.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
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.
3
+ * in the policy, and how one is made.
5
4
  */
6
5
  /**
7
6
  * Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes
@@ -32,10 +31,4 @@ export interface NonceContext {
32
31
  export declare function policyWithNonce(policy: string): (nonce: string) => string;
33
32
  /** 128 random bits, base64: a fresh nonce. */
34
33
  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
34
  //# sourceMappingURL=nonce.d.ts.map
@@ -1 +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
+ {"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,4 @@
1
- import { type Alxia, type Empty, type Plugin, type Requiring } from '@alxia/core';
1
+ import { type Empty, type Middleware, type Next } from '@alxia/core';
2
2
  import { type NonceContext } from './nonce';
3
3
  /** A header's value, or `false` to leave it out. An empty value is refused. */
4
4
  export type Setting = string | false;
@@ -21,14 +21,21 @@ export interface SecureHeadersOptions {
21
21
  readonly hidePoweredBy?: boolean;
22
22
  }
23
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.
24
+ * What `secureHeaders()` returns: a middleware, for `app.use`, that sets
25
+ * the headers on every response after it.
26
26
  */
27
- export type NoncePlugin = Alxia<NonceContext, Empty, '', never> & Requiring<Empty>;
27
+ export type SecureHeaders = Middleware<Empty, Promise<Response>>;
28
28
  /**
29
- * Secure headers on every response, as a plugin. A header a route set
30
- * itself is kept: a page that needs its own `Content-Security-Policy` —
31
- * `@alxia/openapi`'s reference page — sets it.
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>>>;
34
+ /**
35
+ * Secure headers on every response, as a middleware: each response of the
36
+ * routes after it and of a request no route matches — a 404, an error —
37
+ * gets them. A header a route set itself is kept: a page that needs its
38
+ * own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.
32
39
  *
33
40
  * ```ts
34
41
  * app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'" }));
@@ -45,8 +52,8 @@ export type NoncePlugin = Alxia<NonceContext, Empty, '', never> & Requiring<Empt
45
52
  */
46
53
  export declare function secureHeaders(options: SecureHeadersOptions & {
47
54
  readonly nonce: true;
48
- }): NoncePlugin;
55
+ }): NonceMiddleware;
49
56
  export declare function secureHeaders(options?: SecureHeadersOptions & {
50
57
  readonly nonce?: false;
51
- }): Plugin;
58
+ }): SecureHeaders;
52
59
  //# 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,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"}
1
+ {"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,KAAK,EACV,KAAK,UAAU,EAEf,KAAK,IAAI,EAGT,MAAM,aAAa,CAAC;AACrB,OAAO,EAAqB,KAAK,YAAY,EAAmB,MAAM,SAAS,CAAC;AAEhF,+EAA+E;AAC/E,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;AAErC,MAAM,WAAW,oBAAoB;IACpC,yEAAyE;IACzE,QAAQ,CAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IACzC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAClC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACjD,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC,sEAAsE;IACtE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CACjC;AAeD;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;AAEjE;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;AAiC7E;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GACtD,eAAe,CAAC;AACnB,wBAAgB,aAAa,CAC5B,OAAO,CAAC,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACzD,aAAa,CAAC"}
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 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 |
package/docs/guide.md CHANGED
@@ -2,7 +2,7 @@
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 plugin sets its own policy.
5
+ another middleware sets its own policy.
6
6
 
7
7
  ```ts
8
8
  import { alxia } from '@alxia/core';
@@ -21,8 +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 & { readonly nonce: true }): NoncePlugin;
25
- function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }): Plugin;
24
+ function secureHeaders(options: SecureHeadersOptions & { readonly nonce: true }): NonceMiddleware;
25
+ function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }): SecureHeaders;
26
26
 
27
27
  interface SecureHeadersOptions {
28
28
  readonly contentSecurityPolicy?: string | false;
@@ -42,22 +42,25 @@ interface SecureHeadersOptions {
42
42
  ```
43
43
 
44
44
  `nonce` is not in `SecureHeadersOptions`: each overload adds it, so options
45
- typed by that interface still give the plain `Plugin`.
46
-
47
- `secureHeaders` returns a function `Plugin` from `@alxia/core`: give it to
48
- `use`, called, and the app keeps its type. It adds one global `onResponse`
49
- hook, so where it sits in the chain does not matter for which routes it
50
- reaches: every route, declared before or after it, is covered. Used inside
51
- a `group`, it still covers the whole app — a group's global hooks are the
52
- app's. To vary a header for some routes, set it on their replies
45
+ typed by that interface still give the plain `SecureHeaders`.
46
+
47
+ `secureHeaders` returns a middleware: give it to `app.use`, called, and the
48
+ app keeps its type. `SecureHeaders` is a `Middleware` from `@alxia/core`
49
+ that adds nothing to the context. It waits for the rest of the chain with
50
+ `settle`, then sets the headers on the response, so it covers every route
51
+ declared after it, and every request no route matches — a 404, a 405 —
52
+ because those run all of the app's top-level middlewares too. A route
53
+ declared **before** it is not covered. Used inside a `group`, it covers only
54
+ that group's routes, and not a 404 under the group's prefix: declare it on
55
+ the app. To vary a header for some routes, set it on their replies
53
56
  ([below](#a-header-a-route-sets-is-kept)).
54
57
 
55
58
  The options are read once, when `secureHeaders(…)` is called; the header
56
59
  values are fixed from then on, but for the nonce, which is new on every
57
60
  request.
58
61
 
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
62
+ With `nonce: true` it returns a `NonceMiddleware` instead: a middleware, still
63
+ given to `app.use` called, that covers the same responses as above, and which
61
64
  adds `nonce` to the context of the routes declared after it
62
65
  ([below](#a-nonce-per-request)).
63
66
 
@@ -103,7 +106,7 @@ app.use(
103
106
 
104
107
  ### `false`
105
108
 
106
- `false` leaves the header out of every response the plugin touches.
109
+ `false` leaves the header out of every response the middleware touches.
107
110
 
108
111
  ```ts
109
112
  app.use(secureHeaders({ xFrameOptions: false, strictTransportSecurity: false }));
@@ -143,11 +146,11 @@ const app = alxia()
143
146
  (await app.request('/')).headers.get('x-powered-by'); // null
144
147
  ```
145
148
 
146
- `hidePoweredBy: false` keeps whatever the route or another hook sent.
149
+ `hidePoweredBy: false` keeps whatever the route or another middleware sent.
147
150
 
148
151
  ## A header a route sets is kept
149
152
 
150
- The plugin only fills in a header the response does not have yet. A route
153
+ The middleware only fills in a header the response does not have yet. A route
151
154
  that needs its own policy sets it on its reply, and the other defaults
152
155
  still apply:
153
156
 
@@ -171,11 +174,10 @@ response.headers.get('x-frame-options'); // null — f
171
174
  ```
172
175
 
173
176
  `set.headers` in a handler or a `derive` works the same way, since it ends
174
- up on the response before the plugin reads it.
177
+ up on the response before the middleware reads it.
175
178
 
176
- That is how `@alxia/openapi`'s reference page and `@alxia/graphql`'s IDE
177
- load under the strict default: each sets the `Content-Security-Policy` it
178
- needs, and `secureHeaders` keeps it.
179
+ That is how `@alxia/graphql`'s IDE loads under the strict default: it sets
180
+ the `Content-Security-Policy` it needs, and `secureHeaders` keeps it.
179
181
 
180
182
  ## A nonce per request
181
183
 
@@ -213,8 +215,8 @@ await response.text();
213
215
  ### What it is
214
216
 
215
217
  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
+ end. Each request makes its own, when the middleware runs,
219
+ and every reader of that request — the route, its middlewares, the header — gets
218
220
  the same one. Two requests never share one.
219
221
 
220
222
  ### Where it goes in the policy
@@ -251,14 +253,15 @@ which take no nonce, stop applying
251
253
 
252
254
  ### Who reads it
253
255
 
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:
256
+ The header covers every response that comes back through the middleware, as
257
+ without the nonce: a 404 and a 500 get a policy with a nonce of their own.
258
+ `ctx.nonce` is typed and set on the routes declared **after** `app.use`, in
259
+ their middlewares and their handler, as any middleware's context. A route
260
+ declared before it gets neither a policy nor a `ctx.nonce`:
258
261
 
259
262
  ```ts
260
263
  alxia()
261
- .get('/early', ({ reply }) => reply(200, 'policy with a nonce, no ctx.nonce'))
264
+ .get('/early', ({ reply }) => reply(200, 'no policy from this middleware, no ctx.nonce'))
262
265
  .use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
263
266
  .derive(({ nonce }) => ({ scriptTag: (code: string) => `<script nonce="${nonce}">${code}</script>` }))
264
267
  .get('/late', ({ scriptTag, reply }) => reply(200, scriptTag('…')));
@@ -283,17 +286,17 @@ startup, so the placeholder never reaches a browser.
283
286
 
284
287
  ## Which responses get the headers
285
288
 
286
- The plugin runs as an `onResponse` hook, so it reaches what the app's
287
- `onResponse` hooks reach:
289
+ The middleware settles `next()`: it sets the headers on the response the
290
+ client would get, whatever produced it.
288
291
 
289
292
  | Response | Headers |
290
293
  | --- | --- |
291
294
  | a route's reply, `static` and `file` included | yes |
292
- | the core's 400, 404, 405 and 500 | yes |
293
- | a `Response` an `onRequest` hook returned (a CORS preflight, a redirect) | yes |
294
- | a WebSocket upgrade that succeeds (`101`) | no: `onResponse` hooks do not run for it |
295
- | a page served with `page()` | no: `Bun.serve` serves it, around no hook |
296
- | the 500 sent when an `around` hook itself throws | no |
295
+ | the core's 400, 404, 405 and 500, and a `try`/`catch` middleware's reply declared after it | yes |
296
+ | a `Response` another middleware returned (a CORS preflight, a redirect) | yes, when that middleware is declared after `secureHeaders` |
297
+ | a route declared before `app.use(secureHeaders())` | no: the middleware does not run for it |
298
+ | a WebSocket upgrade that succeeds (`101`) | no: the upgrade is not a response a middleware can decorate |
299
+ | a page served with `page()` | no: `Bun.serve` serves it, around no middleware |
297
300
 
298
301
  ```ts
299
302
  const app = alxia().use(secureHeaders());
@@ -303,58 +306,71 @@ response.status; // 404
303
306
  response.headers.get('x-content-type-options'); // 'nosniff'
304
307
  ```
305
308
 
306
- ## Order with other `onResponse` hooks
309
+ ## Order with other middlewares
307
310
 
308
- `onResponse` hooks run in the order they are declared, and the plugin never
309
- overwrites a header that is already there. So:
311
+ Middlewares nest: the first `use` is the outermost, and on the way out each
312
+ one sees the response of the ones after it. The middleware never overwrites
313
+ a header that is already there. So:
310
314
 
311
- - a hook declared **before** `secureHeaders` that sets one of its headers
312
- wins;
313
- - a hook declared **after** it that sets one of its headers wins too, by
314
- overwriting;
315
- - a hook declared **after** it that adds `X-Powered-By` or `Server` keeps
316
- it: the plugin deleted them before that hook ran.
315
+ - a middleware declared **after** `secureHeaders` that sets one of its
316
+ headers wins: `secureHeaders` finds it already there;
317
+ - a middleware declared **before** it that sets one of its headers wins too,
318
+ by overwriting;
319
+ - a middleware declared **before** it that adds `X-Powered-By` or `Server`
320
+ keeps it: `secureHeaders` deleted them before that one ran.
317
321
 
318
322
  ```ts
319
- import { alxia, withHeaders } from '@alxia/core';
323
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
320
324
  import { secureHeaders } from '@alxia/secure-headers';
321
325
 
326
+ const referrerOrigin = defineMiddleware(async (ctx, next) =>
327
+ withHeaders(await settle(ctx, next()), (headers) => headers.set('referrer-policy', 'origin')),
328
+ );
329
+
322
330
  const app = alxia()
323
- .onResponse((response) =>
324
- withHeaders(response, (headers) => headers.set('referrer-policy', 'origin')),
325
- )
326
331
  .use(secureHeaders())
332
+ .use(referrerOrigin)
327
333
  .get('/', ({ reply }) => reply(200, 'ok'));
328
334
 
329
335
  (await app.request('/')).headers.get('referrer-policy'); // 'origin'
330
336
  ```
331
337
 
332
- ## A header the plugin does not know
338
+ Declare `secureHeaders()` among the observers, first: `logger()`,
339
+ `telemetry()`, `secureHeaders()`, `cors()`, `compress()`. An
340
+ error-handling middleware — a `try`/`catch` around `next()`, or
341
+ `janusErrors()` — goes **after** it. `secureHeaders()` settles `next()`: it
342
+ sets the headers on the response the error would be answered with (its
343
+ `HttpError`'s status, or a 500), then the error
344
+ goes on, so a `try`/`catch` catches it wherever it is declared; declared
345
+ before `secureHeaders()`, its reply does not carry the headers. A guard on the app (`bearer`, a required
346
+ session) declared before it answers its `401` without them.
333
347
 
334
- The plugin sends the twelve headers above and nothing else. Another one —
348
+ ## A header the middleware does not know
349
+
350
+ The middleware sends the twelve headers above and nothing else. Another one —
335
351
  `Content-Security-Policy-Report-Only` while a policy is being tried, say —
336
- is a hook of your own, with `withHeaders` from `@alxia/core`:
352
+ is a middleware of your own, with `settle` and `withHeaders` from
353
+ `@alxia/core`:
337
354
 
338
355
  ```ts
339
- import { alxia, withHeaders } from '@alxia/core';
356
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
340
357
  import { secureHeaders } from '@alxia/secure-headers';
341
358
 
359
+ const reportOnly = defineMiddleware(async (ctx, next) =>
360
+ withHeaders(await settle(ctx, next()), (headers) => {
361
+ if (!headers.has('content-security-policy-report-only')) {
362
+ headers.set('content-security-policy-report-only', "default-src 'self'; report-to csp");
363
+ }
364
+ }),
365
+ );
366
+
342
367
  const app = alxia()
343
368
  .use(secureHeaders())
344
- .onResponse((response) =>
345
- withHeaders(response, (headers) => {
346
- if (!headers.has('content-security-policy-report-only')) {
347
- headers.set(
348
- 'content-security-policy-report-only',
349
- "default-src 'self'; report-to csp",
350
- );
351
- }
352
- }),
353
- );
369
+ .use(reportOnly);
354
370
  ```
355
371
 
356
- See [Hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md#onresponse)
357
- in the core's guide for what an `onResponse` hook may do.
372
+ See [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
373
+ in the core's guide for what a middleware may do.
358
374
 
359
375
  ## Recipes
360
376
 
@@ -481,6 +497,6 @@ test('a 404 is covered too', async () => {
481
497
 
482
498
  - [Troubleshooting](troubleshooting.md): a browser refuses something, or
483
499
  TypeScript does.
484
- - [Hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md)
500
+ - [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
485
501
  and [Groups and plugins](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md)
486
502
  in the core's guide.
package/docs/roadmap.md CHANGED
@@ -7,7 +7,11 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
- Nothing scheduled yet.
10
+ - **Secure headers as a middleware.** `app.use(secureHeaders())`, declared
11
+ first, sets the headers on every response that comes back through it, a
12
+ 404's, an error's and a 500's included; `NonceMiddleware` gives `nonce` to the
13
+ routes after it and `SecureHeaders` is the plain one.
14
+ `app.plugin(secureHeaders())`, the deprecated alias, was removed in 0.5.
11
15
 
12
16
  ## Next
13
17
 
@@ -37,7 +41,7 @@ Nothing scheduled yet.
37
41
  ### 0.1.0
38
42
 
39
43
  - **Secure headers on every response.** `secureHeaders()` is a plugin for
40
- `use` that sends `Content-Security-Policy`, `Strict-Transport-Security`,
44
+ `app.plugin` that sends `Content-Security-Policy`, `Strict-Transport-Security`,
41
45
  `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
42
46
  `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
43
47
  `Origin-Agent-Cluster`, `X-DNS-Prefetch-Control` and
@@ -11,14 +11,14 @@ or a header in the wrong place.
11
11
  - [`Type 'true' is not assignable to type 'Setting | undefined'`](#type-true-is-not-assignable-to-type-setting--undefined)
12
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
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)
14
+ - [`Type 'undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'`](#type-undefined-is-not-assignable-to-type-secureheadersoptions---readonly-nonce-true-)
15
+ - [`Property 'nonce' does not exist on type 'RouteBase<…>'`](#property-nonce-does-not-exist-on-type-routebase)
16
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)
17
17
 
18
18
  **Runtime**
19
19
 
20
20
  - [`TypeError: secureHeaders: … is empty; give false to leave the … header out`](#typeerror-secureheaders--is-empty-give-false-to-leave-the--header-out)
21
- - [`TypeError: alxia().use(secureHeaders).get is not a function`](#typeerror-alxiausesecureheadersget-is-not-a-function)
21
+ - [`use(): argument 1 looks like a factory (secureHeaders): call it, use(secureHeaders())`](#use-argument-1-looks-like-a-factory-secureheaders-call-it-usesecureheaders)
22
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
23
  - [`TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`](#typeerror-secureheaders-nonce-is-on-but-contentsecuritypolicy-is-false-)
24
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)
@@ -39,7 +39,9 @@ or a header in the wrong place.
39
39
  - [An option has no effect on a response](#an-option-has-no-effect-on-a-response)
40
40
  - [`X-Powered-By` or `Server` is still sent](#x-powered-by-or-server-is-still-sent)
41
41
  - [A response arrives without the headers](#a-response-arrives-without-the-headers)
42
- - [Routes outside a group get the headers](#routes-outside-a-group-get-the-headers)
42
+ - [A 404 or a route outside a group has no headers](#a-404-or-a-route-outside-a-group-has-no-headers)
43
+ - [A `try`/`catch` middleware's reply has no headers](#a-trycatch-middlewares-reply-has-no-headers)
44
+ - [A route has no headers, though the others do](#a-route-has-no-headers-though-the-others-do)
43
45
 
44
46
  ## Types
45
47
 
@@ -49,9 +51,9 @@ or a header in the wrong place.
49
51
 
50
52
  ```text
51
53
  error TS2769: No overload matches this call.
52
- Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
54
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
53
55
  Type 'true' is not assignable to type 'Setting'.
54
- Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
56
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
55
57
  Type 'true' is not assignable to type 'Setting | undefined'.
56
58
  ```
57
59
 
@@ -84,10 +86,10 @@ app.use(
84
86
 
85
87
  ```text
86
88
  error TS2769: No overload matches this call.
87
- Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
89
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
88
90
  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
91
  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.
92
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
91
93
  Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce?: false; }' with 'exactOptionalPropertyTypes: true'. …
92
94
  ```
93
95
 
@@ -109,9 +111,9 @@ another library's name for one.
109
111
 
110
112
  ```text
111
113
  error TS2769: No overload matches this call.
112
- Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
114
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
113
115
  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.
116
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
115
117
  Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce?: false; }'.
116
118
  ```
117
119
 
@@ -119,40 +121,43 @@ error TS2769: No overload matches this call.
119
121
  [guide](guide.md#the-options), plus `hidePoweredBy` and `nonce`. `X-Powered-By` and
120
122
  `Server` are governed by `hidePoweredBy`; anything else —
121
123
  `Content-Security-Policy-Report-Only`, `X-XSS-Protection` — is not sent by
122
- the plugin.
124
+ the middleware.
123
125
 
124
- **Fix:** `hidePoweredBy` for those two; a hook of your own for another
126
+ **Fix:** `hidePoweredBy` for those two; a middleware of your own for another
125
127
  header:
126
128
 
127
129
  ```ts
128
- import { alxia, withHeaders } from '@alxia/core';
130
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
129
131
  import { secureHeaders } from '@alxia/secure-headers';
130
132
 
133
+ const reportOnly = defineMiddleware(async (ctx, next) =>
134
+ withHeaders(await settle(ctx, next()), (headers) =>
135
+ headers.set('content-security-policy-report-only', "default-src 'self'"),
136
+ ),
137
+ );
138
+
131
139
  const app = alxia()
132
140
  .use(secureHeaders({ hidePoweredBy: true }))
133
- .onResponse((response) =>
134
- withHeaders(response, (headers) =>
135
- headers.set('content-security-policy-report-only', "default-src 'self'"),
136
- ),
137
- );
141
+ .use(reportOnly);
138
142
  ```
139
143
 
140
- ### `No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, Empty, "", never>'`
144
+ ### `Type 'undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'`
141
145
 
142
- **When:** `secureHeaders` is given to `use` without being called.
146
+ **When:** `secureHeaders` is given to `app.use` without being called.
143
147
 
144
148
  ```text
145
- error TS2769: No overload matches this call.
146
- Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
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'.
148
- Types of parameters 'options' and 'app' are incompatible.
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; }'.
149
+ error TS2345: Argument of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware; (options?: (SecureHeadersOptions & { ...; }) | undefined): SecureHeaders; }' is not assignable to parameter of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware; (options?: (SecureHeadersOptions & { ...; }) | undefined): SecureHeaders; } & Step<...>'.
150
+ Type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware; (options?: (SecureHeadersOptions & { ...; }) | undefined): SecureHeaders; }' is not assignable to type 'Step<BaseContext & Empty, (SecureHeadersOptions & { readonly nonce?: false; }) | undefined, MiddlewareReturn>'.
151
+ Types of parameters 'options' and 'ctx' are incompatible.
152
+ Type '(SecureHeadersOptions & { readonly nonce?: false; }) | undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'.
153
+ Type 'undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'.
154
+ Type 'undefined' is not assignable to type 'SecureHeadersOptions'.
151
155
  ```
152
156
 
153
- **Why:** `secureHeaders` makes the plugin from its options; the plugin is
154
- what it returns. TypeScript tries the app as the options of its first
155
- overload, the nonce one, and says what that lacks.
157
+ **Why:** `secureHeaders` makes the middleware from its options; the
158
+ middleware is what it returns. Given to `use` as it is, it is read as a
159
+ middleware whose context is its options, and TypeScript says those do not
160
+ fit its first overload, the nonce one.
156
161
 
157
162
  **Fix:** call it, with or without options:
158
163
 
@@ -160,16 +165,19 @@ overload, the nonce one, and says what that lacks.
160
165
  app.use(secureHeaders());
161
166
  ```
162
167
 
163
- ### `Property 'nonce' does not exist on type 'Context<…>'`
168
+ ### `Property 'nonce' does not exist on type 'RouteBase<…>'`
164
169
 
165
- **When:** a route or a hook reads `nonce` from its context.
170
+ **When:** a route or a middleware reads `nonce` from its context.
166
171
 
167
172
  ```text
168
- error TS2339: Property 'nonce' does not exist on type 'Context<Empty, "/", Empty>'.
173
+ error TS2339: Property 'nonce' does not exist on type 'RouteBase<RouteApp<"GET", Empty, "">, "/">'.
174
+ error TS2339: Property 'nonce' does not exist on type 'BaseContext & Empty'.
169
175
  ```
170
176
 
177
+ The second is a `derive` or a middleware.
178
+
171
179
  **Why:** only `secureHeaders({ nonce: true })` adds it, and only to the
172
- routes declared after its `use`. Without `nonce: true`, or on a route
180
+ routes declared after its `app.use`. Without `nonce: true`, or on a route
173
181
  declared before it, there is none.
174
182
 
175
183
  **Fix:** turn it on, and declare the routes that read it after it:
@@ -187,13 +195,13 @@ the environment, say.
187
195
 
188
196
  ```text
189
197
  error TS2769: No overload matches this call.
190
- Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
198
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
191
199
  Type 'boolean' is not assignable to type 'true'.
192
- Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
200
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
193
201
  Type 'boolean' is not assignable to type 'false'.
194
202
  ```
195
203
 
196
- **Why:** `nonce: true` changes the plugin's type, since it adds `nonce`
204
+ **Why:** `nonce: true` changes the middleware's type, since it adds `nonce`
197
205
  to the context. A `boolean` leaves TypeScript unable to say whether the
198
206
  routes read one.
199
207
 
@@ -229,14 +237,18 @@ Only `false` leaves a header out.
229
237
  app.use(secureHeaders({ xFrameOptions: false }));
230
238
  ```
231
239
 
232
- ### `TypeError: alxia().use(secureHeaders).get is not a function`
240
+ ### `use(): argument 1 looks like a factory (secureHeaders): call it, use(secureHeaders())`
233
241
 
234
- **When:** the same mistake as above in JavaScript, or past a cast: the
235
- chain breaks on the next method after `use(secureHeaders)`.
242
+ **When:** `alxia().use(secureHeaders)`, the factory given uncalled, in
243
+ JavaScript or past a cast. It throws where the app is declared, since
244
+ alxia 0.5; before, each request was answered with a 500 and the log said
245
+ `GET /: a middleware (secureHeaders) returned function: …`. TypeScript
246
+ refuses it first, with `"this looks like a factory given uncalled: …"` in
247
+ the error.
236
248
 
237
- **Why:** `use` calls the function with the app. Uncalled, `secureHeaders`
238
- reads the app as its options and returns a plugin, not the app, so the
239
- next `.get` is called on a function.
249
+ **Why:** `use` takes a middleware, a `(ctx, next)` function; `secureHeaders`
250
+ makes one, and is marked as a factory, so `use`, a route and `plugin`
251
+ refuse it.
240
252
 
241
253
  **Fix:**
242
254
 
@@ -257,7 +269,7 @@ TypeError: secureHeaders: nonce is on, but the content-security-policy has no sc
257
269
  ```
258
270
 
259
271
  **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
272
+ back on `default-src`, and adding a `script-src` of the middleware's own would
261
273
  change what else they may load.
262
274
 
263
275
  **Fix:** write the `script-src` you want, or say where the nonce goes:
@@ -281,7 +293,7 @@ TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: a non
281
293
  no policy there is nothing for it to allow.
282
294
 
283
295
  **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
296
+ with `ctx.nonce` still needs the middleware's: give the app one, and let the
285
297
  route's replace it ([guide](guide.md#who-reads-it)).
286
298
 
287
299
  ### `TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`
@@ -476,11 +488,11 @@ until it reads `max-age=0` from the host over HTTPS.
476
488
 
477
489
  **When:** an option is set, and some responses still carry another value.
478
490
 
479
- **Why:** the plugin never overwrites a header the response already has.
480
- A route set it on its reply or on `set.headers`, an `onResponse` hook
481
- declared before `secureHeaders` set it, or one declared after overwrote it.
482
- `@alxia/openapi`'s reference page and `@alxia/graphql`'s IDE set their own
483
- `Content-Security-Policy` on purpose.
491
+ **Why:** the middleware never overwrites a header the response already has.
492
+ A route set it on its reply or on `set.headers`, a middleware declared
493
+ after `secureHeaders` set it on the way out, or one declared before it
494
+ overwrote it.
495
+ `@alxia/graphql`'s IDE sets its own `Content-Security-Policy` on purpose.
484
496
 
485
497
  **Fix:** find what sets it on that route, and change it there:
486
498
 
@@ -494,22 +506,25 @@ console.log(response.headers.get('content-security-policy'));
494
506
  **When:** `hidePoweredBy` is on (the default), and one of them is on the
495
507
  response.
496
508
 
497
- **Why:** the plugin deletes them when its hook runs. An `onResponse` hook
498
- declared after `secureHeaders` that adds one runs later, and keeps it. A
499
- proxy in front of the app may add its own `Server` too.
509
+ **Why:** the middleware deletes them when the response comes back through
510
+ it. A middleware declared **before** `secureHeaders` that adds one sees the
511
+ response after that, and keeps it. A proxy in front of the app may add its
512
+ own `Server` too.
500
513
 
501
- **Fix:** declare `secureHeaders` after the hook that adds the header, or
502
- stop that hook adding it:
514
+ **Fix:** declare `secureHeaders` before the middleware that adds the header,
515
+ or stop that middleware adding it:
503
516
 
504
517
  ```ts
505
- import { alxia, withHeaders } from '@alxia/core';
518
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
506
519
  import { secureHeaders } from '@alxia/secure-headers';
507
520
 
521
+ const poweredBy = defineMiddleware(async (ctx, next) =>
522
+ withHeaders(await settle(ctx, next()), (headers) => headers.set('x-powered-by', 'my-app')),
523
+ );
524
+
508
525
  const app = alxia()
509
- .onResponse((response) =>
510
- withHeaders(response, (headers) => headers.set('x-powered-by', 'my-app')),
511
- )
512
- .use(secureHeaders()); // its hook runs after, and deletes it
526
+ .use(secureHeaders()) // outermost: deletes the header on the way out
527
+ .use(poweredBy);
513
528
  ```
514
529
 
515
530
  ### A response arrives without the headers
@@ -517,20 +532,19 @@ const app = alxia()
517
532
  **When:** a response has none of the headers, though `secureHeaders` is
518
533
  in use.
519
534
 
520
- **Why:** the plugin is an `onResponse` hook, and three responses run no
521
- `onResponse` hook: a WebSocket upgrade that succeeds (the `101`), a page
522
- served with `page()`, which `Bun.serve` answers itself, and the 500 sent
523
- when an `around` hook throws.
535
+ **Why:** the middleware decorates what comes back through it, and two
536
+ responses do not: a WebSocket upgrade that succeeds (the `101`), and a page
537
+ served with `page()`, which `Bun.serve` answers itself.
524
538
 
525
539
  **Fix:** serve a page that needs the headers through a route, `file` or
526
540
  `static` rather than `page()` — or give it its policy in its HTML with
527
541
  `<meta http-equiv="Content-Security-Policy" content="…">`, where browsers
528
- ignore `frame-ancestors`. An `around` hook whose own work may fail catches
529
- it and still returns what `next()` resolved to, which already carries the
530
- headers:
542
+ ignore `frame-ancestors`. A middleware declared before `secureHeaders`,
543
+ whose own work may fail, catches it and still returns what `next()`
544
+ resolved to, which already carries the headers:
531
545
 
532
546
  ```ts
533
- app.around(async (_ctx, next) => {
547
+ const timing = defineMiddleware(async (ctx, next) => {
534
548
  const response = await next();
535
549
  try {
536
550
  recordTiming(response); // your own work
@@ -539,19 +553,26 @@ app.around(async (_ctx, next) => {
539
553
  }
540
554
  return response;
541
555
  });
556
+
557
+ const app = alxia().use(timing).use(secureHeaders());
542
558
  ```
543
559
 
544
- ### Routes outside a group get the headers
560
+ ### A 404 or a route outside a group has no headers
545
561
 
546
- **When:** `secureHeaders` is used inside a `group`, meant for its routes
547
- only, and every route of the app gets the headers.
562
+ **When:** `secureHeaders` is used inside a `group`, and a route outside the
563
+ group, or a request that matches no route, comes back without them.
548
564
 
549
- **Why:** it adds a global hook, and a group's global hooks are the app's.
565
+ **Why:** a middleware declared in a `group` stays inside the group: it runs
566
+ on its routes and on a request no route matches under its prefix, and on
567
+ nothing else: not on a route outside the group, nor declared after it.
550
568
 
551
569
  **Fix:** use it on the app, and give the routes that differ their own
552
570
  values on their replies:
553
571
 
554
572
  ```ts
573
+ import { alxia } from '@alxia/core';
574
+ import { secureHeaders } from '@alxia/secure-headers';
575
+
555
576
  const app = alxia()
556
577
  .use(secureHeaders())
557
578
  .get('/embed', ({ reply }) =>
@@ -563,3 +584,54 @@ const app = alxia()
563
584
  }),
564
585
  );
565
586
  ```
587
+
588
+ ### A `try`/`catch` middleware's reply has no headers
589
+
590
+ **When:** a middleware wraps `next()` in a `try`/`catch` to answer errors in
591
+ its own format, is declared before `secureHeaders()`, and its reply comes
592
+ back without the security headers.
593
+
594
+ **Why:** `secureHeaders` settles `next()`: it sets the headers on the response
595
+ the route boundary would give (the `HttpError`'s status, or a 500), then the error goes on to the middlewares around it. A `try`/`catch`
596
+ declared **before** it catches the error, but its reply is made outside
597
+ `secureHeaders`, so it carries no headers; the 500 inside did.
598
+
599
+ **Fix:** declare the error-handling middleware after `secureHeaders`, so
600
+ the headers are set on its reply:
601
+
602
+ ```ts
603
+ import { alxia, defineMiddleware } from '@alxia/core';
604
+ import { secureHeaders } from '@alxia/secure-headers';
605
+
606
+ const errors = defineMiddleware(async (_ctx, next) => {
607
+ try {
608
+ return await next();
609
+ } catch (error) {
610
+ return Response.json({ error: String(error) }, { status: 500 });
611
+ }
612
+ });
613
+
614
+ const app = alxia()
615
+ .use(secureHeaders()) // first
616
+ .use(errors)
617
+ .get('/boom', () => {
618
+ throw new Error('boom');
619
+ });
620
+ ```
621
+
622
+ ### A route has no headers, though the others do
623
+
624
+ **When:** one route's responses lack the headers, while a `404` or another
625
+ route's have them.
626
+
627
+ **Why:** a route declared **before** `app.use(secureHeaders())` does not run
628
+ it. A request no route matches runs every top-level middleware wherever it
629
+ is declared, which is why its `404` is covered.
630
+
631
+ **Fix:** declare `secureHeaders` before the routes:
632
+
633
+ ```ts
634
+ const app = alxia()
635
+ .use(secureHeaders())
636
+ .get('/early', ({ reply }) => reply(200, 'now covered'));
637
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/secure-headers",
3
- "version": "0.2.0",
3
+ "version": "0.4.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,12 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/client": "^0.2.1",
45
- "@alxia/core": "^0.3.0",
44
+ "@alxia/core": "^0.5.0",
46
45
  "@types/bun": "^1.4.2",
47
46
  "zod": "^4.2.0"
48
47
  },
49
48
  "peerDependencies": {
50
- "@alxia/core": "^0.3.0",
49
+ "@alxia/core": "^0.5.0",
51
50
  "typescript": "^6.0.3 || ^7.0.0"
52
51
  }
53
52
  }