@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/guide.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Guide
|
|
2
2
|
|
|
3
3
|
This page covers `secureHeaders`: what it sends, how to change or drop each
|
|
4
|
-
header, which responses it reaches, and how a page or
|
|
5
|
-
its own policy.
|
|
4
|
+
header, the per-request nonce, which responses it reaches, and how a page or
|
|
5
|
+
another middleware sets its own policy.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { alxia } from '@alxia/core';
|
|
@@ -21,7 +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
|
|
24
|
+
function secureHeaders(options: SecureHeadersOptions & { readonly nonce: true }): NonceMiddleware;
|
|
25
|
+
function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }): SecureHeaders;
|
|
25
26
|
|
|
26
27
|
interface SecureHeadersOptions {
|
|
27
28
|
readonly contentSecurityPolicy?: string | false;
|
|
@@ -40,16 +41,28 @@ interface SecureHeadersOptions {
|
|
|
40
41
|
}
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
`nonce` is not in `SecureHeadersOptions`: each overload adds it, so options
|
|
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
|
|
49
56
|
([below](#a-header-a-route-sets-is-kept)).
|
|
50
57
|
|
|
51
58
|
The options are read once, when `secureHeaders(…)` is called; the header
|
|
52
|
-
values are fixed from then on
|
|
59
|
+
values are fixed from then on, but for the nonce, which is new on every
|
|
60
|
+
request.
|
|
61
|
+
|
|
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
|
|
64
|
+
adds `nonce` to the context of the routes declared after it
|
|
65
|
+
([below](#a-nonce-per-request)).
|
|
53
66
|
|
|
54
67
|
## The options
|
|
55
68
|
|
|
@@ -74,6 +87,7 @@ leave the header out. An option left out keeps the default.
|
|
|
74
87
|
| Option | Type | Default | Effect |
|
|
75
88
|
| --- | --- | --- | --- |
|
|
76
89
|
| `hidePoweredBy` | `boolean` | `true` | removes `X-Powered-By` and `Server` from every response, a route's own included |
|
|
90
|
+
| `nonce` | `boolean` | `false` | a fresh nonce per request, in the policy and on the context: [A nonce per request](#a-nonce-per-request) |
|
|
77
91
|
|
|
78
92
|
### A value
|
|
79
93
|
|
|
@@ -92,7 +106,7 @@ app.use(
|
|
|
92
106
|
|
|
93
107
|
### `false`
|
|
94
108
|
|
|
95
|
-
`false` leaves the header out of every response the
|
|
109
|
+
`false` leaves the header out of every response the middleware touches.
|
|
96
110
|
|
|
97
111
|
```ts
|
|
98
112
|
app.use(secureHeaders({ xFrameOptions: false, strictTransportSecurity: false }));
|
|
@@ -132,11 +146,11 @@ const app = alxia()
|
|
|
132
146
|
(await app.request('/')).headers.get('x-powered-by'); // null
|
|
133
147
|
```
|
|
134
148
|
|
|
135
|
-
`hidePoweredBy: false` keeps whatever the route or another
|
|
149
|
+
`hidePoweredBy: false` keeps whatever the route or another middleware sent.
|
|
136
150
|
|
|
137
151
|
## A header a route sets is kept
|
|
138
152
|
|
|
139
|
-
The
|
|
153
|
+
The middleware only fills in a header the response does not have yet. A route
|
|
140
154
|
that needs its own policy sets it on its reply, and the other defaults
|
|
141
155
|
still apply:
|
|
142
156
|
|
|
@@ -160,25 +174,130 @@ response.headers.get('x-frame-options'); // null — f
|
|
|
160
174
|
```
|
|
161
175
|
|
|
162
176
|
`set.headers` in a handler or a `derive` works the same way, since it ends
|
|
163
|
-
up on the response before the
|
|
177
|
+
up on the response before the middleware reads it.
|
|
178
|
+
|
|
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.
|
|
181
|
+
|
|
182
|
+
## A nonce per request
|
|
183
|
+
|
|
184
|
+
A policy that allows a page's inline scripts without `'unsafe-inline'`
|
|
185
|
+
names a nonce: `script-src 'nonce-…'`, and the same value on each
|
|
186
|
+
`<script nonce="…">`. It must be unguessable and new on every response.
|
|
187
|
+
`nonce: true` does both halves.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { alxia } from '@alxia/core';
|
|
191
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
192
|
+
|
|
193
|
+
const app = alxia()
|
|
194
|
+
.use(
|
|
195
|
+
secureHeaders({
|
|
196
|
+
nonce: true,
|
|
197
|
+
contentSecurityPolicy: "default-src 'self'; script-src 'self'; frame-ancestors 'none'",
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
.get('/', ({ nonce, reply }) =>
|
|
201
|
+
reply(
|
|
202
|
+
200,
|
|
203
|
+
`<!doctype html><script nonce="${nonce}">document.title = 'ready'</script>`,
|
|
204
|
+
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const response = await app.request('/');
|
|
209
|
+
response.headers.get('content-security-policy');
|
|
210
|
+
// "default-src 'self'; script-src 'self' 'nonce-Gm1y0S3h1JZcVvXQ4l6x0A=='; frame-ancestors 'none'"
|
|
211
|
+
await response.text();
|
|
212
|
+
// "<!doctype html><script nonce=\"Gm1y0S3h1JZcVvXQ4l6x0A==\">…"
|
|
213
|
+
```
|
|
164
214
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
215
|
+
### What it is
|
|
216
|
+
|
|
217
|
+
16 bytes from `crypto.getRandomValues`, base64: 24 characters, `==` at the
|
|
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
|
|
220
|
+
the same one. Two requests never share one.
|
|
221
|
+
|
|
222
|
+
### Where it goes in the policy
|
|
223
|
+
|
|
224
|
+
| The policy | The nonce goes |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| names `NONCE` | where `NONCE` stands, each time, and nowhere else |
|
|
227
|
+
| has no `NONCE`, has `script-src` or `script-src-elem` | at the end of each of those directives (their name in any case) |
|
|
228
|
+
| has neither, as the default policy | nowhere: `secureHeaders()` throws at startup; give `contentSecurityPolicy` with `nonce: true` |
|
|
229
|
+
|
|
230
|
+
`NONCE`, exported, is a placeholder string (`'nonce-{alxia}'`) that no real
|
|
231
|
+
policy contains. Use it to put the nonce in `style-src`, or in a directive
|
|
232
|
+
of your own choosing:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import { NONCE, secureHeaders } from '@alxia/secure-headers';
|
|
236
|
+
|
|
237
|
+
app.use(
|
|
238
|
+
secureHeaders({
|
|
239
|
+
nonce: true,
|
|
240
|
+
contentSecurityPolicy: [
|
|
241
|
+
"default-src 'self'",
|
|
242
|
+
`script-src 'self' ${NONCE} 'strict-dynamic'`,
|
|
243
|
+
`style-src 'self' ${NONCE}`,
|
|
244
|
+
].join('; '),
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Leaving `style-src` alone is the default on purpose: once a directive holds
|
|
250
|
+
a nonce, browsers ignore its `'unsafe-inline'`, and `style="…"` attributes,
|
|
251
|
+
which take no nonce, stop applying
|
|
252
|
+
([troubleshooting](troubleshooting.md#inline-style-attributes-stop-applying-once-style-src-has-the-nonce)).
|
|
253
|
+
|
|
254
|
+
### Who reads it
|
|
255
|
+
|
|
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`:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
alxia()
|
|
264
|
+
.get('/early', ({ reply }) => reply(200, 'no policy from this middleware, no ctx.nonce'))
|
|
265
|
+
.use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
|
|
266
|
+
.derive(({ nonce }) => ({ scriptTag: (code: string) => `<script nonce="${nonce}">${code}</script>` }))
|
|
267
|
+
.get('/late', ({ scriptTag, reply }) => reply(200, scriptTag('…')));
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
A route that sets its own `Content-Security-Policy` keeps it, as
|
|
271
|
+
[above](#a-header-a-route-sets-is-kept); its `ctx.nonce` is then in no
|
|
272
|
+
header, unless it writes it into its own policy.
|
|
273
|
+
|
|
274
|
+
### With React Router
|
|
275
|
+
|
|
276
|
+
`@alxia/react-router` reads the nonce from the context, if one is there,
|
|
277
|
+
with `nonceOf(loadContext)` in `entry.server.tsx`. Neither package depends
|
|
278
|
+
on the other. See
|
|
279
|
+
[its guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide.md#a-csp-nonce).
|
|
280
|
+
|
|
281
|
+
### Without `nonce: true`
|
|
282
|
+
|
|
283
|
+
Nothing changes: the same headers, the same values, no `nonce` on the
|
|
284
|
+
context. A policy that names `NONCE` without `nonce: true` is refused at
|
|
285
|
+
startup, so the placeholder never reaches a browser.
|
|
168
286
|
|
|
169
287
|
## Which responses get the headers
|
|
170
288
|
|
|
171
|
-
The
|
|
172
|
-
|
|
289
|
+
The middleware settles `next()`: it sets the headers on the response the
|
|
290
|
+
client would get, whatever produced it.
|
|
173
291
|
|
|
174
292
|
| Response | Headers |
|
|
175
293
|
| --- | --- |
|
|
176
294
|
| a route's reply, `static` and `file` included | yes |
|
|
177
|
-
| the core's 400, 404, 405 and 500 | yes |
|
|
178
|
-
| a `Response`
|
|
179
|
-
| a
|
|
180
|
-
| a
|
|
181
|
-
|
|
|
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 |
|
|
182
301
|
|
|
183
302
|
```ts
|
|
184
303
|
const app = alxia().use(secureHeaders());
|
|
@@ -188,58 +307,75 @@ response.status; // 404
|
|
|
188
307
|
response.headers.get('x-content-type-options'); // 'nosniff'
|
|
189
308
|
```
|
|
190
309
|
|
|
191
|
-
## Order with other
|
|
310
|
+
## Order with other middlewares
|
|
192
311
|
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
195
315
|
|
|
196
|
-
- a
|
|
197
|
-
wins;
|
|
198
|
-
- a
|
|
199
|
-
overwriting;
|
|
200
|
-
- a
|
|
201
|
-
it:
|
|
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.
|
|
202
322
|
|
|
203
323
|
```ts
|
|
204
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
324
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
205
325
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
206
326
|
|
|
327
|
+
const referrerOrigin = defineMiddleware(async (ctx, next) =>
|
|
328
|
+
withHeaders(await settle(ctx, next()), (headers) => headers.set('referrer-policy', 'origin')),
|
|
329
|
+
);
|
|
330
|
+
|
|
207
331
|
const app = alxia()
|
|
208
|
-
.onResponse((response) =>
|
|
209
|
-
withHeaders(response, (headers) => headers.set('referrer-policy', 'origin')),
|
|
210
|
-
)
|
|
211
332
|
.use(secureHeaders())
|
|
333
|
+
.use(referrerOrigin)
|
|
212
334
|
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
213
335
|
|
|
214
336
|
(await app.request('/')).headers.get('referrer-policy'); // 'origin'
|
|
215
337
|
```
|
|
216
338
|
|
|
217
|
-
|
|
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.
|
|
218
352
|
|
|
219
|
-
|
|
353
|
+
## A header the middleware does not know
|
|
354
|
+
|
|
355
|
+
The middleware sends the twelve headers above and nothing else. Another one —
|
|
220
356
|
`Content-Security-Policy-Report-Only` while a policy is being tried, say —
|
|
221
|
-
is a
|
|
357
|
+
is a middleware of your own, with `settle` and `withHeaders` from
|
|
358
|
+
`@alxia/core`:
|
|
222
359
|
|
|
223
360
|
```ts
|
|
224
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
361
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
225
362
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
226
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
|
+
|
|
227
372
|
const app = alxia()
|
|
228
373
|
.use(secureHeaders())
|
|
229
|
-
.
|
|
230
|
-
withHeaders(response, (headers) => {
|
|
231
|
-
if (!headers.has('content-security-policy-report-only')) {
|
|
232
|
-
headers.set(
|
|
233
|
-
'content-security-policy-report-only',
|
|
234
|
-
"default-src 'self'; report-to csp",
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
}),
|
|
238
|
-
);
|
|
374
|
+
.use(reportOnly);
|
|
239
375
|
```
|
|
240
376
|
|
|
241
|
-
See [
|
|
242
|
-
in the core's guide for what
|
|
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.
|
|
243
379
|
|
|
244
380
|
## Recipes
|
|
245
381
|
|
|
@@ -259,6 +395,8 @@ app.use(
|
|
|
259
395
|
|
|
260
396
|
Or keep the strict default for the API and give the pages their policy on
|
|
261
397
|
their reply, as in [A header a route sets is kept](#a-header-a-route-sets-is-kept).
|
|
398
|
+
When the pages run inline scripts, add `nonce: true` rather than
|
|
399
|
+
`'unsafe-inline'`: [A nonce per request](#a-nonce-per-request).
|
|
262
400
|
|
|
263
401
|
### Framed by your own origin
|
|
264
402
|
|
|
@@ -364,6 +502,6 @@ test('a 404 is covered too', async () => {
|
|
|
364
502
|
|
|
365
503
|
- [Troubleshooting](troubleshooting.md): a browser refuses something, or
|
|
366
504
|
TypeScript does.
|
|
367
|
-
- [
|
|
505
|
+
- [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
|
|
368
506
|
and [Groups and plugins](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md)
|
|
369
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
|
-
|
|
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
|
|
|
@@ -25,10 +29,19 @@ Nothing scheduled yet.
|
|
|
25
29
|
|
|
26
30
|
## Shipped
|
|
27
31
|
|
|
32
|
+
### Next release
|
|
33
|
+
|
|
34
|
+
- **A nonce per request.** `secureHeaders({ nonce: true })` makes a fresh,
|
|
35
|
+
random nonce for each request, adds it to the policy's `script-src`, or
|
|
36
|
+
wherever the policy names `NONCE`, and gives the same one to the routes
|
|
37
|
+
after it as `ctx.nonce`, typed. A page's inline scripts run without
|
|
38
|
+
`'unsafe-inline'`; `@alxia/react-router`'s `nonceOf` hands it to React
|
|
39
|
+
Router. Without the option, nothing changes.
|
|
40
|
+
|
|
28
41
|
### 0.1.0
|
|
29
42
|
|
|
30
43
|
- **Secure headers on every response.** `secureHeaders()` is a plugin for
|
|
31
|
-
`
|
|
44
|
+
`app.plugin` that sends `Content-Security-Policy`, `Strict-Transport-Security`,
|
|
32
45
|
`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
|
33
46
|
`Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
|
|
34
47
|
`Origin-Agent-Cluster`, `X-DNS-Prefetch-Control` and
|