@alxia/secure-headers 0.4.9 → 0.5.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,8 +2,9 @@
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` throws only at startup, on options it cannot send; what
6
- goes wrong otherwise is a type, a browser refusing what a header forbids,
5
+ `secureHeaders`, `csp()`, `pageHeaders()` and `clearSiteData()` throw only
6
+ at startup, on options they cannot send; `cacheControl` is an option of
7
+ `secureHeaders`. What goes wrong otherwise is a type, a browser refusing what a header forbids,
7
8
  or a header in the wrong place.
8
9
 
9
10
  **Types**
@@ -14,14 +15,29 @@ or a header in the wrong place.
14
15
  - [`Type 'undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'`](#type-undefined-is-not-assignable-to-type-secureheadersoptions---readonly-nonce-true-)
15
16
  - [`Property 'nonce' does not exist on type 'RouteBase<…>'`](#property-nonce-does-not-exist-on-type-routebase)
16
17
  - [`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)
18
+ - [`Type 'string' is not assignable to type 'CspValue | undefined'`](#type-string-is-not-assignable-to-type-cspvalue--undefined)
19
+ - [`Object literal may only specify known properties, and 'contentSecurityPolicy' does not exist in type 'PageHeadersOptions'`](#object-literal-may-only-specify-known-properties-and-contentsecuritypolicy-does-not-exist-in-type-pageheadersoptions)
20
+ - [`Property 'https' is missing in type '{}' but required in type 'PageHeadersOptions'`](#property-https-is-missing-in-type--but-required-in-type-pageheadersoptions)
21
+ - [`Type '"cash"' is not assignable to type '"cookies" | "*" | "cache" | "storage" | "executionContexts" | "clientHints" | "prefetchCache" | "prerenderCache"'`](#type-cash-is-not-assignable-to-type-cookies----cache--storage--executioncontexts--clienthints--prefetchcache--prerendercache)
17
22
 
18
23
  **Runtime**
19
24
 
20
25
  - [`TypeError: secureHeaders: … is empty; give false to leave the … header out`](#typeerror-secureheaders--is-empty-give-false-to-leave-the--header-out)
21
- - [`use(): argument 1 looks like a factory (secureHeaders): call it, use(secureHeaders())`](#use-argument-1-looks-like-a-factory-secureheaders-call-it-usesecureheaders)
26
+ - [`use(): argument 1 looks like a factory (<name>): call it, use(<name>())`](#use-argument-1-looks-like-a-factory-name-call-it-usename)
22
27
  - [`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
28
  - [`TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`](#typeerror-secureheaders-nonce-is-on-but-contentsecuritypolicy-is-false-)
24
29
  - [`TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`](#typeerror-secureheaders-contentsecuritypolicy-names-nonce-but-nonce-is-off-give-nonce-true)
30
+ - [`TypeError: csp(): <name> has no source: write ["'none'"] to allow nothing, or false to leave it out`](#typeerror-csp-name-has-no-source-write-none-to-allow-nothing-or-false-to-leave-it-out)
31
+ - [`TypeError: csp(): "<name>" is not a directive name: write lowercase letters, digits and hyphens, starting with a letter, as in "script-src"`](#typeerror-csp-name-is-not-a-directive-name-write-lowercase-letters-digits-and-hyphens-starting-with-a-letter-as-in-script-src)
32
+ - [`TypeError: csp(): <name> has the source "<source>", which is empty or holds ";", "," or a space: a source is one token, so split a list into several sources, and write a comma inside a URL as %2C`](#typeerror-csp-name-has-the-source-source-which-is-empty-or-holds---or-a-space-a-source-is-one-token-so-split-a-list-into-several-sources-and-write-a-comma-inside-a-url-as-2c)
33
+ - [`TypeError: csp(): no directive is left: give at least one, such as { "default-src": ["'none'"] }`](#typeerror-csp-no-directive-is-left-give-at-least-one-such-as--default-src-none-)
34
+ - [`TypeError: clearSiteData(): no type is given: write at least one, such as ["cache"]`](#typeerror-clearsitedata-no-type-is-given-write-at-least-one-such-as-cache)
35
+ - [`TypeError: clearSiteData(): "<type>" is not a type: write one of "cache", "cookies", "storage", "executionContexts", "clientHints", "prefetchCache", "prerenderCache", "*"`](#typeerror-clearsitedata-type-is-not-a-type-write-one-of-cache-cookies-storage-executioncontexts-clienthints-prefetchcache-prerendercache-)
36
+ - [`TypeError: pageHeaders(): script-src has no NONCE: a page script runs only with the response's nonce, so write script-src with NONCE in directives, such as ["'self'", NONCE, "'strict-dynamic'"]`](#typeerror-pageheaders-script-src-has-no-nonce-a-page-script-runs-only-with-the-responses-nonce-so-write-script-src-with-nonce-in-directives-such-as-self-nonce-strict-dynamic)
37
+ - [`TypeError: pageHeaders(): script-src-elem has no NONCE: script elements follow it instead of script-src, so add NONCE to it, or give false to remove it`](#typeerror-pageheaders-script-src-elem-has-no-nonce-script-elements-follow-it-instead-of-script-src-so-add-nonce-to-it-or-give-false-to-remove-it)
38
+ - [`TypeError: pageHeaders(): <name> cannot be removed from a page policy: …`](#typeerror-pageheaders-name-cannot-be-removed-from-a-page-policy-)
39
+ - [`TypeError: csp(): <value> in <directive> is a keyword: write it quoted, '<value>'`](#typeerror-csp-value-in-directive-is-a-keyword-write-it-quoted-value)
40
+ - [`TypeError: clearSiteData(): methods is empty or holds an empty method: write at least one, such as ["POST"], or leave methods out`](#typeerror-clearsitedata-methods-is-empty-or-holds-an-empty-method-write-at-least-one-such-as-post-or-leave-methods-out)
25
41
 
26
42
  **In the browser**
27
43
 
@@ -29,6 +45,7 @@ or a header in the wrong place.
29
45
  - [`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
46
  - [`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
47
  - [Inline style attributes stop applying once `style-src` has the nonce](#inline-style-attributes-stop-applying-once-style-src-has-the-nonce)
48
+ - [A third-party script or font is refused by a page policy](#a-third-party-script-or-font-is-refused-by-a-page-policy)
32
49
  - [`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)
33
50
  - [`net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin`](#neterr_blocked_by_responsenotsameorigin)
34
51
  - [`window.opener` is `null` in a sign-in popup](#windowopener-is-null-in-a-sign-in-popup)
@@ -37,11 +54,13 @@ or a header in the wrong place.
37
54
  **Headers**
38
55
 
39
56
  - [An option has no effect on a response](#an-option-has-no-effect-on-a-response)
57
+ - [A CDN still caches a page, though `cacheControl` is set](#a-cdn-still-caches-a-page-though-cachecontrol-is-set)
40
58
  - [`X-Powered-By` or `Server` is still sent](#x-powered-by-or-server-is-still-sent)
41
59
  - [A response arrives without the headers](#a-response-arrives-without-the-headers)
42
60
  - [A 404 or a route outside a group has no headers](#a-404-or-a-route-outside-a-group-has-no-headers)
43
61
  - [A `try`/`catch` middleware's reply has no headers](#a-trycatch-middlewares-reply-has-no-headers)
44
62
  - [A route has no headers, though the others do](#a-route-has-no-headers-though-the-others-do)
63
+ - [`Clear-Site-Data` is missing on a client-side sign-out](#clear-site-data-is-missing-on-a-client-side-sign-out)
45
64
 
46
65
  ## Types
47
66
 
@@ -65,8 +84,9 @@ place of `'true'`.
65
84
 
66
85
  **Why:** a header option is the header's value as a string, or `false` to
67
86
  leave it out (`Setting`, exported, is `string | false`). There is no
68
- `true` — leaving the key out is the default — and no object form for a
69
- policy.
87
+ `true` — leaving the key out is the default. The option takes a string,
88
+ and `csp()` builds that string from directives
89
+ ([guide](guide.md#writing-the-policy-as-directives)).
70
90
 
71
91
  **Fix:** write the header as it goes out, or leave the key out:
72
92
 
@@ -216,6 +236,75 @@ const app = useNonce
216
236
 
217
237
  Or keep it on everywhere: the nonce costs one random draw per request.
218
238
 
239
+ ### `Type 'string' is not assignable to type 'CspValue | undefined'`
240
+
241
+ **When:** a directive of `csp()`, or of `pageHeaders({ directives })`, is given a bare string instead of a list.
242
+
243
+ ```text
244
+ error TS2322: Type 'string' is not assignable to type 'CspValue | undefined'.
245
+ ```
246
+
247
+ **Why:** a directive's value is a list of sources (`readonly string[]`), `true` for a bare directive, or `false` to leave it out. One string would be one source, and a space inside it is refused at startup.
248
+
249
+ **Fix:** write a list, even for one source:
250
+
251
+ ```ts
252
+ csp({ 'script-src': ["'self'"] });
253
+ ```
254
+
255
+ ### `Object literal may only specify known properties, and 'contentSecurityPolicy' does not exist in type 'PageHeadersOptions'`
256
+
257
+ **When:** `pageHeaders()` is given `contentSecurityPolicy`. `'nonce'` gives the same message, with the name changed.
258
+
259
+ ```text
260
+ error TS2353: Object literal may only specify known properties, and 'contentSecurityPolicy' does not exist in type 'PageHeadersOptions'.
261
+ ```
262
+
263
+ **Why:** a page policy is written through `directives`, merged over `PAGE_DIRECTIVES`, and always carries a nonce, so neither `contentSecurityPolicy` nor `nonce` is an option of `pageHeaders()`.
264
+
265
+ **Fix:** use `directives`, and drop `nonce`:
266
+
267
+ ```ts
268
+ pageHeaders({ https: true, directives: { 'img-src': ["'self'", 'https://img.example'] } });
269
+ ```
270
+
271
+ For a whole policy of your own, use `secureHeaders({ nonce: true, contentSecurityPolicy: csp(…) })`.
272
+
273
+ ### `Property 'https' is missing in type '{}' but required in type 'PageHeadersOptions'`
274
+
275
+ **When:** `pageHeaders({})`, or an options object without `https`.
276
+
277
+ ```text
278
+ error TS2345: Argument of type '{}' is not assignable to parameter of type 'PageHeadersOptions'.
279
+ Property 'https' is missing in type '{}' but required in type 'PageHeadersOptions'.
280
+ ```
281
+
282
+ **Why:** `https` switches HSTS and `upgrade-insecure-requests`, and the package never reads the environment to guess it.
283
+
284
+ **Fix:** pass your own flag:
285
+
286
+ ```ts
287
+ pageHeaders({ https: import.meta.env.PROD });
288
+ ```
289
+
290
+ ### `Type '"cash"' is not assignable to type '"cookies" | "*" | "cache" | "storage" | "executionContexts" | "clientHints" | "prefetchCache" | "prerenderCache"'`
291
+
292
+ **When:** `clearSiteData(['cash'])`, a type the header does not know, or a type of `ClearSiteDataType` misspelled.
293
+
294
+ ```text
295
+ error TS2322: Type '"cash"' is not assignable to type '"cookies" | "*" | "cache" | "storage" | "executionContexts" | "clientHints" | "prefetchCache" | "prerenderCache"'.
296
+ ```
297
+
298
+ The union is `ClearSiteDataType`, written out.
299
+
300
+ **Why:** the browser ignores a type it does not know, so the cache would stay as it was without a word.
301
+
302
+ **Fix:** use one of the listed types, in a list:
303
+
304
+ ```ts
305
+ clearSiteData(['cache']);
306
+ ```
307
+
219
308
  ## Runtime
220
309
 
221
310
  ### `TypeError: secureHeaders: … is empty; give false to leave the … header out`
@@ -237,20 +326,21 @@ Only `false` leaves a header out.
237
326
  app.use(secureHeaders({ xFrameOptions: false }));
238
327
  ```
239
328
 
240
- ### `use(): argument 1 looks like a factory (secureHeaders): call it, use(secureHeaders())`
329
+ ### `use(): argument 1 looks like a factory (<name>): call it, use(<name>())`
241
330
 
242
- **When:** `alxia().use(secureHeaders)`, the factory given uncalled, in
243
- JavaScript or past a cast. It throws where the app is declared, since
331
+ **When:** `alxia().use(secureHeaders)`, `use(pageHeaders)` or
332
+ `use(clearSiteData)`, a factory given uncalled, in JavaScript or past a
333
+ cast. `<name>` is the factory's. It throws where the app is declared, since
244
334
  alxia 0.5; before, each request was answered with a 500 and the log said
245
335
  `GET /: a middleware (secureHeaders) returned function: …`. TypeScript
246
336
  refuses it first, with `"this looks like a factory given uncalled: …"` in
247
337
  the error.
248
338
 
249
- **Why:** `use` takes a middleware, a `(ctx, next)` function; `secureHeaders`
250
- makes one, and is marked as a factory, so `use`, a route and `plugin`
251
- refuse it.
339
+ **Why:** `use` takes a middleware, a `(ctx, next)` function; `secureHeaders`,
340
+ `pageHeaders` and `clearSiteData` make one, and are marked as factories, so
341
+ `use`, a route and `plugin` refuse them.
252
342
 
253
- **Fix:**
343
+ **Fix:** call it:
254
344
 
255
345
  ```ts
256
346
  const app = alxia()
@@ -317,6 +407,174 @@ import { NONCE, secureHeaders } from '@alxia/secure-headers';
317
407
  app.use(secureHeaders({ nonce: true, contentSecurityPolicy: `script-src 'self' ${NONCE}` }));
318
408
  ```
319
409
 
410
+ ### `TypeError: csp(): <name> has no source: write ["'none'"] to allow nothing, or false to leave it out`
411
+
412
+ **When:** `csp()` is given an empty array for a directive. It throws at once, so the app fails at startup:
413
+
414
+ ```text
415
+ TypeError: csp(): <name> has no source: write ["'none'"] to allow nothing, or false to leave it out
416
+ ```
417
+
418
+ **Why:** an empty list says nothing a browser can use, and omitting the directive is not the same as forbidding everything.
419
+
420
+ **Fix:**
421
+
422
+ ```ts
423
+ csp({ 'img-src': ["'none'"] }); // allow nothing
424
+ csp({ 'img-src': false }); // leave the directive out
425
+ ```
426
+
427
+ ### `TypeError: csp(): "<name>" is not a directive name: write lowercase letters, digits and hyphens, starting with a letter, as in "script-src"`
428
+
429
+ **When:** a key of the object is not lowercase letters, digits and hyphens starting with a letter, such as `scriptSrc`, `Script-Src` or `-x`. It throws at once, so the app fails at startup:
430
+
431
+ ```text
432
+ TypeError: csp(): "<name>" is not a directive name: write lowercase letters, digits and hyphens, starting with a letter, as in "script-src"
433
+ ```
434
+
435
+ **Why:** directive names are written as in the header; a camel-cased key would name a directive no browser knows, so it is refused, not sent.
436
+
437
+ **Fix:**
438
+
439
+ ```ts
440
+ csp({ 'script-src': ["'self'"] });
441
+ ```
442
+
443
+ ### `TypeError: csp(): <name> has the source "<source>", which is empty or holds ";", "," or a space: a source is one token, so split a list into several sources, and write a comma inside a URL as %2C`
444
+
445
+ **When:** a source is an empty string, or holds `;`, `,` or whitespace, usually a whole list or a second directive in one string. It throws at once, so the app fails at startup:
446
+
447
+ ```text
448
+ TypeError: csp(): <name> has the source "<source>", which is empty or holds ";", "," or a space: a source is one token, so split a list into several sources, and write a comma inside a URL as %2C
449
+ ```
450
+
451
+ **Why:** a `;` would end the directive and start another, so a value could smuggle in a directive; a space would pass two sources as one; a `,` would split the header in two for a proxy. A comma inside a URL is legitimate, so percent-encode it as `%2C`.
452
+
453
+ **Fix:**
454
+
455
+ ```ts
456
+ csp({ 'img-src': ["'self'", 'data:'] }); // not ["'self' data:"]
457
+ ```
458
+
459
+ ### `TypeError: csp(): no directive is left: give at least one, such as { "default-src": ["'none'"] }`
460
+
461
+ **When:** every directive is `false` or `undefined`, or the object is empty. It throws at once, so the app fails at startup:
462
+
463
+ ```text
464
+ TypeError: csp(): no directive is left: give at least one, such as { "default-src": ["'none'"] }
465
+ ```
466
+
467
+ **Why:** an empty policy is no policy; it would be sent as an empty header.
468
+
469
+ **Fix:**
470
+
471
+ ```ts
472
+ csp({ 'default-src': ["'none'"] });
473
+ // or leave the header out: secureHeaders({ contentSecurityPolicy: false })
474
+ ```
475
+
476
+ ### `TypeError: clearSiteData(): no type is given: write at least one, such as ["cache"]`
477
+
478
+ **When:** `clearSiteData([])`, called with no type, throws where the app is declared.
479
+
480
+ **Why:** a `Clear-Site-Data` header with no value tells the browser nothing.
481
+
482
+ **Fix:** name what to clear:
483
+
484
+ ```ts
485
+ app.use('/sign-out', clearSiteData(['cache']));
486
+ ```
487
+
488
+ ### `TypeError: clearSiteData(): "<type>" is not a type: write one of "cache", "cookies", "storage", "executionContexts", "clientHints", "prefetchCache", "prerenderCache", "*"`
489
+
490
+ **When:** a type is misspelled (`'cash'`) or is not one the header knows, past a cast or in JavaScript. TypeScript refuses it first.
491
+
492
+ **Why:** the browser ignores a type it does not know, so the cache would stay as it was without a word.
493
+
494
+ **Fix:** use one of the types the message lists:
495
+
496
+ ```ts
497
+ app.use('/sign-out', clearSiteData(['cache', 'cookies']));
498
+ ```
499
+
500
+ ### `TypeError: pageHeaders(): script-src has no NONCE: a page script runs only with the response's nonce, so write script-src with NONCE in directives, such as ["'self'", NONCE, "'strict-dynamic'"]`
501
+
502
+ **When:** `pageHeaders({ directives: { 'script-src': [...] } })` gives a `script-src` without `NONCE`, or `false`, and the app is declared.
503
+
504
+ **Why:** a page script runs only with the response's nonce. A `script-src` that lacks `NONCE` would leave every script of the page refused, and the page would not hydrate.
505
+
506
+ **Fix:** keep `NONCE` in the list you give, with `'strict-dynamic'` after it:
507
+
508
+ ```ts
509
+ import { NONCE, pageHeaders } from '@alxia/secure-headers';
510
+
511
+ pageHeaders({
512
+ https: true,
513
+ directives: { 'script-src': ["'self'", NONCE, "'strict-dynamic'"] },
514
+ });
515
+ ```
516
+
517
+ ### `TypeError: pageHeaders(): script-src-elem has no NONCE: script elements follow it instead of script-src, so add NONCE to it, or give false to remove it`
518
+
519
+ **When:** `pageHeaders({ directives: { 'script-src-elem': [...] } })` gives a `script-src-elem` list without `NONCE`.
520
+
521
+ **Why:** browsers that support CSP level 3 check `<script>` elements against `script-src-elem` when it is present, and ignore `script-src` for them. A list without `NONCE` would leave every script of the page refused, and the page would not hydrate.
522
+
523
+ **Fix:** add `NONCE` to the list, or give `false` to remove the directive so `script-src` governs again:
524
+
525
+ ```ts
526
+ import { NONCE, pageHeaders } from '@alxia/secure-headers';
527
+
528
+ pageHeaders({
529
+ https: true,
530
+ directives: { 'script-src-elem': ["'self'", NONCE, "'strict-dynamic'"] },
531
+ });
532
+ ```
533
+
534
+ ### `TypeError: pageHeaders(): <name> cannot be removed from a page policy: …`
535
+
536
+ **When:** `pageHeaders({ directives: { 'default-src': false } })`, or `false` or `true` for `base-uri` or `object-src`. The app fails where it is declared.
537
+
538
+ ```text
539
+ TypeError: pageHeaders(): base-uri cannot be removed from a page policy: it has no fallback, so an injected <base> would redirect nonced relative scripts, so give it a list of sources
540
+ ```
541
+
542
+ **Why:** `default-src` covers frame, media and child sources, and `base-uri` has no fallback, so without it an injected `<base>` would redirect the nonced relative scripts. `object-src` blocks plugin content.
543
+
544
+ **Fix:** give the directive a list, or leave it out of `directives` to keep the preset's:
545
+
546
+ ```ts
547
+ pageHeaders({ https: true, directives: { 'base-uri': ["'self'"] } });
548
+ ```
549
+
550
+ ### `TypeError: csp(): <value> in <directive> is a keyword: write it quoted, '<value>'`
551
+
552
+ **When:** a source of `csp()` is `self`, `none`, `unsafe-inline`, `unsafe-eval`, `unsafe-hashes`, `strict-dynamic`, `wasm-unsafe-eval`, `report-sample` or `inline-speculation-rules`, or starts `nonce-`, `sha256-`, `sha384-` or `sha512-`, written without its single quotes (in any case). The same goes for `pageHeaders({ directives })`.
553
+
554
+ ```text
555
+ TypeError: csp(): self in script-src is a keyword: write it quoted, 'self'
556
+ ```
557
+
558
+ **Why:** a browser reads `self` as a host called `self`, not as this site, and ignores the keyword it meant. The policy would be looser or stricter than written, without a word.
559
+
560
+ **Fix:** quote it inside the string:
561
+
562
+ ```ts
563
+ csp({ 'script-src': ["'self'", NONCE] });
564
+ ```
565
+
566
+ ### `TypeError: clearSiteData(): methods is empty or holds an empty method: write at least one, such as ["POST"], or leave methods out`
567
+
568
+ **When:** `clearSiteData(['cache'], { methods: [] })`, or a `methods` list that holds `''`. It throws where the app is declared.
569
+
570
+ **Why:** with no method, the middleware would never send the header, and the sign-out would leave the cache as it was.
571
+
572
+ **Fix:** name the methods, or leave `methods` out for `['POST']`:
573
+
574
+ ```ts
575
+ clearSiteData(['cache'], { methods: ['POST', 'DELETE'] });
576
+ ```
577
+
320
578
  ## In the browser
321
579
 
322
580
  These messages are Chrome's wording; Firefox and Safari report the same
@@ -409,6 +667,26 @@ directive of their own:
409
667
  contentSecurityPolicy: `default-src 'self'; script-src 'self'; style-src-elem 'self' ${NONCE}; style-src-attr 'unsafe-inline'`,
410
668
  ```
411
669
 
670
+ ### A third-party script or font is refused by a page policy
671
+
672
+ **When:** a page behind `pageHeaders()` loads a script, a stylesheet or a font from another origin, and the console reads `Refused to load the script 'https://…' because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…' 'strict-dynamic'"` (or `font-src 'self'`).
673
+
674
+ **Why:** `PAGE_DIRECTIVES` allows this site only. A script another script adds is allowed through `'strict-dynamic'`; one named in the HTML needs the nonce, and a font needs its origin in `font-src`.
675
+
676
+ **Fix:** extend the directive that loads it through `directives`, which replaces that directive's list, so repeat what it already holds:
677
+
678
+ ```ts
679
+ pageHeaders({
680
+ https: true,
681
+ directives: {
682
+ 'font-src': ["'self'", 'https://fonts.example'],
683
+ 'style-src-elem': ["'self'", NONCE, 'https://fonts.example'],
684
+ },
685
+ });
686
+ ```
687
+
688
+ Never put the origin in `default-src`. Under `'strict-dynamic'` a third-party script is loaded by a nonced script, not by allowlisting its host: a script tag from another origin takes the page's `nonce` attribute, and a host in `script-src` is ignored by browsers that support `'strict-dynamic'`. To allow a host for images, extend `img-src` the same way, as in `directives: { 'img-src': ["'self'", 'https://img.example'] }`.
689
+
412
690
  ### `Refused to frame '…' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'"`
413
691
 
414
692
  **When:** a page puts a response of the app in an `<iframe>`. With the
@@ -501,6 +779,34 @@ const response = await app.request('/the-route');
501
779
  console.log(response.headers.get('content-security-policy'));
502
780
  ```
503
781
 
782
+ ### A CDN still caches a page, though `cacheControl` is set
783
+
784
+ **When:** `cacheControl: 'private, no-store'` is set, and some pages are still
785
+ stored by a cache.
786
+
787
+ **Why:** a route that sets its own `Cache-Control` (a `reply` with
788
+ `cache-control: public, max-age=…`, or a middleware after `secureHeaders`)
789
+ keeps it: the option only fills a header the response lacks.
790
+
791
+ **Fix:** drop the route's header, or give it the same value. Before, the
792
+ route's own header wins:
793
+
794
+ ```ts
795
+ app.get('/the-page', ({ reply }) =>
796
+ reply(200, html, { headers: { 'cache-control': 'public, max-age=60' } }),
797
+ );
798
+ ```
799
+
800
+ After, the route leaves it to `secureHeaders` (or says `private, no-store`
801
+ itself):
802
+
803
+ ```ts
804
+ app.get('/the-page', ({ reply }) => reply(200, html));
805
+
806
+ const response = await app.request('/the-page');
807
+ console.log(response.headers.get('cache-control')); // private, no-store
808
+ ```
809
+
504
810
  ### `X-Powered-By` or `Server` is still sent
505
811
 
506
812
  **When:** `hidePoweredBy` is on (the default), and one of them is on the
@@ -635,3 +941,19 @@ const app = alxia()
635
941
  .use(secureHeaders())
636
942
  .get('/early', ({ reply }) => reply(200, 'now covered'));
637
943
  ```
944
+
945
+ ### `Clear-Site-Data` is missing on a client-side sign-out
946
+
947
+ **When:** a form sign-out empties the cache, but a client-side one (the button, a navigation) does not. `Clear-Site-Data` is on the response of `POST /sign-out` and absent from `POST /sign-out.data`.
948
+
949
+ **Why:** React Router's single fetch posts the route's `.data` URL, a path `use('/sign-out', …)` does not cover. A route cannot set the header there either: that answer carries a redirect's cookies, not its other headers.
950
+
951
+ **Fix:** mount the middleware on both paths:
952
+
953
+ ```ts
954
+ const cleared = clearSiteData(['cache']);
955
+
956
+ const app = alxia()
957
+ .use('/sign-out', cleared)
958
+ .use('/sign-out.data', cleared);
959
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/secure-headers",
3
- "version": "0.4.9",
3
+ "version": "0.5.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,12 +41,12 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/core": "^0.14.0",
44
+ "@alxia/core": "^0.15.1",
45
45
  "@types/bun": "^1.4.2",
46
46
  "zod": "^4.2.0"
47
47
  },
48
48
  "peerDependencies": {
49
- "@alxia/core": "^0.14.0",
49
+ "@alxia/core": "^0.15.1",
50
50
  "typescript": "^6.0.3 || ^7.0.0"
51
51
  }
52
52
  }