@alxia/secure-headers 0.2.0 → 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,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,30 @@ 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
+ | `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
+ ```
103
120
 
104
121
  ## Documentation
105
122
 
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.
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.
107
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.
108
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,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 NoncePlugin, 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,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -16,20 +16,10 @@ 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
+ settle,
33
23
  withHeaders
34
24
  } from "@alxia/core";
35
25
  var DEFAULTS = {
@@ -69,17 +59,19 @@ function secureHeaders(options = {}) {
69
59
  throw new TypeError("secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true");
70
60
  }
71
61
  const set = setter(headers, hide);
72
- return (app) => app.onResponse((response) => set(response));
62
+ return defineMiddleware(async (ctx, next) => set(await settle(ctx, next())));
73
63
  }
74
64
  if (policy === undefined) {
75
65
  throw new TypeError("secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header");
76
66
  }
77
67
  headers.delete("content-security-policy");
78
68
  const withNonce = policyWithNonce(policy);
79
- const nonceOf = nonceStore();
80
69
  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;
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
+ });
83
75
  }
84
76
  function fixedHeaders(options) {
85
77
  const headers = new Map;
@@ -114,5 +106,5 @@ export {
114
106
  secureHeaders
115
107
  };
116
108
 
117
- //# debugId=3F73DEF9D20E37B064756E2164756E21
109
+ //# debugId=73869EA605B3C65B64756E2164756E21
118
110
  //# 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\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"
7
7
  ],
8
- "mappings": "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",
9
- "debugId": "3F73DEF9D20E37B064756E2164756E21",
8
+ "mappings": "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",
9
+ "debugId": "73869EA605B3C65B64756E2164756E21",
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 MiddlewareMark, 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,23 @@ 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>> & MiddlewareMark;
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>>> & 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.
32
41
  *
33
42
  * ```ts
34
43
  * app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'" }));
@@ -45,8 +54,8 @@ export type NoncePlugin = Alxia<NonceContext, Empty, '', never> & Requiring<Empt
45
54
  */
46
55
  export declare function secureHeaders(options: SecureHeadersOptions & {
47
56
  readonly nonce: true;
48
- }): NoncePlugin;
57
+ }): NonceMiddleware;
49
58
  export declare function secureHeaders(options?: SecureHeadersOptions & {
50
59
  readonly nonce?: false;
51
- }): Plugin;
60
+ }): SecureHeaders;
52
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,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,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, 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,18 @@ 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 an `onError` reply | 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 |
300
+ | the 500 sent when a deprecated `around` hook itself throws | no |
297
301
 
298
302
  ```ts
299
303
  const app = alxia().use(secureHeaders());
@@ -303,58 +307,75 @@ response.status; // 404
303
307
  response.headers.get('x-content-type-options'); // 'nosniff'
304
308
  ```
305
309
 
306
- ## Order with other `onResponse` hooks
310
+ ## Order with other middlewares
307
311
 
308
- `onResponse` hooks run in the order they are declared, and the plugin never
309
- overwrites a header that is already there. So:
312
+ Middlewares nest: the first `use` is the outermost, and on the way out each
313
+ one sees the response of the ones after it. The middleware never overwrites
314
+ a header that is already there. So:
310
315
 
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.
316
+ - a middleware declared **after** `secureHeaders` that sets one of its
317
+ headers wins: `secureHeaders` finds it already there;
318
+ - a middleware declared **before** it that sets one of its headers wins too,
319
+ by overwriting;
320
+ - a middleware declared **before** it that adds `X-Powered-By` or `Server`
321
+ keeps it: `secureHeaders` deleted them before that one ran.
317
322
 
318
323
  ```ts
319
- import { alxia, withHeaders } from '@alxia/core';
324
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
320
325
  import { secureHeaders } from '@alxia/secure-headers';
321
326
 
327
+ const referrerOrigin = defineMiddleware(async (ctx, next) =>
328
+ withHeaders(await settle(ctx, next()), (headers) => headers.set('referrer-policy', 'origin')),
329
+ );
330
+
322
331
  const app = alxia()
323
- .onResponse((response) =>
324
- withHeaders(response, (headers) => headers.set('referrer-policy', 'origin')),
325
- )
326
332
  .use(secureHeaders())
333
+ .use(referrerOrigin)
327
334
  .get('/', ({ reply }) => reply(200, 'ok'));
328
335
 
329
336
  (await app.request('/')).headers.get('referrer-policy'); // 'origin'
