@alxia/secure-headers 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,25 +2,33 @@
2
2
 
3
3
  Each entry is headed by the text you see: a TypeScript error, an exception,
4
4
  a browser console message, or a header that is or is not on a response.
5
- `secureHeaders` itself throws nothing; what goes wrong is a type, a
6
- browser refusing what a header forbids, or a header in the wrong place.
5
+ `secureHeaders` throws only at startup, on options it cannot send; what
6
+ goes wrong otherwise is a type, a browser refusing what a header forbids,
7
+ or a header in the wrong place.
7
8
 
8
9
  **Types**
9
10
 
10
11
  - [`Type 'true' is not assignable to type 'Setting | undefined'`](#type-true-is-not-assignable-to-type-setting--undefined)
11
- - [`… is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`](#-is-not-assignable-to-parameter-of-type-secureheadersoptions-with-exactoptionalpropertytypes-true)
12
- - [`Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions'`](#object-literal-may-only-specify-known-properties-and--does-not-exist-in-type-secureheadersoptions)
13
- - [`No overload matches this call` … `has no properties in common with type 'SecureHeadersOptions'`](#no-overload-matches-this-call--has-no-properties-in-common-with-type-secureheadersoptions)
12
+ - [`No overload matches this call` … `is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`](#no-overload-matches-this-call--is-not-assignable-to-type-secureheadersoptions-with-exactoptionalpropertytypes-true)
13
+ - [`No overload matches this call` … `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions & …'`](#no-overload-matches-this-call--object-literal-may-only-specify-known-properties-and--does-not-exist-in-type-secureheadersoptions--)
14
+ - [`No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, "", 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
+ - [`No overload matches this call` … `Type 'boolean' is not assignable to type 'true'`](#no-overload-matches-this-call--type-boolean-is-not-assignable-to-type-true)
14
17
 
15
18
  **Runtime**
16
19
 
17
20
  - [`TypeError: secureHeaders: … is empty; give false to leave the … header out`](#typeerror-secureheaders--is-empty-give-false-to-leave-the--header-out)
18
- - [`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
+ - [`TypeError: secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: …`](#typeerror-secureheaders-nonce-is-on-but-the-content-security-policy-has-no-script-src-to-add-it-to-)
23
+ - [`TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`](#typeerror-secureheaders-nonce-is-on-but-contentsecuritypolicy-is-false-)
24
+ - [`TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`](#typeerror-secureheaders-contentsecuritypolicy-names-nonce-but-nonce-is-off-give-nonce-true)
19
25
 
20
26
  **In the browser**
21
27
 
22
28
  - [`Refused to load the … because it violates the following Content Security Policy directive: "default-src 'none'"`](#refused-to-load-the--because-it-violates-the-following-content-security-policy-directive-default-src-none)
23
29
  - [`Refused to send form data to '…' because it violates the following Content Security Policy directive: "form-action 'none'"`](#refused-to-send-form-data-to--because-it-violates-the-following-content-security-policy-directive-form-action-none)
30
+ - [`Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)
31
+ - [Inline style attributes stop applying once `style-src` has the nonce](#inline-style-attributes-stop-applying-once-style-src-has-the-nonce)
24
32
  - [`Refused to frame '…' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'"`](#refused-to-frame--because-an-ancestor-violates-the-following-content-security-policy-directive-frame-ancestors-none)
25
33
  - [`net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin`](#neterr_blocked_by_responsenotsameorigin)
26
34
  - [`window.opener` is `null` in a sign-in popup](#windowopener-is-null-in-a-sign-in-popup)
@@ -31,7 +39,9 @@ browser refusing what a header forbids, or a header in the wrong place.
31
39
  - [An option has no effect on a response](#an-option-has-no-effect-on-a-response)
32
40
  - [`X-Powered-By` or `Server` is still sent](#x-powered-by-or-server-is-still-sent)
33
41
  - [A response arrives without the headers](#a-response-arrives-without-the-headers)
34
- - [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)
35
45
 
36
46
  ## Types
37
47
 
@@ -40,10 +50,16 @@ browser refusing what a header forbids, or a header in the wrong place.
40
50
  **When:** a header option is given `true`, an object or an array.
41
51
 
42
52
  ```text
43
- error TS2322: Type 'true' is not assignable to type 'Setting | undefined'.
44
- error TS2322: Type '{ defaultSrc: string[]; }' is not assignable to type 'Setting | undefined'.
53
+ error TS2769: No overload matches this call.
54
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
55
+ Type 'true' is not assignable to type 'Setting'.
56
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
57
+ Type 'true' is not assignable to type 'Setting | undefined'.
45
58
  ```
46
59
 
60
+ An object, `{ defaultSrc: [...] }`, reads the same with its own type in
61
+ place of `'true'`.
62
+
47
63
  `hidePoweredBy` given anything but a boolean is the same error, against
48
64
  `'boolean | undefined'`.
49
65
 
@@ -63,13 +79,18 @@ app.use(
63
79
  );
64
80
  ```
65
81
 
66
- ### `… is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`
82
+ ### `No overload matches this call` … `is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`
67
83
 
68
84
  **When:** an option is set to `undefined`, often from a condition, with
69
85
  `exactOptionalPropertyTypes` on.
70
86
 
71
87
  ```text
72
- error TS2379: Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
88
+ error TS2769: No overload matches this call.
89
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
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.
91
+ Type '{ xFrameOptions: undefined; }' is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
92
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
93
+ Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce?: false; }' with 'exactOptionalPropertyTypes: true'. …
73
94
  ```
74
95
 
75
96
  **Why:** the options are optional, not `undefined`-able. Under that flag a
@@ -83,51 +104,59 @@ const production = Bun.env.NODE_ENV === 'production';
83
104
  app.use(secureHeaders({ ...(production ? {} : { strictTransportSecurity: false }) }));
84
105
  ```
85
106
 
86
- ### `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions'`
107
+ ### `No overload matches this call` … `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions & …'`
87
108
 
88
109
  **When:** an option names a header the plugin does not send, or uses
89
110
  another library's name for one.
90
111
 
91
112
  ```text
92
- error TS2353: Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions'.
113
+ error TS2769: No overload matches this call.
114
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
115
+ Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce: true; }'.
116
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
117
+ Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce?: false; }'.
93
118
  ```
94
119
 
95
120
  **Why:** the options are the twelve headers in the
96
- [guide](guide.md#the-options), plus `hidePoweredBy`. `X-Powered-By` and
121
+ [guide](guide.md#the-options), plus `hidePoweredBy` and `nonce`. `X-Powered-By` and
97
122
  `Server` are governed by `hidePoweredBy`; anything else —
98
123
  `Content-Security-Policy-Report-Only`, `X-XSS-Protection` — is not sent by
99
- the plugin.
124
+ the middleware.
100
125
 
101
- **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
102
127
  header:
103
128
 
104
129
  ```ts
105
- import { alxia, withHeaders } from '@alxia/core';
130
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
106
131
  import { secureHeaders } from '@alxia/secure-headers';
107
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
+
108
139
  const app = alxia()
109
140
  .use(secureHeaders({ hidePoweredBy: true }))
110
- .onResponse((response) =>
111
- withHeaders(response, (headers) =>
112
- headers.set('content-security-policy-report-only', "default-src 'self'"),
113
- ),
114
- );
141
+ .use(reportOnly);
115
142
  ```
116
143
 
117
- ### `No overload matches this call` … `has no properties in common with type 'SecureHeadersOptions'`
144
+ ### `No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, "", never>'`
118
145
 
119
- **When:** `secureHeaders` is given to `use` without being called.
146
+ **When:** `secureHeaders` is given to `app.use` without being called.
120
147
 
121
148
  ```text
122
149
  error TS2769: No overload matches this call.
123
- Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
124
- Argument of type '(options?: SecureHeadersOptions | undefined) => Plugin' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => AnyAlxia'.
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'.
125
152
  Types of parameters 'options' and 'app' are incompatible.
126
- Type 'Alxia<Empty, Empty, "", never>' has no properties in common with type 'SecureHeadersOptions'.
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; }'.
127
155
  ```
128
156
 
129
- **Why:** `secureHeaders` makes the plugin from its options; the plugin is
130
- what it returns.
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
159
+ overload, the nonce one, and says what that lacks.
131
160
 
132
161
  **Fix:** call it, with or without options:
133
162
 
@@ -135,6 +164,57 @@ what it returns.
135
164
  app.use(secureHeaders());
136
165
  ```
137
166
 
167
+ ### `Property 'nonce' does not exist on type 'RouteBase<…>'`
168
+
169
+ **When:** a route or a middleware reads `nonce` from its context.
170
+
171
+ ```text
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'.
174
+ ```
175
+
176
+ The second is a `derive` or a middleware.
177
+
178
+ **Why:** only `secureHeaders({ nonce: true })` adds it, and only to the
179
+ routes declared after its `app.use`. Without `nonce: true`, or on a route
180
+ declared before it, there is none.
181
+
182
+ **Fix:** turn it on, and declare the routes that read it after it:
183
+
184
+ ```ts
185
+ alxia()
186
+ .use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
187
+ .get('/', ({ nonce, reply }) => reply(200, nonce));
188
+ ```
189
+
190
+ ### `No overload matches this call` … `Type 'boolean' is not assignable to type 'true'`
191
+
192
+ **When:** `nonce` is given a `boolean` that is not a literal: a flag from
193
+ the environment, say.
194
+
195
+ ```text
196
+ error TS2769: No overload matches this call.
197
+ Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
198
+ Type 'boolean' is not assignable to type 'true'.
199
+ Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
200
+ Type 'boolean' is not assignable to type 'false'.
201
+ ```
202
+
203
+ **Why:** `nonce: true` changes the middleware's type, since it adds `nonce`
204
+ to the context. A `boolean` leaves TypeScript unable to say whether the
205
+ routes read one.
206
+
207
+ **Fix:** pick the call by the flag, so each has a literal:
208
+
209
+ ```ts
210
+ const policy = "default-src 'self'; script-src 'self'";
211
+ const app = useNonce
212
+ ? alxia().use(secureHeaders({ nonce: true, contentSecurityPolicy: policy }))
213
+ : alxia().use(secureHeaders({ contentSecurityPolicy: policy }));
214
+ ```
215
+
216
+ Or keep it on everywhere: the nonce costs one random draw per request.
217
+
138
218
  ## Runtime
139
219
 
140
220
  ### `TypeError: secureHeaders: … is empty; give false to leave the … header out`
@@ -156,14 +236,20 @@ Only `false` leaves a header out.
156
236
  app.use(secureHeaders({ xFrameOptions: false }));
157
237
  ```
158
238
 
159
- ### `TypeError: alxia().use(secureHeaders).get is not a function`
239
+ ### `TypeError: use(): the plugin function returned function, not an app: …`
160
240
 
161
- **When:** the same mistake as above in JavaScript, or past a cast: the
162
- 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:
163
244
 
164
- **Why:** `use` calls the function with the app. Uncalled, `secureHeaders`
165
- reads the app as its options and returns a plugin, not the app, so the
166
- 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.
167
253
 
168
254
  **Fix:**
169
255
 
@@ -173,6 +259,65 @@ const app = alxia()
173
259
  .get('/', ({ reply }) => reply(200, 'ok'));
174
260
  ```
175
261
 
262
+ ### `TypeError: secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: …`
263
+
264
+ **When:** `nonce: true`, with a policy that has neither a `script-src` (or
265
+ `script-src-elem`) nor `NONCE` — the default policy among them. It throws
266
+ at startup:
267
+
268
+ ```text
269
+ TypeError: secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: write one, or place NONCE where the nonce goes (the policy is "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
270
+ ```
271
+
272
+ **Why:** the nonce goes in a directive. Without `script-src`, scripts fall
273
+ back on `default-src`, and adding a `script-src` of the middleware's own would
274
+ change what else they may load.
275
+
276
+ **Fix:** write the `script-src` you want, or say where the nonce goes:
277
+
278
+ ```ts
279
+ import { NONCE, secureHeaders } from '@alxia/secure-headers';
280
+
281
+ app.use(secureHeaders({ nonce: true, contentSecurityPolicy: "default-src 'self'; script-src 'self'" }));
282
+ app.use(secureHeaders({ nonce: true, contentSecurityPolicy: `default-src 'self' ${NONCE}` }));
283
+ ```
284
+
285
+ ### `TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`
286
+
287
+ **When:** `nonce: true` and `contentSecurityPolicy: false` together.
288
+
289
+ ```text
290
+ TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header
291
+ ```
292
+
293
+ **Why:** a browser only honours a nonce the response's policy names; with
294
+ no policy there is nothing for it to allow.
295
+
296
+ **Fix:** give a policy, or drop `nonce`. A route that writes its own policy
297
+ with `ctx.nonce` still needs the middleware's: give the app one, and let the
298
+ route's replace it ([guide](guide.md#who-reads-it)).
299
+
300
+ ### `TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`
301
+
302
+ **When:** the policy holds `NONCE`, and `nonce` is left out or `false`.
303
+ It throws at startup:
304
+
305
+ ```text
306
+ TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true
307
+ ```
308
+
309
+ **Why:** without `nonce: true` the placeholder would go out as it is,
310
+ `'nonce-{alxia}'`, which allows nothing and reads as a mistake in a
311
+ browser's console.
312
+
313
+ **Fix:**
314
+
315
+ ```ts
316
+ import { NONCE, secureHeaders } from '@alxia/secure-headers';
317
+
318
+ app.use(secureHeaders({ nonce: true, contentSecurityPolicy: `script-src 'self' ${NONCE}` }));
319
+ ```
320
+
176
321
  ## In the browser
177
322
 
178
323
  These messages are Chrome's wording; Firefox and Safari report the same
@@ -220,6 +365,51 @@ form, even to itself.
220
365
  const PAGE_POLICY = "default-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'";
221
366
  ```
222
367
 
368
+ ### `Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`
369
+
370
+ **When:** `nonce: true` is on, and a page's inline script does not run.
371
+ Chrome adds `Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a
372
+ nonce ('nonce-...') is required to enable inline execution.`
373
+
374
+ **Why:** the script carries no nonce, or another request's: one cached with
375
+ the page, or written into the HTML at build time. Each response allows only
376
+ its own.
377
+
378
+ **Fix:** write the request's `ctx.nonce` on each `<script>`, and never
379
+ cache a page whose HTML holds one:
380
+
381
+ ```ts
382
+ .get('/', ({ nonce, reply }) =>
383
+ reply(200, `<script nonce="${nonce}">…</script>`, {
384
+ headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
385
+ }),
386
+ )
387
+ ```
388
+
389
+ With React Router, `nonceOf(loadContext)` in `entry.server.tsx` does it for
390
+ every script it renders; see
391
+ [its troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#a-script-of-the-page-has-no-nonce).
392
+
393
+ ### Inline style attributes stop applying once `style-src` has the nonce
394
+
395
+ **When:** `NONCE` is put in `style-src`, which also has `'unsafe-inline'`,
396
+ and every `style="…"` attribute is ignored: Chrome prints `Refused to apply
397
+ inline style because it violates the following Content Security Policy
398
+ directive: "style-src 'self' 'unsafe-inline' 'nonce-…'". Note that
399
+ 'unsafe-inline' is ignored if either a hash or nonce value is present in
400
+ the source list.`
401
+
402
+ **Why:** a nonce in a directive turns its `'unsafe-inline'` off, and an
403
+ attribute cannot carry a nonce. That is why `nonce: true` alone leaves
404
+ `style-src` as written.
405
+
406
+ **Fix:** keep the nonce out of `style-src`, or allow the attributes in a
407
+ directive of their own:
408
+
409
+ ```ts
410
+ contentSecurityPolicy: `default-src 'self'; script-src 'self'; style-src-elem 'self' ${NONCE}; style-src-attr 'unsafe-inline'`,
411
+ ```
412
+
223
413
  ### `Refused to frame '…' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'"`
224
414
 
225
415
  **When:** a page puts a response of the app in an `<iframe>`. With the
@@ -299,11 +489,11 @@ until it reads `max-age=0` from the host over HTTPS.
299
489
 
300
490
  **When:** an option is set, and some responses still carry another value.
301
491
 
302
- **Why:** the plugin never overwrites a header the response already has.
303
- A route set it on its reply or on `set.headers`, an `onResponse` hook
304
- declared before `secureHeaders` set it, or one declared after overwrote it.
305
- `@alxia/openapi`'s reference page and `@alxia/graphql`'s IDE set their own
306
- `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.
307
497
 
308
498
  **Fix:** find what sets it on that route, and change it there:
309
499
 
@@ -317,22 +507,26 @@ console.log(response.headers.get('content-security-policy'));
317
507
  **When:** `hidePoweredBy` is on (the default), and one of them is on the
318
508
  response.
319
509
 
320
- **Why:** the plugin deletes them when its hook runs. An `onResponse` hook
321
- declared after `secureHeaders` that adds one runs later, and keeps it. A
322
- 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.
323
515
 
324
- **Fix:** declare `secureHeaders` after the hook that adds the header, or
325
- stop that hook adding it:
516
+ **Fix:** declare `secureHeaders` before the middleware that adds the header,
517
+ or stop that middleware adding it:
326
518
 
327
519
  ```ts
328
- import { alxia, withHeaders } from '@alxia/core';
520
+ import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
329
521
  import { secureHeaders } from '@alxia/secure-headers';
330
522
 
523
+ const poweredBy = defineMiddleware(async (ctx, next) =>
524
+ withHeaders(await settle(ctx, next()), (headers) => headers.set('x-powered-by', 'my-app')),
525
+ );
526
+
331
527
  const app = alxia()
332
- .onResponse((response) =>
333
- withHeaders(response, (headers) => headers.set('x-powered-by', 'my-app')),
334
- )
335
- .use(secureHeaders()); // its hook runs after, and deletes it
528
+ .use(secureHeaders()) // outermost: deletes the header on the way out
529
+ .use(poweredBy);
336
530
  ```
337
531
 
338
532
  ### A response arrives without the headers
@@ -340,20 +534,20 @@ const app = alxia()
340
534
  **When:** a response has none of the headers, though `secureHeaders` is
341
535
  in use.
342
536
 
343
- **Why:** the plugin is an `onResponse` hook, and three responses run no
344
- `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
345
539
  served with `page()`, which `Bun.serve` answers itself, and the 500 sent
346
- when an `around` hook throws.
540
+ when a deprecated `around` hook throws.
347
541
 
348
542
  **Fix:** serve a page that needs the headers through a route, `file` or
349
543
  `static` rather than `page()` — or give it its policy in its HTML with
350
544
  `<meta http-equiv="Content-Security-Policy" content="…">`, where browsers
351
- ignore `frame-ancestors`. An `around` hook whose own work may fail catches
352
- it and still returns what `next()` resolved to, which already carries the
353
- 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:
354
548
 
355
549
  ```ts
356
- app.around(async (_ctx, next) => {
550
+ const timing = defineMiddleware(async (ctx, next) => {
357
551
  const response = await next();
358
552
  try {
359
553
  recordTiming(response); // your own work
@@ -362,19 +556,26 @@ app.around(async (_ctx, next) => {
362
556
  }
363
557
  return response;
364
558
  });
559
+
560
+ const app = alxia().use(timing).use(secureHeaders());
365
561
  ```
366
562
 
367
- ### Routes outside a group get the headers
563
+ ### A 404 or a route outside a group has no headers
368
564
 
369
- **When:** `secureHeaders` is used inside a `group`, meant for its routes
370
- 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.
371
567
 
372
- **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.
373
571
 
374
572
  **Fix:** use it on the app, and give the routes that differ their own
375
573
  values on their replies:
376
574
 
377
575
  ```ts
576
+ import { alxia } from '@alxia/core';
577
+ import { secureHeaders } from '@alxia/secure-headers';
578
+
378
579
  const app = alxia()
379
580
  .use(secureHeaders())
380
581
  .get('/embed', ({ reply }) =>
@@ -386,3 +587,55 @@ const app = alxia()
386
587
  }),
387
588
  );
388
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.1.1",
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.0",
45
- "@alxia/core": "^0.2.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.2.0",
49
+ "@alxia/core": "^0.4.0",
51
50
  "typescript": "^6.0.3 || ^7.0.0"
52
51
  }
53
52
  }