@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/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 another plugin sets
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?: SecureHeadersOptions): Plugin;
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
- `secureHeaders` returns a function `Plugin` from `@alxia/core`: give it to
44
- `use`, called, and the app keeps its type. It adds one global `onResponse`
45
- hook, so where it sits in the chain does not matter for which routes it
46
- reaches: every route, declared before or after it, is covered. Used inside
47
- a `group`, it still covers the whole app — a group's global hooks are the
48
- app's. To vary a header for some routes, set it on their replies
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 plugin touches.
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 hook sent.
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 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
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 plugin reads it.
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
- That is how `@alxia/openapi`'s reference page and `@alxia/graphql`'s IDE
166
- load under the strict default: each sets the `Content-Security-Policy` it
167
- needs, and `secureHeaders` keeps it.
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 plugin runs as an `onResponse` hook, so it reaches what the app's
172
- `onResponse` hooks reach:
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` an `onRequest` hook returned (a CORS preflight, a redirect) | yes |
179
- | a WebSocket upgrade that succeeds (`101`) | no: `onResponse` hooks do not run for it |
180
- | a page served with `page()` | no: `Bun.serve` serves it, around no hook |
181
- | 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 |
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 `onResponse` hooks
310
+ ## Order with other middlewares
192
311
 
193
- `onResponse` hooks run in the order they are declared, and the plugin never
194
- 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:
195
315
 
196
- - a hook declared **before** `secureHeaders` that sets one of its headers
197
- wins;
198
- - a hook declared **after** it that sets one of its headers wins too, by
199
- overwriting;
200
- - a hook declared **after** it that adds `X-Powered-By` or `Server` keeps
201
- 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.
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
- ## 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.
218
352
 
219
- The plugin sends the twelve headers above and nothing else. Another one —
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 hook of your own, with `withHeaders` from `@alxia/core`:
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
- .onResponse((response) =>
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 [Hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md#onresponse)
242
- 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.
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
- - [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)
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
- 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
 
@@ -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
- `use` that sends `Content-Security-Policy`, `Strict-Transport-Security`,
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