@alxia/secure-headers 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -6
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -7
- package/dist/index.js.map +5 -4
- package/dist/nonce.d.ts +34 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/secure-headers.d.ts +34 -5
- package/dist/secure-headers.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide.md +192 -54
- package/docs/roadmap.md +15 -2
- package/docs/troubleshooting.md +314 -61
- package/package.json +3 -4
package/docs/troubleshooting.md
CHANGED
|
@@ -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`
|
|
6
|
-
|
|
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
|
-
- [
|
|
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` … `
|
|
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:
|
|
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
|
-
- [
|
|
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
|
|
44
|
-
|
|
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
|
-
###
|
|
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
|
|
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
|
|
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
|
|
124
|
+
the middleware.
|
|
100
125
|
|
|
101
|
-
**Fix:** `hidePoweredBy` for those two; a
|
|
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
|
-
.
|
|
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` … `
|
|
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
|
|
124
|
-
Argument of type '(options?: SecureHeadersOptions | undefined)
|
|
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,
|
|
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
|
|
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:
|
|
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:
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
303
|
-
A route set it on its reply or on `set.headers`,
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
321
|
-
declared
|
|
322
|
-
|
|
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`
|
|
325
|
-
stop that
|
|
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
|
-
.
|
|
333
|
-
|
|
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
|
|
344
|
-
|
|
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
|
|
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`.
|
|
352
|
-
it and still returns what `next()`
|
|
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
|
-
|
|
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
|
-
###
|
|
563
|
+
### A 404 or a route outside a group has no headers
|
|
368
564
|
|
|
369
|
-
**When:** `secureHeaders` is used inside a `group`,
|
|
370
|
-
|
|
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:**
|
|
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.
|
|
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/
|
|
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.
|
|
49
|
+
"@alxia/core": "^0.4.0",
|
|
51
50
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
52
51
|
}
|
|
53
52
|
}
|