330
337
  ```
331
338
 
332
- ## A header the plugin does not know
339
+ Declare `secureHeaders()` among the observers, first: `logger()`,
340
+ `telemetry()`, `secureHeaders()`, `cors()`, `compress()`. An
341
+ error-handling middleware — a `try`/`catch` around `next()`, or
342
+ `janusErrors()` — goes **after** it. `secureHeaders()` settles `next()`: it
343
+ sets the headers on the response the error would be answered with (the
344
+ route's `onError` reply, its `HttpError`'s status, or a 500), then the error
345
+ goes on, so a `try`/`catch` catches it wherever it is declared; declared
346
+ before `secureHeaders()`, its reply does not carry the headers. A guard on the app (`bearer`, a required
347
+ session) declared before it answers its `401` without them.
348
+
349
+ The deprecated `onResponse` and `around` hooks still run outside every
350
+ middleware: an `onResponse` hook sees the headers `secureHeaders` set,
351
+ wherever it is declared.
352
+
353
+ ## A header the middleware does not know
333
354
 
334
- The plugin sends the twelve headers above and nothing else. Another one —
355
+ The middleware sends the twelve headers above and nothing else. Another one —
335
356
  `Content-Security-Policy-Report-Only` while a policy is being tried, say —
336
- is a hook of your own, with `withHeaders` from `@alxia/core`:
357
+ is a middleware of your own, with `settle` and `withHeaders` from
358
+ `@alxia/core`:
337
359
 
338
360
  ```ts
339
- import { alxia, withHeaders } from '@alxia/core';
361
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
340
362
  import { secureHeaders } from '@alxia/secure-headers';
341
363
 
364
+ const reportOnly = defineMiddleware(async (ctx, next) =>
365
+ withHeaders(await settle(ctx, next()), (headers) => {
366
+ if (!headers.has('content-security-policy-report-only')) {
367
+ headers.set('content-security-policy-report-only', "default-src 'self'; report-to csp");
368
+ }
369
+ }),
370
+ );
371
+
342
372
  const app = alxia()
343
373
  .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
