@alxia/secure-headers 0.1.1 → 0.2.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 +55 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +66 -7
- package/dist/index.js.map +5 -4
- package/dist/nonce.d.ts +41 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/secure-headers.d.ts +22 -2
- package/dist/secure-headers.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide.md +121 -4
- package/docs/roadmap.md +9 -0
- package/docs/troubleshooting.md +193 -16
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -21,6 +21,54 @@ app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'", xFrameOptio
|
|
|
21
21
|
A header a route sets itself is kept: a page that needs its own policy sets
|
|
22
22
|
it on its reply.
|
|
23
23
|
|
|
24
|
+
## A nonce per request
|
|
25
|
+
|
|
26
|
+
`nonce: true` makes a fresh nonce for each request (128 random bits,
|
|
27
|
+
base64), puts it in the policy as `'nonce-…'`, and gives it to the routes
|
|
28
|
+
declared after the plugin as `ctx.nonce`. The header and the context always
|
|
29
|
+
carry the same one, so a page's inline scripts need no `'unsafe-inline'`.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { alxia } from '@alxia/core';
|
|
33
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
34
|
+
|
|
35
|
+
const app = alxia()
|
|
36
|
+
.use(
|
|
37
|
+
secureHeaders({
|
|
38
|
+
nonce: true,
|
|
39
|
+
// The nonce is added to script-src (and script-src-elem).
|
|
40
|
+
contentSecurityPolicy: "default-src 'self'; script-src 'self'",
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
.get('/', ({ nonce, reply }) =>
|
|
44
|
+
reply(200, `<script nonce="${nonce}">console.log('allowed')</script>`, {
|
|
45
|
+
headers: { 'content-type': 'text/html' },
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
// content-security-policy: default-src 'self'; script-src 'self' 'nonce-3q2+7w…=='
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
To place it yourself, in `style-src` say, write `NONCE` where it goes;
|
|
52
|
+
each occurrence becomes the request's `'nonce-…'`, and `script-src` is then
|
|
53
|
+
left as written:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { NONCE, secureHeaders } from '@alxia/secure-headers';
|
|
57
|
+
|
|
58
|
+
app.use(
|
|
59
|
+
secureHeaders({
|
|
60
|
+
nonce: true,
|
|
61
|
+
contentSecurityPolicy: `default-src 'self'; script-src 'self' ${NONCE}; style-src 'self' ${NONCE}`,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A policy with nowhere to put the nonce (no `script-src`, no `NONCE`) is
|
|
67
|
+
refused at startup, the default policy included: give `contentSecurityPolicy`
|
|
68
|
+
with `nonce: true`. Without `nonce: true`, the headers are what they always
|
|
69
|
+
were. With `@alxia/react-router`, `nonceOf(loadContext)` hands it to
|
|
70
|
+
`entry.server.tsx` ([its README](https://github.com/softistx/alxia/tree/develop/packages/react-router#a-csp-nonce)).
|
|
71
|
+
|
|
24
72
|
## Defaults
|
|
25
73
|
|
|
26
74
|
| header | default | option |
|
|
@@ -39,19 +87,22 @@ it on its reply.
|
|
|
39
87
|
| `Permissions-Policy` | not sent | `permissionsPolicy` |
|
|
40
88
|
|
|
41
89
|
Each option takes a value, or `false` to leave the header out; an empty
|
|
42
|
-
value is refused at startup.
|
|
90
|
+
value is refused at startup. `nonce` is off by default.
|
|
43
91
|
`X-Powered-By` and `Server` are removed unless `hidePoweredBy: false`.
|
|
44
92
|
|
|
45
93
|
## API
|
|
46
94
|
|
|
47
95
|
| export | |
|
|
48
96
|
| --- | --- |
|
|
49
|
-
| `secureHeaders(options?)` | the plugin |
|
|
50
|
-
| `SecureHeadersOptions` | its options |
|
|
97
|
+
| `secureHeaders(options?)` | the plugin: a function `Plugin`, or with `nonce: true` a `NoncePlugin` |
|
|
98
|
+
| `SecureHeadersOptions` | its options: the headers and `hidePoweredBy`; `nonce` is added by each overload |
|
|
51
99
|
| `Setting` | a header option's type: its value, or `false` to leave it out |
|
|
100
|
+
| `NONCE` | where the nonce goes in `contentSecurityPolicy`, each time it is named |
|
|
101
|
+
| `NonceContext` | what `nonce: true` adds to the context: `nonce`, a string |
|
|
102
|
+
| `NoncePlugin` | what `secureHeaders({ nonce: true })` returns: an app plugin adding `NonceContext` |
|
|
52
103
|
|
|
53
104
|
## Documentation
|
|
54
105
|
|
|
55
|
-
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/secure-headers/docs): every option and its default, which responses get the headers, a page's own policy, the order with other hooks, and recipes.
|
|
106
|
+
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/secure-headers/docs): every option and its default, the per-request nonce, which responses get the headers, a page's own policy, the order with other hooks, and recipes.
|
|
56
107
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/secure-headers/docs/troubleshooting.md): an error message or a browser refusal, and what to do about it.
|
|
57
108
|
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/secure-headers/docs/roadmap.md): what is coming, and what is not planned.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { NONCE, type NonceContext } from './nonce';
|
|
2
|
+
export { type NoncePlugin, type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
|
|
2
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
|
+
// src/nonce.ts
|
|
2
|
+
var NONCE = "'nonce-{alxia}'";
|
|
3
|
+
var SCRIPT_DIRECTIVES = new Set(["script-src", "script-src-elem"]);
|
|
4
|
+
function policyWithNonce(policy) {
|
|
5
|
+
const placed = policy.includes(NONCE) ? policy : policy.split(";").map((directive) => {
|
|
6
|
+
const name = directive.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
7
|
+
return SCRIPT_DIRECTIVES.has(name) ? `${directive.trimEnd()} ${NONCE}` : directive;
|
|
8
|
+
}).join(";");
|
|
9
|
+
if (!placed.includes(NONCE)) {
|
|
10
|
+
throw new 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 "${policy}")`);
|
|
11
|
+
}
|
|
12
|
+
const parts = placed.split(NONCE);
|
|
13
|
+
return (nonce) => parts.join(`'nonce-${nonce}'`);
|
|
14
|
+
}
|
|
15
|
+
function freshNonce() {
|
|
16
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
17
|
+
return btoa(String.fromCharCode(...bytes));
|
|
18
|
+
}
|
|
19
|
+
function nonceStore() {
|
|
20
|
+
const nonces = new WeakMap;
|
|
21
|
+
return (url) => {
|
|
22
|
+
let nonce = nonces.get(url);
|
|
23
|
+
if (nonce === undefined) {
|
|
24
|
+
nonce = freshNonce();
|
|
25
|
+
nonces.set(url, nonce);
|
|
26
|
+
}
|
|
27
|
+
return nonce;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
1
30
|
// src/secure-headers.ts
|
|
2
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
definePlugin,
|
|
33
|
+
withHeaders
|
|
34
|
+
} from "@alxia/core";
|
|
3
35
|
var DEFAULTS = {
|
|
4
36
|
"content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
5
37
|
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
|
@@ -29,7 +61,28 @@ var OPTION = {
|
|
|
29
61
|
"permissions-policy": "permissionsPolicy"
|
|
30
62
|
};
|
|
31
63
|
function secureHeaders(options = {}) {
|
|
32
|
-
const headers =
|
|
64
|
+
const headers = fixedHeaders(options);
|
|
65
|
+
const hide = options.hidePoweredBy ?? true;
|
|
66
|
+
const policy = headers.get("content-security-policy");
|
|
67
|
+
if (!options.nonce) {
|
|
68
|
+
if (policy?.includes(NONCE)) {
|
|
69
|
+
throw new TypeError("secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true");
|
|
70
|
+
}
|
|
71
|
+
const set = setter(headers, hide);
|
|
72
|
+
return (app) => app.onResponse((response) => set(response));
|
|
73
|
+
}
|
|
74
|
+
if (policy === undefined) {
|
|
75
|
+
throw new TypeError("secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header");
|
|
76
|
+
}
|
|
77
|
+
headers.delete("content-security-policy");
|
|
78
|
+
const withNonce = policyWithNonce(policy);
|
|
79
|
+
const nonceOf = nonceStore();
|
|
80
|
+
const set = setter(headers, hide);
|
|
81
|
+
const plugin = definePlugin()((app) => app.onResponse((response, ctx) => set(response, withNonce(nonceOf(ctx.url)))).derive(({ url }) => ({ nonce: nonceOf(url) })));
|
|
82
|
+
return plugin;
|
|
83
|
+
}
|
|
84
|
+
function fixedHeaders(options) {
|
|
85
|
+
const headers = new Map;
|
|
33
86
|
for (const [name, fallback] of Object.entries(DEFAULTS)) {
|
|
34
87
|
const setting = options[OPTION[name]];
|
|
35
88
|
if (typeof setting === "string" && setting.trim() === "") {
|
|
@@ -37,23 +90,29 @@ function secureHeaders(options = {}) {
|
|
|
37
90
|
}
|
|
38
91
|
const value = setting === undefined ? fallback : setting;
|
|
39
92
|
if (typeof value === "string")
|
|
40
|
-
headers.
|
|
93
|
+
headers.set(name, value);
|
|
41
94
|
}
|
|
42
|
-
|
|
43
|
-
|
|
95
|
+
return headers;
|
|
96
|
+
}
|
|
97
|
+
function setter(headers, hide) {
|
|
98
|
+
return (response, policy) => withHeaders(response, (current) => {
|
|
44
99
|
for (const [name, value] of headers) {
|
|
45
100
|
if (!current.has(name))
|
|
46
101
|
current.set(name, value);
|
|
47
102
|
}
|
|
103
|
+
if (policy !== undefined && !current.has("content-security-policy")) {
|
|
104
|
+
current.set("content-security-policy", policy);
|
|
105
|
+
}
|
|
48
106
|
if (hide) {
|
|
49
107
|
current.delete("x-powered-by");
|
|
50
108
|
current.delete("server");
|
|
51
109
|
}
|
|
52
|
-
})
|
|
110
|
+
});
|
|
53
111
|
}
|
|
54
112
|
export {
|
|
113
|
+
NONCE,
|
|
55
114
|
secureHeaders
|
|
56
115
|
};
|
|
57
116
|
|
|
58
|
-
//# debugId=
|
|
117
|
+
//# debugId=3F73DEF9D20E37B064756E2164756E21
|
|
59
118
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/secure-headers.ts"],
|
|
3
|
+
"sources": ["../src/nonce.ts", "../src/secure-headers.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"
|
|
5
|
+
"/**\n * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes\n * in the policy, how one is made, and how the context and the header read\n * the same one.\n */\n\n/**\n * Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes\n * `'nonce-<value>'`, the request's own value. Without it, `nonce: true`\n * adds the nonce to `script-src` and `script-src-elem`.\n *\n * ```ts\n * secureHeaders({\n * nonce: true,\n * contentSecurityPolicy: `default-src 'self'; style-src 'self' ${NONCE}`,\n * });\n * ```\n */\nexport const NONCE = \"'nonce-{alxia}'\";\n\n/** What `secureHeaders({ nonce: true })` adds to the context of the routes after it. */\nexport interface NonceContext {\n\t/**\n\t * This request's nonce, base64, the one its `Content-Security-Policy`\n\t * allows: what a page puts on its `<script nonce>`.\n\t */\n\treadonly nonce: string;\n}\n\n/** The directives the nonce is added to when the policy does not place it. */\nconst SCRIPT_DIRECTIVES = new Set(['script-src', 'script-src-elem']);\n\n/**\n * The policy as a function of the nonce: split where `NONCE` stands, or\n * where it is added to the script directives. Throws when there is\n * nowhere to put it.\n */\nexport function policyWithNonce(policy: string): (nonce: string) => string {\n\tconst placed = policy.includes(NONCE)\n\t\t? policy\n\t\t: policy\n\t\t\t\t.split(';')\n\t\t\t\t.map((directive) => {\n\t\t\t\t\tconst name = directive.trim().split(/\\s+/)[0]?.toLowerCase() ?? '';\n\t\t\t\t\treturn SCRIPT_DIRECTIVES.has(name)\n\t\t\t\t\t\t? `${directive.trimEnd()} ${NONCE}`\n\t\t\t\t\t\t: directive;\n\t\t\t\t})\n\t\t\t\t.join(';');\n\tif (!placed.includes(NONCE)) {\n\t\tthrow new TypeError(\n\t\t\t`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 \"${policy}\")`,\n\t\t);\n\t}\n\tconst parts = placed.split(NONCE);\n\treturn (nonce) => parts.join(`'nonce-${nonce}'`);\n}\n\n/** 128 random bits, base64: a fresh nonce. */\nexport function freshNonce(): string {\n\tconst bytes = crypto.getRandomValues(new Uint8Array(16));\n\treturn btoa(String.fromCharCode(...bytes));\n}\n\n/**\n * One nonce per request, made when first asked for. Keyed by the request's\n * `URL`: the one object every hook and the route of a request share, where\n * a `bodyLimit` hands the route a `Request` of its own.\n */\nexport function nonceStore(): (url: URL) => string {\n\tconst nonces = new WeakMap<URL, string>();\n\treturn (url) => {\n\t\tlet nonce = nonces.get(url);\n\t\tif (nonce === undefined) {\n\t\t\tnonce = freshNonce();\n\t\t\tnonces.set(url, nonce);\n\t\t}\n\t\treturn nonce;\n\t};\n}\n",
|
|
6
|
+
"import {\n\ttype Alxia,\n\tdefinePlugin,\n\ttype Empty,\n\ttype Plugin,\n\ttype Requiring,\n\twithHeaders,\n} from '@alxia/core';\nimport { NONCE, type NonceContext, nonceStore, policyWithNonce } from './nonce';\n\n/** A header's value, or `false` to leave it out. An empty value is refused. */\nexport type Setting = string | false;\n\nexport interface SecureHeadersOptions {\n\t/** Defaults to a policy for an API: nothing loads, nothing frames it. */\n\treadonly contentSecurityPolicy?: Setting;\n\treadonly strictTransportSecurity?: Setting;\n\treadonly xContentTypeOptions?: Setting;\n\treadonly xFrameOptions?: Setting;\n\treadonly referrerPolicy?: Setting;\n\treadonly crossOriginOpenerPolicy?: Setting;\n\treadonly crossOriginResourcePolicy?: Setting;\n\treadonly crossOriginEmbedderPolicy?: Setting;\n\treadonly originAgentCluster?: Setting;\n\treadonly xDnsPrefetchControl?: Setting;\n\treadonly xPermittedCrossDomainPolicies?: Setting;\n\t/** Off by default: a policy is the app's to write. */\n\treadonly permissionsPolicy?: Setting;\n\t/** Whether `X-Powered-By` and `Server` are removed. On by default. */\n\treadonly hidePoweredBy?: boolean;\n}\n\n/**\n * The nonce switch, beside `SecureHeadersOptions` rather than in it, so that\n * options typed by that interface still pick the plugin without a nonce.\n */\ninterface NonceOption {\n\t/**\n\t * A fresh nonce for each request, in `contentSecurityPolicy` and on the\n\t * context as `nonce`. Placed where the policy names `NONCE`, or added to\n\t * its `script-src`. Off by default.\n\t */\n\treadonly nonce?: boolean;\n}\n\n/**\n * What `secureHeaders({ nonce: true })` returns: an app plugin, whose routes\n * after it read `nonce`, and whose hook sets the header with the same one.\n */\nexport type NoncePlugin = Alxia<NonceContext, Empty, '', never> &\n\tRequiring<Empty>;\n\nconst DEFAULTS = {\n\t'content-security-policy':\n\t\t\"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n\t'strict-transport-security': 'max-age=31536000; includeSubDomains',\n\t'x-content-type-options': 'nosniff',\n\t'x-frame-options': 'DENY',\n\t'referrer-policy': 'no-referrer',\n\t'cross-origin-opener-policy': 'same-origin',\n\t'cross-origin-resource-policy': 'same-origin',\n\t'cross-origin-embedder-policy': false,\n\t'origin-agent-cluster': '?1',\n\t'x-dns-prefetch-control': 'off',\n\t'x-permitted-cross-domain-policies': 'none',\n\t'permissions-policy': false,\n} as const satisfies Record<string, Setting>;\n\nconst OPTION: Record<keyof typeof DEFAULTS, keyof SecureHeadersOptions> = {\n\t'content-security-policy': 'contentSecurityPolicy',\n\t'strict-transport-security': 'strictTransportSecurity',\n\t'x-content-type-options': 'xContentTypeOptions',\n\t'x-frame-options': 'xFrameOptions',\n\t'referrer-policy': 'referrerPolicy',\n\t'cross-origin-opener-policy': 'crossOriginOpenerPolicy',\n\t'cross-origin-resource-policy': 'crossOriginResourcePolicy',\n\t'cross-origin-embedder-policy': 'crossOriginEmbedderPolicy',\n\t'origin-agent-cluster': 'originAgentCluster',\n\t'x-dns-prefetch-control': 'xDnsPrefetchControl',\n\t'x-permitted-cross-domain-policies': 'xPermittedCrossDomainPolicies',\n\t'permissions-policy': 'permissionsPolicy',\n};\n\n/**\n * Secure headers on every response, as a plugin. A header a route set\n * itself is kept: a page that needs its own `Content-Security-Policy` —\n * `@alxia/openapi`'s reference page — sets it.\n *\n * ```ts\n * app.use(secureHeaders({ contentSecurityPolicy: \"default-src 'self'\" }));\n * ```\n *\n * With `nonce: true`, each request gets a nonce of its own, in the policy\n * and on the context of the routes declared after it:\n *\n * ```ts\n * app\n * .use(secureHeaders({ nonce: true, contentSecurityPolicy: \"script-src 'self'\" }))\n * .get('/', ({ nonce, reply }) => reply(200, `<script nonce=\"${nonce}\">…</script>`));\n * ```\n */\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & { readonly nonce: true },\n): NoncePlugin;\nexport function secureHeaders(\n\toptions?: SecureHeadersOptions & { readonly nonce?: false },\n): Plugin;\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & NonceOption = {},\n): Plugin | NoncePlugin {\n\tconst headers = fixedHeaders(options);\n\tconst hide = options.hidePoweredBy ?? true;\n\tconst policy = headers.get('content-security-policy');\n\tif (!options.nonce) {\n\t\tif (policy?.includes(NONCE)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true',\n\t\t\t);\n\t\t}\n\t\tconst set = setter(headers, hide);\n\t\treturn (app) => app.onResponse((response) => set(response));\n\t}\n\tif (policy === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header',\n\t\t);\n\t}\n\theaders.delete('content-security-policy');\n\tconst withNonce = policyWithNonce(policy);\n\tconst nonceOf = nonceStore();\n\tconst set = setter(headers, hide);\n\tconst plugin: NoncePlugin = definePlugin()((app) =>\n\t\tapp\n\t\t\t.onResponse((response, ctx) => set(response, withNonce(nonceOf(ctx.url))))\n\t\t\t.derive(({ url }): NonceContext => ({ nonce: nonceOf(url) })),\n\t);\n\treturn plugin;\n}\n\n/** Each header the options leave in, by name, with its value. Throws on an empty one. */\nfunction fixedHeaders(options: SecureHeadersOptions): Map<string, string> {\n\tconst headers = new Map<string, string>();\n\tfor (const [name, fallback] of Object.entries(DEFAULTS) as [\n\t\tkeyof typeof DEFAULTS,\n\t\tSetting,\n\t][]) {\n\t\tconst setting = options[OPTION[name]];\n\t\tif (typeof setting === 'string' && setting.trim() === '') {\n\t\t\t// An empty header says nothing a browser can act on: leave it out.\n\t\t\tthrow new TypeError(\n\t\t\t\t`secureHeaders: ${OPTION[name]} is empty; give false to leave the ${name} header out`,\n\t\t\t);\n\t\t}\n\t\tconst value = setting === undefined ? fallback : setting;\n\t\tif (typeof value === 'string') headers.set(name, value);\n\t}\n\treturn headers;\n}\n\n/** The `onResponse` hook: every header the response lacks, then the policy given per request. */\nfunction setter(headers: ReadonlyMap<string, string>, hide: boolean) {\n\treturn (response: Response, policy?: string) =>\n\t\twithHeaders(response, (current) => {\n\t\t\tfor (const [name, value] of headers) {\n\t\t\t\tif (!current.has(name)) current.set(name, value);\n\t\t\t}\n\t\t\tif (policy !== undefined && !current.has('content-security-policy')) {\n\t\t\t\tcurrent.set('content-security-policy', policy);\n\t\t\t}\n\t\t\tif (hide) {\n\t\t\t\tcurrent.delete('x-powered-by');\n\t\t\t\tcurrent.delete('server');\n\t\t\t}\n\t\t});\n}\n"
|
|
6
7
|
],
|
|
7
|
-
"mappings": ";AAAA;
|
|
8
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "3F73DEF9D20E37B064756E2164756E21",
|
|
9
10
|
"names": []
|
|
10
11
|
}
|
package/dist/nonce.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-request nonce of `secureHeaders({ nonce: true })`: where it goes
|
|
3
|
+
* in the policy, how one is made, and how the context and the header read
|
|
4
|
+
* the same one.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes
|
|
8
|
+
* `'nonce-<value>'`, the request's own value. Without it, `nonce: true`
|
|
9
|
+
* adds the nonce to `script-src` and `script-src-elem`.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* secureHeaders({
|
|
13
|
+
* nonce: true,
|
|
14
|
+
* contentSecurityPolicy: `default-src 'self'; style-src 'self' ${NONCE}`,
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare const NONCE = "'nonce-{alxia}'";
|
|
19
|
+
/** What `secureHeaders({ nonce: true })` adds to the context of the routes after it. */
|
|
20
|
+
export interface NonceContext {
|
|
21
|
+
/**
|
|
22
|
+
* This request's nonce, base64, the one its `Content-Security-Policy`
|
|
23
|
+
* allows: what a page puts on its `<script nonce>`.
|
|
24
|
+
*/
|
|
25
|
+
readonly nonce: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The policy as a function of the nonce: split where `NONCE` stands, or
|
|
29
|
+
* where it is added to the script directives. Throws when there is
|
|
30
|
+
* nowhere to put it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function policyWithNonce(policy: string): (nonce: string) => string;
|
|
33
|
+
/** 128 random bits, base64: a fresh nonce. */
|
|
34
|
+
export declare function freshNonce(): string;
|
|
35
|
+
/**
|
|
36
|
+
* One nonce per request, made when first asked for. Keyed by the request's
|
|
37
|
+
* `URL`: the one object every hook and the route of a request share, where
|
|
38
|
+
* a `bodyLimit` hands the route a `Request` of its own.
|
|
39
|
+
*/
|
|
40
|
+
export declare function nonceStore(): (url: URL) => string;
|
|
41
|
+
//# sourceMappingURL=nonce.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,KAAK,oBAAoB,CAAC;AAEvC,wFAAwF;AACxF,MAAM,WAAW,YAAY;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAKD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAmBzE;AAED,8CAA8C;AAC9C,wBAAgB,UAAU,IAAI,MAAM,CAGnC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,MAAM,CAUjD"}
|
package/dist/secure-headers.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type Plugin } from '@alxia/core';
|
|
1
|
+
import { type Alxia, type Empty, type Plugin, type Requiring } from '@alxia/core';
|
|
2
|
+
import { type NonceContext } from './nonce';
|
|
2
3
|
/** A header's value, or `false` to leave it out. An empty value is refused. */
|
|
3
4
|
export type Setting = string | false;
|
|
4
5
|
export interface SecureHeadersOptions {
|
|
@@ -19,6 +20,11 @@ export interface SecureHeadersOptions {
|
|
|
19
20
|
/** Whether `X-Powered-By` and `Server` are removed. On by default. */
|
|
20
21
|
readonly hidePoweredBy?: boolean;
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* What `secureHeaders({ nonce: true })` returns: an app plugin, whose routes
|
|
25
|
+
* after it read `nonce`, and whose hook sets the header with the same one.
|
|
26
|
+
*/
|
|
27
|
+
export type NoncePlugin = Alxia<NonceContext, Empty, '', never> & Requiring<Empty>;
|
|
22
28
|
/**
|
|
23
29
|
* Secure headers on every response, as a plugin. A header a route set
|
|
24
30
|
* itself is kept: a page that needs its own `Content-Security-Policy` —
|
|
@@ -27,6 +33,20 @@ export interface SecureHeadersOptions {
|
|
|
27
33
|
* ```ts
|
|
28
34
|
* app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'" }));
|
|
29
35
|
* ```
|
|
36
|
+
*
|
|
37
|
+
* With `nonce: true`, each request gets a nonce of its own, in the policy
|
|
38
|
+
* and on the context of the routes declared after it:
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* app
|
|
42
|
+
* .use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
|
|
43
|
+
* .get('/', ({ nonce, reply }) => reply(200, `<script nonce="${nonce}">…</script>`));
|
|
44
|
+
* ```
|
|
30
45
|
*/
|
|
31
|
-
export declare function secureHeaders(options
|
|
46
|
+
export declare function secureHeaders(options: SecureHeadersOptions & {
|
|
47
|
+
readonly nonce: true;
|
|
48
|
+
}): NoncePlugin;
|
|
49
|
+
export declare function secureHeaders(options?: SecureHeadersOptions & {
|
|
50
|
+
readonly nonce?: false;
|
|
51
|
+
}): Plugin;
|
|
32
52
|
//# sourceMappingURL=secure-headers.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,KAAK,EAEV,KAAK,KAAK,EACV,KAAK,MAAM,EACX,KAAK,SAAS,EAEd,MAAM,aAAa,CAAC;AACrB,OAAO,EAAS,KAAK,YAAY,EAA+B,MAAM,SAAS,CAAC;AAEhF,+EAA+E;AAC/E,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;AAErC,MAAM,WAAW,oBAAoB;IACpC,yEAAyE;IACzE,QAAQ,CAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IACzC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAClC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACjD,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC,sEAAsE;IACtE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CACjC;AAeD;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,KAAK,CAAC,YAAY,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,CAAC,GAC9D,SAAS,CAAC,KAAK,CAAC,CAAC;AAiClB;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GACtD,WAAW,CAAC;AACf,wBAAgB,aAAa,CAC5B,OAAO,CAAC,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACzD,MAAM,CAAC"}
|
package/docs/README.md
CHANGED
|
@@ -6,6 +6,6 @@ headers, and what to do when a browser refuses what they forbid.
|
|
|
6
6
|
|
|
7
7
|
| Page | Read it when |
|
|
8
8
|
| --- | --- |
|
|
9
|
-
| [Guide](guide.md) | changing or dropping a header, giving a page its own policy, ordering it with other hooks, or testing the headers |
|
|
9
|
+
| [Guide](guide.md) | changing or dropping a header, a nonce per request, giving a page its own policy, ordering it with other hooks, or testing the headers |
|
|
10
10
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message: a TypeScript error, a browser refusal, a header that is or is not there |
|
|
11
11
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
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 plugin 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 }): NoncePlugin;
|
|
25
|
+
function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }): Plugin;
|
|
25
26
|
|
|
26
27
|
interface SecureHeadersOptions {
|
|
27
28
|
readonly contentSecurityPolicy?: string | false;
|
|
@@ -40,6 +41,9 @@ interface SecureHeadersOptions {
|
|
|
40
41
|
}
|
|
41
42
|
```
|
|
42
43
|
|
|
44
|
+
`nonce` is not in `SecureHeadersOptions`: each overload adds it, so options
|
|
45
|
+
typed by that interface still give the plain `Plugin`.
|
|
46
|
+
|
|
43
47
|
`secureHeaders` returns a function `Plugin` from `@alxia/core`: give it to
|
|
44
48
|
`use`, called, and the app keeps its type. It adds one global `onResponse`
|
|
45
49
|
hook, so where it sits in the chain does not matter for which routes it
|
|
@@ -49,7 +53,13 @@ app's. To vary a header for some routes, set it on their replies
|
|
|
49
53
|
([below](#a-header-a-route-sets-is-kept)).
|
|
50
54
|
|
|
51
55
|
The options are read once, when `secureHeaders(…)` is called; the header
|
|
52
|
-
values are fixed from then on
|
|
56
|
+
values are fixed from then on, but for the nonce, which is new on every
|
|
57
|
+
request.
|
|
58
|
+
|
|
59
|
+
With `nonce: true` it returns a `NoncePlugin` instead: an app plugin, still
|
|
60
|
+
given to `use` called, whose hook covers every route as above, and which
|
|
61
|
+
adds `nonce` to the context of the routes declared after it
|
|
62
|
+
([below](#a-nonce-per-request)).
|
|
53
63
|
|
|
54
64
|
## The options
|
|
55
65
|
|
|
@@ -74,6 +84,7 @@ leave the header out. An option left out keeps the default.
|
|
|
74
84
|
| Option | Type | Default | Effect |
|
|
75
85
|
| --- | --- | --- | --- |
|
|
76
86
|
| `hidePoweredBy` | `boolean` | `true` | removes `X-Powered-By` and `Server` from every response, a route's own included |
|
|
87
|
+
| `nonce` | `boolean` | `false` | a fresh nonce per request, in the policy and on the context: [A nonce per request](#a-nonce-per-request) |
|
|
77
88
|
|
|
78
89
|
### A value
|
|
79
90
|
|
|
@@ -166,6 +177,110 @@ That is how `@alxia/openapi`'s reference page and `@alxia/graphql`'s IDE
|
|
|
166
177
|
load under the strict default: each sets the `Content-Security-Policy` it
|
|
167
178
|
needs, and `secureHeaders` keeps it.
|
|
168
179
|
|
|
180
|
+
## A nonce per request
|
|
181
|
+
|
|
182
|
+
A policy that allows a page's inline scripts without `'unsafe-inline'`
|
|
183
|
+
names a nonce: `script-src 'nonce-…'`, and the same value on each
|
|
184
|
+
`<script nonce="…">`. It must be unguessable and new on every response.
|
|
185
|
+
`nonce: true` does both halves.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { alxia } from '@alxia/core';
|
|
189
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
190
|
+
|
|
191
|
+
const app = alxia()
|
|
192
|
+
.use(
|
|
193
|
+
secureHeaders({
|
|
194
|
+
nonce: true,
|
|
195
|
+
contentSecurityPolicy: "default-src 'self'; script-src 'self'; frame-ancestors 'none'",
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
.get('/', ({ nonce, reply }) =>
|
|
199
|
+
reply(
|
|
200
|
+
200,
|
|
201
|
+
`<!doctype html><script nonce="${nonce}">document.title = 'ready'</script>`,
|
|
202
|
+
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const response = await app.request('/');
|
|
207
|
+
response.headers.get('content-security-policy');
|
|
208
|
+
// "default-src 'self'; script-src 'self' 'nonce-Gm1y0S3h1JZcVvXQ4l6x0A=='; frame-ancestors 'none'"
|
|
209
|
+
await response.text();
|
|
210
|
+
// "<!doctype html><script nonce=\"Gm1y0S3h1JZcVvXQ4l6x0A==\">…"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### What it is
|
|
214
|
+
|
|
215
|
+
16 bytes from `crypto.getRandomValues`, base64: 24 characters, `==` at the
|
|
216
|
+
end. Each request makes its own, when the first hook or route asks for it,
|
|
217
|
+
and every reader of that request — the route, its hooks, the header — gets
|
|
218
|
+
the same one. Two requests never share one.
|
|
219
|
+
|
|
220
|
+
### Where it goes in the policy
|
|
221
|
+
|
|
222
|
+
| The policy | The nonce goes |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| names `NONCE` | where `NONCE` stands, each time, and nowhere else |
|
|
225
|
+
| has no `NONCE`, has `script-src` or `script-src-elem` | at the end of each of those directives (their name in any case) |
|
|
226
|
+
| has neither, as the default policy | nowhere: `secureHeaders()` throws at startup; give `contentSecurityPolicy` with `nonce: true` |
|
|
227
|
+
|
|
228
|
+
`NONCE`, exported, is a placeholder string (`'nonce-{alxia}'`) that no real
|
|
229
|
+
policy contains. Use it to put the nonce in `style-src`, or in a directive
|
|
230
|
+
of your own choosing:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { NONCE, secureHeaders } from '@alxia/secure-headers';
|
|
234
|
+
|
|
235
|
+
app.use(
|
|
236
|
+
secureHeaders({
|
|
237
|
+
nonce: true,
|
|
238
|
+
contentSecurityPolicy: [
|
|
239
|
+
"default-src 'self'",
|
|
240
|
+
`script-src 'self' ${NONCE} 'strict-dynamic'`,
|
|
241
|
+
`style-src 'self' ${NONCE}`,
|
|
242
|
+
].join('; '),
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Leaving `style-src` alone is the default on purpose: once a directive holds
|
|
248
|
+
a nonce, browsers ignore its `'unsafe-inline'`, and `style="…"` attributes,
|
|
249
|
+
which take no nonce, stop applying
|
|
250
|
+
([troubleshooting](troubleshooting.md#inline-style-attributes-stop-applying-once-style-src-has-the-nonce)).
|
|
251
|
+
|
|
252
|
+
### Who reads it
|
|
253
|
+
|
|
254
|
+
The header covers every response, as without the nonce: 404s, 500s and the
|
|
255
|
+
routes declared before the plugin get a policy with a nonce of their own.
|
|
256
|
+
`ctx.nonce` is typed and set on the routes declared **after** `use`, in
|
|
257
|
+
their hooks and their handler, as any plugin's context:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
alxia()
|
|
261
|
+
.get('/early', ({ reply }) => reply(200, 'policy with a nonce, no ctx.nonce'))
|
|
262
|
+
.use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
|
|
263
|
+
.derive(({ nonce }) => ({ scriptTag: (code: string) => `<script nonce="${nonce}">${code}</script>` }))
|
|
264
|
+
.get('/late', ({ scriptTag, reply }) => reply(200, scriptTag('…')));
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
A route that sets its own `Content-Security-Policy` keeps it, as
|
|
268
|
+
[above](#a-header-a-route-sets-is-kept); its `ctx.nonce` is then in no
|
|
269
|
+
header, unless it writes it into its own policy.
|
|
270
|
+
|
|
271
|
+
### With React Router
|
|
272
|
+
|
|
273
|
+
`@alxia/react-router` reads the nonce from the context, if one is there,
|
|
274
|
+
with `nonceOf(loadContext)` in `entry.server.tsx`. Neither package depends
|
|
275
|
+
on the other. See
|
|
276
|
+
[its guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide.md#a-csp-nonce).
|
|
277
|
+
|
|
278
|
+
### Without `nonce: true`
|
|
279
|
+
|
|
280
|
+
Nothing changes: the same headers, the same values, no `nonce` on the
|
|
281
|
+
context. A policy that names `NONCE` without `nonce: true` is refused at
|
|
282
|
+
startup, so the placeholder never reaches a browser.
|
|
283
|
+
|
|
169
284
|
## Which responses get the headers
|
|
170
285
|
|
|
171
286
|
The plugin runs as an `onResponse` hook, so it reaches what the app's
|
|
@@ -259,6 +374,8 @@ app.use(
|
|
|
259
374
|
|
|
260
375
|
Or keep the strict default for the API and give the pages their policy on
|
|
261
376
|
their reply, as in [A header a route sets is kept](#a-header-a-route-sets-is-kept).
|
|
377
|
+
When the pages run inline scripts, add `nonce: true` rather than
|
|
378
|
+
`'unsafe-inline'`: [A nonce per request](#a-nonce-per-request).
|
|
262
379
|
|
|
263
380
|
### Framed by your own origin
|
|
264
381
|
|
package/docs/roadmap.md
CHANGED
|
@@ -25,6 +25,15 @@ Nothing scheduled yet.
|
|
|
25
25
|
|
|
26
26
|
## Shipped
|
|
27
27
|
|
|
28
|
+
### Next release
|
|
29
|
+
|
|
30
|
+
- **A nonce per request.** `secureHeaders({ nonce: true })` makes a fresh,
|
|
31
|
+
random nonce for each request, adds it to the policy's `script-src`, or
|
|
32
|
+
wherever the policy names `NONCE`, and gives the same one to the routes
|
|
33
|
+
after it as `ctx.nonce`, typed. A page's inline scripts run without
|
|
34
|
+
`'unsafe-inline'`; `@alxia/react-router`'s `nonceOf` hands it to React
|
|
35
|
+
Router. Without the option, nothing changes.
|
|
36
|
+
|
|
28
37
|
### 0.1.0
|
|
29
38
|
|
|
30
39
|
- **Secure headers on every response.** `secureHeaders()` is a plugin for
|
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, Empty, "", never>'`](#no-overload-matches-this-call--property-nonce-is-missing-in-type-alxiaempty-empty--never)
|
|
15
|
+
- [`Property 'nonce' does not exist on type 'Context<…>'`](#property-nonce-does-not-exist-on-type-context)
|
|
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
21
|
- [`TypeError: alxia().use(secureHeaders).get is not a function`](#typeerror-alxiausesecureheadersget-is-not-a-function)
|
|
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)
|
|
@@ -40,10 +48,16 @@ browser refusing what a header forbids, or a header in the wrong place.
|
|
|
40
48
|
**When:** a header option is given `true`, an object or an array.
|
|
41
49
|
|
|
42
50
|
```text
|
|
43
|
-
error
|
|
44
|
-
|
|
51
|
+
error TS2769: No overload matches this call.
|
|
52
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
|
|
53
|
+
Type 'true' is not assignable to type 'Setting'.
|
|
54
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
|
|
55
|
+
Type 'true' is not assignable to type 'Setting | undefined'.
|
|
45
56
|
```
|
|
46
57
|
|
|
58
|
+
An object, `{ defaultSrc: [...] }`, reads the same with its own type in
|
|
59
|
+
place of `'true'`.
|
|
60
|
+
|
|
47
61
|
`hidePoweredBy` given anything but a boolean is the same error, against
|
|
48
62
|
`'boolean | undefined'`.
|
|
49
63
|
|
|
@@ -63,13 +77,18 @@ app.use(
|
|
|
63
77
|
);
|
|
64
78
|
```
|
|
65
79
|
|
|
66
|
-
###
|
|
80
|
+
### `No overload matches this call` … `is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`
|
|
67
81
|
|
|
68
82
|
**When:** an option is set to `undefined`, often from a condition, with
|
|
69
83
|
`exactOptionalPropertyTypes` on.
|
|
70
84
|
|
|
71
85
|
```text
|
|
72
|
-
error
|
|
86
|
+
error TS2769: No overload matches this call.
|
|
87
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
|
|
88
|
+
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.
|
|
89
|
+
Type '{ xFrameOptions: undefined; }' is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
|
|
90
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
|
|
91
|
+
Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce?: false; }' with 'exactOptionalPropertyTypes: true'. …
|
|
73
92
|
```
|
|
74
93
|
|
|
75
94
|
**Why:** the options are optional, not `undefined`-able. Under that flag a
|
|
@@ -83,17 +102,21 @@ const production = Bun.env.NODE_ENV === 'production';
|
|
|
83
102
|
app.use(secureHeaders({ ...(production ? {} : { strictTransportSecurity: false }) }));
|
|
84
103
|
```
|
|
85
104
|
|
|
86
|
-
### `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions'`
|
|
105
|
+
### `No overload matches this call` … `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions & …'`
|
|
87
106
|
|
|
88
107
|
**When:** an option names a header the plugin does not send, or uses
|
|
89
108
|
another library's name for one.
|
|
90
109
|
|
|
91
110
|
```text
|
|
92
|
-
error
|
|
111
|
+
error TS2769: No overload matches this call.
|
|
112
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
|
|
113
|
+
Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce: true; }'.
|
|
114
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
|
|
115
|
+
Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce?: false; }'.
|
|
93
116
|
```
|
|
94
117
|
|
|
95
118
|
**Why:** the options are the twelve headers in the
|
|
96
|
-
[guide](guide.md#the-options), plus `hidePoweredBy`. `X-Powered-By` and
|
|
119
|
+
[guide](guide.md#the-options), plus `hidePoweredBy` and `nonce`. `X-Powered-By` and
|
|
97
120
|
`Server` are governed by `hidePoweredBy`; anything else —
|
|
98
121
|
`Content-Security-Policy-Report-Only`, `X-XSS-Protection` — is not sent by
|
|
99
122
|
the plugin.
|
|
@@ -114,20 +137,22 @@ const app = alxia()
|
|
|
114
137
|
);
|
|
115
138
|
```
|
|
116
139
|
|
|
117
|
-
### `No overload matches this call` … `
|
|
140
|
+
### `No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, Empty, "", never>'`
|
|
118
141
|
|
|
119
142
|
**When:** `secureHeaders` is given to `use` without being called.
|
|
120
143
|
|
|
121
144
|
```text
|
|
122
145
|
error TS2769: No overload matches this call.
|
|
123
146
|
Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
|
|
124
|
-
Argument of type '(options?: SecureHeadersOptions | undefined)
|
|
147
|
+
Argument of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin; (options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin; }' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => AnyAlxia'.
|
|
125
148
|
Types of parameters 'options' and 'app' are incompatible.
|
|
126
|
-
Type 'Alxia<Empty, Empty, "", never>'
|
|
149
|
+
Type 'Alxia<Empty, Empty, "", never>' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'.
|
|
150
|
+
Property 'nonce' is missing in type 'Alxia<Empty, Empty, "", never>' but required in type '{ readonly nonce: true; }'.
|
|
127
151
|
```
|
|
128
152
|
|
|
129
153
|
**Why:** `secureHeaders` makes the plugin from its options; the plugin is
|
|
130
|
-
what it returns.
|
|
154
|
+
what it returns. TypeScript tries the app as the options of its first
|
|
155
|
+
overload, the nonce one, and says what that lacks.
|
|
131
156
|
|
|
132
157
|
**Fix:** call it, with or without options:
|
|
133
158
|
|
|
@@ -135,6 +160,54 @@ what it returns.
|
|
|
135
160
|
app.use(secureHeaders());
|
|
136
161
|
```
|
|
137
162
|
|
|
163
|
+
### `Property 'nonce' does not exist on type 'Context<…>'`
|
|
164
|
+
|
|
165
|
+
**When:** a route or a hook reads `nonce` from its context.
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
error TS2339: Property 'nonce' does not exist on type 'Context<Empty, "/", Empty>'.
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Why:** only `secureHeaders({ nonce: true })` adds it, and only to the
|
|
172
|
+
routes declared after its `use`. Without `nonce: true`, or on a route
|
|
173
|
+
declared before it, there is none.
|
|
174
|
+
|
|
175
|
+
**Fix:** turn it on, and declare the routes that read it after it:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
alxia()
|
|
179
|
+
.use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
|
|
180
|
+
.get('/', ({ nonce, reply }) => reply(200, nonce));
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `No overload matches this call` … `Type 'boolean' is not assignable to type 'true'`
|
|
184
|
+
|
|
185
|
+
**When:** `nonce` is given a `boolean` that is not a literal: a flag from
|
|
186
|
+
the environment, say.
|
|
187
|
+
|
|
188
|
+
```text
|
|
189
|
+
error TS2769: No overload matches this call.
|
|
190
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NoncePlugin', gave the following error.
|
|
191
|
+
Type 'boolean' is not assignable to type 'true'.
|
|
192
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin', gave the following error.
|
|
193
|
+
Type 'boolean' is not assignable to type 'false'.
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Why:** `nonce: true` changes the plugin's type, since it adds `nonce`
|
|
197
|
+
to the context. A `boolean` leaves TypeScript unable to say whether the
|
|
198
|
+
routes read one.
|
|
199
|
+
|
|
200
|
+
**Fix:** pick the call by the flag, so each has a literal:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const policy = "default-src 'self'; script-src 'self'";
|
|
204
|
+
const app = useNonce
|
|
205
|
+
? alxia().use(secureHeaders({ nonce: true, contentSecurityPolicy: policy }))
|
|
206
|
+
: alxia().use(secureHeaders({ contentSecurityPolicy: policy }));
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Or keep it on everywhere: the nonce costs one random draw per request.
|
|
210
|
+
|
|
138
211
|
## Runtime
|
|
139
212
|
|
|
140
213
|
### `TypeError: secureHeaders: … is empty; give false to leave the … header out`
|
|
@@ -173,6 +246,65 @@ const app = alxia()
|
|
|
173
246
|
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
174
247
|
```
|
|
175
248
|
|
|
249
|
+
### `TypeError: secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: …`
|
|
250
|
+
|
|
251
|
+
**When:** `nonce: true`, with a policy that has neither a `script-src` (or
|
|
252
|
+
`script-src-elem`) nor `NONCE` — the default policy among them. It throws
|
|
253
|
+
at startup:
|
|
254
|
+
|
|
255
|
+
```text
|
|
256
|
+
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'")
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Why:** the nonce goes in a directive. Without `script-src`, scripts fall
|
|
260
|
+
back on `default-src`, and adding a `script-src` of the plugin's own would
|
|
261
|
+
change what else they may load.
|
|
262
|
+
|
|
263
|
+
**Fix:** write the `script-src` you want, or say where the nonce goes:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
import { NONCE, secureHeaders } from '@alxia/secure-headers';
|
|
267
|
+
|
|
268
|
+
app.use(secureHeaders({ nonce: true, contentSecurityPolicy: "default-src 'self'; script-src 'self'" }));
|
|
269
|
+
app.use(secureHeaders({ nonce: true, contentSecurityPolicy: `default-src 'self' ${NONCE}` }));
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### `TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`
|
|
273
|
+
|
|
274
|
+
**When:** `nonce: true` and `contentSecurityPolicy: false` together.
|
|
275
|
+
|
|
276
|
+
```text
|
|
277
|
+
TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Why:** a browser only honours a nonce the response's policy names; with
|
|
281
|
+
no policy there is nothing for it to allow.
|
|
282
|
+
|
|
283
|
+
**Fix:** give a policy, or drop `nonce`. A route that writes its own policy
|
|
284
|
+
with `ctx.nonce` still needs the plugin's: give the app one, and let the
|
|
285
|
+
route's replace it ([guide](guide.md#who-reads-it)).
|
|
286
|
+
|
|
287
|
+
### `TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`
|
|
288
|
+
|
|
289
|
+
**When:** the policy holds `NONCE`, and `nonce` is left out or `false`.
|
|
290
|
+
It throws at startup:
|
|
291
|
+
|
|
292
|
+
```text
|
|
293
|
+
TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**Why:** without `nonce: true` the placeholder would go out as it is,
|
|
297
|
+
`'nonce-{alxia}'`, which allows nothing and reads as a mistake in a
|
|
298
|
+
browser's console.
|
|
299
|
+
|
|
300
|
+
**Fix:**
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { NONCE, secureHeaders } from '@alxia/secure-headers';
|
|
304
|
+
|
|
305
|
+
app.use(secureHeaders({ nonce: true, contentSecurityPolicy: `script-src 'self' ${NONCE}` }));
|
|
306
|
+
```
|
|
307
|
+
|
|
176
308
|
## In the browser
|
|
177
309
|
|
|
178
310
|
These messages are Chrome's wording; Firefox and Safari report the same
|
|
@@ -220,6 +352,51 @@ form, even to itself.
|
|
|
220
352
|
const PAGE_POLICY = "default-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'";
|
|
221
353
|
```
|
|
222
354
|
|
|
355
|
+
### `Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`
|
|
356
|
+
|
|
357
|
+
**When:** `nonce: true` is on, and a page's inline script does not run.
|
|
358
|
+
Chrome adds `Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a
|
|
359
|
+
nonce ('nonce-...') is required to enable inline execution.`
|
|
360
|
+
|
|
361
|
+
**Why:** the script carries no nonce, or another request's: one cached with
|
|
362
|
+
the page, or written into the HTML at build time. Each response allows only
|
|
363
|
+
its own.
|
|
364
|
+
|
|
365
|
+
**Fix:** write the request's `ctx.nonce` on each `<script>`, and never
|
|
366
|
+
cache a page whose HTML holds one:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
.get('/', ({ nonce, reply }) =>
|
|
370
|
+
reply(200, `<script nonce="${nonce}">…</script>`, {
|
|
371
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
372
|
+
}),
|
|
373
|
+
)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
With React Router, `nonceOf(loadContext)` in `entry.server.tsx` does it for
|
|
377
|
+
every script it renders; see
|
|
378
|
+
[its troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md#a-script-of-the-page-has-no-nonce).
|
|
379
|
+
|
|
380
|
+
### Inline style attributes stop applying once `style-src` has the nonce
|
|
381
|
+
|
|
382
|
+
**When:** `NONCE` is put in `style-src`, which also has `'unsafe-inline'`,
|
|
383
|
+
and every `style="…"` attribute is ignored: Chrome prints `Refused to apply
|
|
384
|
+
inline style because it violates the following Content Security Policy
|
|
385
|
+
directive: "style-src 'self' 'unsafe-inline' 'nonce-…'". Note that
|
|
386
|
+
'unsafe-inline' is ignored if either a hash or nonce value is present in
|
|
387
|
+
the source list.`
|
|
388
|
+
|
|
389
|
+
**Why:** a nonce in a directive turns its `'unsafe-inline'` off, and an
|
|
390
|
+
attribute cannot carry a nonce. That is why `nonce: true` alone leaves
|
|
391
|
+
`style-src` as written.
|
|
392
|
+
|
|
393
|
+
**Fix:** keep the nonce out of `style-src`, or allow the attributes in a
|
|
394
|
+
directive of their own:
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
contentSecurityPolicy: `default-src 'self'; script-src 'self'; style-src-elem 'self' ${NONCE}; style-src-attr 'unsafe-inline'`,
|
|
398
|
+
```
|
|
399
|
+
|
|
223
400
|
### `Refused to frame '…' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'"`
|
|
224
401
|
|
|
225
402
|
**When:** a page puts a response of the app in an `<iframe>`. With the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/secure-headers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,13 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/client": "^0.2.
|
|
45
|
-
"@alxia/core": "^0.
|
|
44
|
+
"@alxia/client": "^0.2.1",
|
|
45
|
+
"@alxia/core": "^0.3.0",
|
|
46
46
|
"@types/bun": "^1.4.2",
|
|
47
47
|
"zod": "^4.2.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@alxia/core": "^0.
|
|
50
|
+
"@alxia/core": "^0.3.0",
|
|
51
51
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
52
52
|
}
|
|
53
53
|
}
|