- );
374
+ .use(reportOnly);
354
375
  ```
355
376
 
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.
377
+ See [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
378
+ in the core's guide for what a middleware may do.
358
379
 
359
380
  ## Recipes
360
381
 
@@ -481,6 +502,6 @@ test('a 404 is covered too', async () => {
481
502
 
482
503
  - [Troubleshooting](troubleshooting.md): a browser refuses something, or
483
504
  TypeScript does.
484
- - [Hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md)
505
+ - [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
485
506
  and [Groups and plugins](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md)
486
507
  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())` keeps working as a deprecated alias.
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
+ - [`No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, "", never>'`](#no-overload-matches-this-call--property-nonce-is-missing-in-type-alxiaempty--never)
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
+ - [`TypeError: use(): the plugin function returned function, not an app: …`](#typeerror-use-the-plugin-function-returned-function-not-an-app-)
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,39 +121,41 @@ 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
+ ### `No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, "", never>'`
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
149
  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'.
150
+ Overload 1 of 11, '(plugin: (app: Alxia<Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
151
+ Argument of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware; (options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin; }' is not assignable to parameter of type '(app: Alxia<Empty, "", never>) => AnyAlxia'.
148
152
  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; }'.
153
+ Type 'Alxia<Empty, "", never>' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'.
154
+ Property 'nonce' is missing in type 'Alxia<Empty, "", never>' but required in type '{ readonly nonce: true; }'.
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
157
+ **Why:** `secureHeaders` makes the middleware from its options; the
158
+ middleware is what it returns. TypeScript tries the app as the options of its first
155
159
  overload, the nonce one, and says what that lacks.
156
160
 
157
161
  **Fix:** call it, with or without options:
@@ -160,16 +164,19 @@ overload, the nonce one, and says what that lacks.
160
164
  app.use(secureHeaders());
161
165
  ```
162
166
 
163
- ### `Property 'nonce' does not exist on type 'Context<…>'`
167
+ ### `Property 'nonce' does not exist on type 'RouteBase<…>'`
164
168
 
165
- **When:** a route or a hook reads `nonce` from its context.
169
+ **When:** a route or a middleware reads `nonce` from its context.
166
170
 
167
171
  ```text
168
- error TS2339: Property 'nonce' does not exist on type 'Context<Empty, "/", Empty>'.
172
+ error TS2339: Property 'nonce' does not exist on type 'RouteBase<RouteApp<"GET", Empty, "", never>, "/">'.
173
+ error TS2339: Property 'nonce' does not exist on type 'BaseContext & Empty'.
169
174
  ```
170
175
 
176
+ The second is a `derive` or a middleware.
177
+
171
178
  **Why:** only `secureHeaders({ nonce: true })` adds it, and only to the
172
- routes declared after its `use`. Without `nonce: true`, or on a route
179
+ routes declared after its `app.use`. Without `nonce: true`, or on a route
173
180
  declared before it, there is none.
174
181
 
175
182
  **Fix:** turn it on, and declare the routes that read it after it:
@@ -187,13 +194,13 @@ the environment, say.
187
194
 
188
195
  ```text
189
196
  error TS2769: No overload matches this call.
190
- Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
197
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
191
198
  Type 'boolean' is not assignable to type 'true'.
192
- Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
199
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
193
200
  Type 'boolean' is not assignable to type 'false'.
194
201
  ```
195
202
 
196
- **Why:** `nonce: true` changes the plugin's type, since it adds `nonce`
203
+ **Why:** `nonce: true` changes the middleware's type, since it adds `nonce`
197
204
  to the context. A `boolean` leaves TypeScript unable to say whether the
198
205
  routes read one.
199
206
 
@@ -229,14 +236,20 @@ Only `false` leaves a header out.
229
236
  app.use(secureHeaders({ xFrameOptions: false }));
230
237
  ```
231
238
 
232
- ### `TypeError: alxia().use(secureHeaders).get is not a function`
239
+ ### `TypeError: use(): the plugin function returned function, not an app: …`
233
240
 
234
- **When:** the same mistake as above in JavaScript, or past a cast: the
235
- chain breaks on the next method after `use(secureHeaders)`.
241
+ **When:** the same mistake as above in JavaScript, or past a cast:
242
+ `alxia().use(secureHeaders)`, the factory given uncalled. It throws at
243
+ startup:
236
244
 
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.
245
+ ```text
246
+ TypeError: use(): the plugin function returned function, not an app: a plugin returns the app it is given; a middleware is made with defineMiddleware() and given to use()
247
+ ```
248
+
249
+ **Why:** `app.use` takes a function that is not a middleware for a plugin:
250
+ it calls it with the app, and expects the app back. Uncalled,
251
+ `secureHeaders` reads the app as its options and returns a middleware, a
252
+ function, not the app.
240
253
 
241
254
  **Fix:**
242
255
 
@@ -257,7 +270,7 @@ TypeError: secureHeaders: nonce is on, but the content-security-policy has no sc
257
270
  ```
258
271
 
259
272
  **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
273
+ back on `default-src`, and adding a `script-src` of the middleware's own would
261
274
  change what else they may load.
262
275
 
263
276
  **Fix:** write the `script-src` you want, or say where the nonce goes:
@@ -281,7 +294,7 @@ TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: a non
281
294
  no policy there is nothing for it to allow.
282
295
 
283
296
  **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
297
+ with `ctx.nonce` still needs the middleware's: give the app one, and let the
285
298
  route's replace it ([guide](guide.md#who-reads-it)).
286
299
 
287
300
  ### `TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`
@@ -476,11 +489,11 @@ until it reads `max-age=0` from the host over HTTPS.
476
489
 
477
490
  **When:** an option is set, and some responses still carry another value.
478
491
 
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.
492
+ **Why:** the middleware never overwrites a header the response already has.
493
+ A route set it on its reply or on `set.headers`, a middleware declared
494
+ after `secureHeaders` set it on the way out, or one declared before it
495
+ overwrote it.
496
+ `@alxia/graphql`'s IDE sets its own `Content-Security-Policy` on purpose.
484
497
 
485
498
  **Fix:** find what sets it on that route, and change it there:
486
499
 
@@ -494,22 +507,26 @@ console.log(response.headers.get('content-security-policy'));
494
507
  **When:** `hidePoweredBy` is on (the default), and one of them is on the
495
508
  response.
496
509
 
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.
510
+ **Why:** the middleware deletes them when the response comes back through
511
+ it. A middleware declared **before** `secureHeaders` that adds one sees the
512
+ response after that, and keeps it; so does a deprecated `onResponse` hook,
513
+ which runs after every middleware. A proxy in front of the app may add its
514
+ own `Server` too.
500
515
 
501
- **Fix:** declare `secureHeaders` after the hook that adds the header, or
502
- stop that hook adding it:
516
+ **Fix:** declare `secureHeaders` before the middleware that adds the header,
517
+ or stop that middleware adding it:
503
518
 
504
519
  ```ts
505
- import { alxia, withHeaders } from '@alxia/core';
520
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
506
521
  import { secureHeaders } from '@alxia/secure-headers';
507
522
 
523
+ const poweredBy = defineMiddleware(async (ctx, next) =>
524
+ withHeaders(await settle(ctx, next()), (headers) => headers.set('x-powered-by', 'my-app')),
525
+ );
526
+
508
527
  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
528
+ .use(secureHeaders()) // outermost: deletes the header on the way out
529
+ .use(poweredBy);
513
530
  ```
514
531
 
515
532
  ### A response arrives without the headers
@@ -517,20 +534,20 @@ const app = alxia()
517
534
  **When:** a response has none of the headers, though `secureHeaders` is
518
535
  in use.
519
536
 
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
537
+ **Why:** the middleware decorates what comes back through it, and three
538
+ responses do not: a WebSocket upgrade that succeeds (the `101`), a page
522
539
  served with `page()`, which `Bun.serve` answers itself, and the 500 sent
523
- when an `around` hook throws.
540
+ when a deprecated `around` hook throws.
524
541
 
525
542
  **Fix:** serve a page that needs the headers through a route, `file` or
526
543
  `static` rather than `page()` — or give it its policy in its HTML with
527
544
  `<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:
545
+ ignore `frame-ancestors`. A middleware declared before `secureHeaders`,
546
+ whose own work may fail, catches it and still returns what `next()`
547
+ resolved to, which already carries the headers:
531
548
 
532
549
  ```ts
533
- app.around(async (_ctx, next) => {
550
+ const timing = defineMiddleware(async (ctx, next) => {
534
551
  const response = await next();
535
552
  try {
536
553
  recordTiming(response); // your own work
@@ -539,19 +556,26 @@ app.around(async (_ctx, next) => {
539
556
  }
540
557
  return response;
541
558
  });
559
+
560
+ const app = alxia().use(timing).use(secureHeaders());
542
561
  ```
543
562
 
544
- ### Routes outside a group get the headers
563
+ ### A 404 or a route outside a group has no headers
545
564
 
546
- **When:** `secureHeaders` is used inside a `group`, meant for its routes
547
- only, and every route of the app gets the headers.
565
+ **When:** `secureHeaders` is used inside a `group`, and a route outside the
566
+ group, or a request that matches no route, comes back without them.
548
567
 
549
- **Why:** it adds a global hook, and a group's global hooks are the app's.
568
+ **Why:** a middleware declared in a `group` stays inside the group: it runs
569
+ on its routes and on a request no route matches under its prefix, and on
570
+ nothing else: not on a route outside the group, nor declared after it.
550
571
 
551
572
  **Fix:** use it on the app, and give the routes that differ their own
552
573
  values on their replies:
553
574
 
554
575
  ```ts
576
+ import { alxia } from '@alxia/core';
577
+ import { secureHeaders } from '@alxia/secure-headers';
578
+
555
579
  const app = alxia()
556
580
  .use(secureHeaders())
557
581
  .get('/embed', ({ reply }) =>
@@ -563,3 +587,55 @@ const app = alxia()
563
587
  }),
564
588
  );
565
589
  ```
590
+
591
+ ### A `try`/`catch` middleware's reply has no headers
592
+
593
+ **When:** a middleware wraps `next()` in a `try`/`catch` to answer errors in
594
+ its own format, is declared before `secureHeaders()`, and its reply comes
595
+ back without the security headers.
596
+
597
+ **Why:** `secureHeaders` settles `next()`: it sets the headers on the response
598
+ the route boundary would give (`onError`, the `HttpError`'s status, or a
599
+ 500), then the error goes on to the middlewares around it. A `try`/`catch`
600
+ declared **before** it catches the error, but its reply is made outside
601
+ `secureHeaders`, so it carries no headers; the 500 inside did.
602
+
603
+ **Fix:** declare the error-handling middleware after `secureHeaders`, so
604
+ the headers are set on its reply:
605
+
606
+ ```ts
607
+ import { alxia, defineMiddleware } from '@alxia/core';
608
+ import { secureHeaders } from '@alxia/secure-headers';
609
+
610
+ const errors = defineMiddleware(async (_ctx, next) => {
611
+ try {
612
+ return await next();
613
+ } catch (error) {
614
+ return Response.json({ error: String(error) }, { status: 500 });
615
+ }
616
+ });
617
+
618
+ const app = alxia()
619
+ .use(secureHeaders()) // first
620
+ .use(errors)
621
+ .get('/boom', () => {
622
+ throw new Error('boom');
623
+ });
624
+ ```
625
+
626
+ ### A route has no headers, though the others do
627
+
628
+ **When:** one route's responses lack the headers, while a `404` or another
629
+ route's have them.
630
+
631
+ **Why:** a route declared **before** `app.use(secureHeaders())` does not run
632
+ it. A request no route matches runs every top-level middleware wherever it
633
+ is declared, which is why its `404` is covered.
634
+
635
+ **Fix:** declare `secureHeaders` before the routes:
636
+
637
+ ```ts
638
+ const app = alxia()
639
+ .use(secureHeaders())
640
+ .get('/early', ({ reply }) => reply(200, 'now covered'));
641
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/secure-headers",
3
- "version": "0.2.0",
3
+ "version": "0.3.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.4.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.4.0",
51
50
  "typescript": "^6.0.3 || ^7.0.0"
52
51
  }
53
52
  }