@alxia/secure-headers 0.2.0 → 0.4.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 +22 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -17
- package/dist/index.js.map +4 -4
- package/dist/nonce.d.ts +1 -8
- package/dist/nonce.d.ts.map +1 -1
- package/dist/secure-headers.d.ts +16 -9
- package/dist/secure-headers.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide.md +80 -64
- package/docs/roadmap.md +6 -2
- package/docs/troubleshooting.md +143 -71
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -18,14 +18,16 @@ app.use(secureHeaders());
|
|
|
18
18
|
app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'", xFrameOptions: false }));
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
it
|
|
21
|
+
`secureHeaders()` is a middleware: every response that comes back through
|
|
22
|
+
it carries the headers, a 404's, an error's and a refused preflight's
|
|
23
|
+
included. A header a route sets itself is kept: a page that needs its own
|
|
24
|
+
policy sets it on its reply.
|
|
23
25
|
|
|
24
26
|
## A nonce per request
|
|
25
27
|
|
|
26
28
|
`nonce: true` makes a fresh nonce for each request (128 random bits,
|
|
27
29
|
base64), puts it in the policy as `'nonce-…'`, and gives it to the routes
|
|
28
|
-
declared after the
|
|
30
|
+
declared after the middleware as `ctx.nonce`. The header and the context always
|
|
29
31
|
carry the same one, so a page's inline scripts need no `'unsafe-inline'`.
|
|
30
32
|
|
|
31
33
|
```ts
|
|
@@ -94,15 +96,29 @@ value is refused at startup. `nonce` is off by default.
|
|
|
94
96
|
|
|
95
97
|
| export | |
|
|
96
98
|
| --- | --- |
|
|
97
|
-
| `secureHeaders(options?)` | the
|
|
99
|
+
| `secureHeaders(options?)` | the middleware, for `app.use`: a `SecureHeaders`, or with `nonce: true` a `NonceMiddleware` |
|
|
98
100
|
| `SecureHeadersOptions` | its options: the headers and `hidePoweredBy`; `nonce` is added by each overload |
|
|
99
101
|
| `Setting` | a header option's type: its value, or `false` to leave it out |
|
|
100
102
|
| `NONCE` | where the nonce goes in `contentSecurityPolicy`, each time it is named |
|
|
101
103
|
| `NonceContext` | what `nonce: true` adds to the context: `nonce`, a string |
|
|
102
|
-
| `
|
|
104
|
+
| `SecureHeaders` | what `secureHeaders()` returns: a middleware that adds nothing to the context |
|
|
105
|
+
| `NonceMiddleware` | what `secureHeaders({ nonce: true })` returns: a middleware adding `NonceContext` |
|
|
106
|
+
|
|
107
|
+
## Traps
|
|
108
|
+
|
|
109
|
+
`use` it first: a route declared before `app.use(secureHeaders())` gets no
|
|
110
|
+
header, and `ctx.nonce` exists only on routes declared after it. It
|
|
111
|
+
settles `next()` without swallowing the error: a `try`/`catch` middleware
|
|
112
|
+
catches it wherever it stands, but only one declared after
|
|
113
|
+
`secureHeaders()` has the headers on its reply. Declare error-handling
|
|
114
|
+
middleware after it.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const app = alxia().use(secureHeaders()).use(errorHandler).get(...);
|
|
118
|
+
```
|
|
103
119
|
|
|
104
120
|
## Documentation
|
|
105
121
|
|
|
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
|
|
122
|
+
- [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 middlewares, and recipes.
|
|
107
123
|
- [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.
|
|
108
124
|
- [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,3 +1,3 @@
|
|
|
1
1
|
export { NONCE, type NonceContext } from './nonce';
|
|
2
|
-
export { type
|
|
2
|
+
export { type NonceMiddleware, type SecureHeaders, type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
|
|
3
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,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,KAAK,
|
|
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,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -16,20 +16,11 @@ function freshNonce() {
|
|
|
16
16
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
17
17
|
return btoa(String.fromCharCode(...bytes));
|
|
18
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
|
-
}
|
|
30
19
|
// src/secure-headers.ts
|
|
31
20
|
import {
|
|
32
|
-
|
|
21
|
+
defineMiddleware,
|
|
22
|
+
markFactory,
|
|
23
|
+
settle,
|
|
33
24
|
withHeaders
|
|
34
25
|
} from "@alxia/core";
|
|
35
26
|
var DEFAULTS = {
|
|
@@ -69,17 +60,21 @@ function secureHeaders(options = {}) {
|
|
|
69
60
|
throw new TypeError("secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true");
|
|
70
61
|
}
|
|
71
62
|
const set = setter(headers, hide);
|
|
72
|
-
return (
|
|
63
|
+
return defineMiddleware(async function secureHeaders(ctx, next) {
|
|
64
|
+
return set(await settle(ctx, next()));
|
|
65
|
+
});
|
|
73
66
|
}
|
|
74
67
|
if (policy === undefined) {
|
|
75
68
|
throw new TypeError("secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header");
|
|
76
69
|
}
|
|
77
70
|
headers.delete("content-security-policy");
|
|
78
71
|
const withNonce = policyWithNonce(policy);
|
|
79
|
-
const nonceOf = nonceStore();
|
|
80
72
|
const set = setter(headers, hide);
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
return defineMiddleware(async function secureHeaders(ctx, next) {
|
|
74
|
+
const added = { nonce: freshNonce() };
|
|
75
|
+
const response = await settle(ctx, next(added));
|
|
76
|
+
return set(response, withNonce(added.nonce));
|
|
77
|
+
});
|
|
83
78
|
}
|
|
84
79
|
function fixedHeaders(options) {
|
|
85
80
|
const headers = new Map;
|
|
@@ -109,10 +104,11 @@ function setter(headers, hide) {
|
|
|
109
104
|
}
|
|
110
105
|
});
|
|
111
106
|
}
|
|
107
|
+
markFactory(secureHeaders);
|
|
112
108
|
export {
|
|
113
109
|
NONCE,
|
|
114
110
|
secureHeaders
|
|
115
111
|
};
|
|
116
112
|
|
|
117
|
-
//# debugId=
|
|
113
|
+
//# debugId=DA6BBAE79DDA945364756E2164756E21
|
|
118
114
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/nonce.ts", "../src/secure-headers.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes\n * in the policy, how one is made
|
|
6
|
-
"import {\n\
|
|
5
|
+
"/**\n * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes\n * in the policy, and how one is made.\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",
|
|
6
|
+
"import {\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\tmarkFactory,\n\ttype Next,\n\tsettle,\n\twithHeaders,\n} from '@alxia/core';\nimport { freshNonce, NONCE, type NonceContext, 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()` returns: a middleware, for `app.use`, that sets\n * the headers on every response after it.\n */\nexport type SecureHeaders = Middleware<Empty, Promise<Response>>;\n\n/**\n * What `secureHeaders({ nonce: true })` returns: a middleware, for\n * `app.use`, whose routes after it read `nonce`, and which sets the header\n * with the same one.\n */\nexport type NonceMiddleware = Middleware<Empty, Promise<Next<NonceContext>>>;\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 middleware: each response of the\n * routes after it and of a request no route matches — a 404, an error —\n * gets them. A header a route set itself is kept: a page that needs its\n * own `Content-Security-Policy` — `@alxia/graphql`'s IDE — 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): NonceMiddleware;\nexport function secureHeaders(\n\toptions?: SecureHeadersOptions & { readonly nonce?: false },\n): SecureHeaders;\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & NonceOption = {},\n): SecureHeaders | NonceMiddleware {\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 defineMiddleware(async function secureHeaders(ctx, next) {\n\t\t\treturn set(await settle(ctx, next()));\n\t\t});\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 set = setter(headers, hide);\n\treturn defineMiddleware(async function secureHeaders(ctx, next) {\n\t\tconst added: NonceContext = { nonce: freshNonce() };\n\t\tconst response = await settle(ctx, next(added));\n\t\treturn set(response, withNonce(added.nonce)) as typeof response;\n\t});\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/** 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\nmarkFactory(secureHeaders);\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": ";
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "DA6BBAE79DDA945364756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|
package/dist/nonce.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The per-request nonce of `secureHeaders({ nonce: true })`: where it goes
|
|
3
|
-
* in the policy, how one is made
|
|
4
|
-
* the same one.
|
|
3
|
+
* in the policy, and how one is made.
|
|
5
4
|
*/
|
|
6
5
|
/**
|
|
7
6
|
* Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes
|
|
@@ -32,10 +31,4 @@ export interface NonceContext {
|
|
|
32
31
|
export declare function policyWithNonce(policy: string): (nonce: string) => string;
|
|
33
32
|
/** 128 random bits, base64: a fresh nonce. */
|
|
34
33
|
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
34
|
//# sourceMappingURL=nonce.d.ts.map
|
package/dist/nonce.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAAA;;;GAGG;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"}
|
package/dist/secure-headers.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Empty, type Middleware, type Next } from '@alxia/core';
|
|
2
2
|
import { type NonceContext } from './nonce';
|
|
3
3
|
/** A header's value, or `false` to leave it out. An empty value is refused. */
|
|
4
4
|
export type Setting = string | false;
|
|
@@ -21,14 +21,21 @@ export interface SecureHeadersOptions {
|
|
|
21
21
|
readonly hidePoweredBy?: boolean;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* What `secureHeaders(
|
|
25
|
-
*
|
|
24
|
+
* What `secureHeaders()` returns: a middleware, for `app.use`, that sets
|
|
25
|
+
* the headers on every response after it.
|
|
26
26
|
*/
|
|
27
|
-
export type
|
|
27
|
+
export type SecureHeaders = Middleware<Empty, Promise<Response>>;
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* What `secureHeaders({ nonce: true })` returns: a middleware, for
|
|
30
|
+
* `app.use`, whose routes after it read `nonce`, and which sets the header
|
|
31
|
+
* with the same one.
|
|
32
|
+
*/
|
|
33
|
+
export type NonceMiddleware = Middleware<Empty, Promise<Next<NonceContext>>>;
|
|
34
|
+
/**
|
|
35
|
+
* Secure headers on every response, as a middleware: each response of the
|
|
36
|
+
* routes after it and of a request no route matches — a 404, an error —
|
|
37
|
+
* gets them. A header a route set itself is kept: a page that needs its
|
|
38
|
+
* own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.
|
|
32
39
|
*
|
|
33
40
|
* ```ts
|
|
34
41
|
* app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'" }));
|
|
@@ -45,8 +52,8 @@ export type NoncePlugin = Alxia<NonceContext, Empty, '', never> & Requiring<Empt
|
|
|
45
52
|
*/
|
|
46
53
|
export declare function secureHeaders(options: SecureHeadersOptions & {
|
|
47
54
|
readonly nonce: true;
|
|
48
|
-
}):
|
|
55
|
+
}): NonceMiddleware;
|
|
49
56
|
export declare function secureHeaders(options?: SecureHeadersOptions & {
|
|
50
57
|
readonly nonce?: false;
|
|
51
|
-
}):
|
|
58
|
+
}): SecureHeaders;
|
|
52
59
|
//# 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,EAEN,KAAK,KAAK,EACV,KAAK,UAAU,EAEf,KAAK,IAAI,EAGT,MAAM,aAAa,CAAC;AACrB,OAAO,EAAqB,KAAK,YAAY,EAAmB,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,aAAa,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;AAEjE;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;AAiC7E;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GACtD,eAAe,CAAC;AACnB,wBAAgB,aAAa,CAC5B,OAAO,CAAC,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACzD,aAAa,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, a nonce per request, giving a page its own policy, ordering it with other
|
|
9
|
+
| [Guide](guide.md) | changing or dropping a header, a nonce per request, giving a page its own policy, ordering it with other middlewares, 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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This page covers `secureHeaders`: what it sends, how to change or drop each
|
|
4
4
|
header, the per-request nonce, which responses it reaches, and how a page or
|
|
5
|
-
another
|
|
5
|
+
another middleware sets its own policy.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { alxia } from '@alxia/core';
|
|
@@ -21,8 +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 & { readonly nonce: true }):
|
|
25
|
-
function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }):
|
|
24
|
+
function secureHeaders(options: SecureHeadersOptions & { readonly nonce: true }): NonceMiddleware;
|
|
25
|
+
function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }): SecureHeaders;
|
|
26
26
|
|
|
27
27
|
interface SecureHeadersOptions {
|
|
28
28
|
readonly contentSecurityPolicy?: string | false;
|
|
@@ -42,22 +42,25 @@ interface SecureHeadersOptions {
|
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
`nonce` is not in `SecureHeadersOptions`: each overload adds it, so options
|
|
45
|
-
typed by that interface still give the plain `
|
|
46
|
-
|
|
47
|
-
`secureHeaders` returns a
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
53
56
|
([below](#a-header-a-route-sets-is-kept)).
|
|
54
57
|
|
|
55
58
|
The options are read once, when `secureHeaders(…)` is called; the header
|
|
56
59
|
values are fixed from then on, but for the nonce, which is new on every
|
|
57
60
|
request.
|
|
58
61
|
|
|
59
|
-
With `nonce: true` it returns a `
|
|
60
|
-
given to `use` called,
|
|
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
|
|
61
64
|
adds `nonce` to the context of the routes declared after it
|
|
62
65
|
([below](#a-nonce-per-request)).
|
|
63
66
|
|
|
@@ -103,7 +106,7 @@ app.use(
|
|
|
103
106
|
|
|
104
107
|
### `false`
|
|
105
108
|
|
|
106
|
-
`false` leaves the header out of every response the
|
|
109
|
+
`false` leaves the header out of every response the middleware touches.
|
|
107
110
|
|
|
108
111
|
```ts
|
|
109
112
|
app.use(secureHeaders({ xFrameOptions: false, strictTransportSecurity: false }));
|
|
@@ -143,11 +146,11 @@ const app = alxia()
|
|
|
143
146
|
(await app.request('/')).headers.get('x-powered-by'); // null
|
|
144
147
|
```
|
|
145
148
|
|
|
146
|
-
`hidePoweredBy: false` keeps whatever the route or another
|
|
149
|
+
`hidePoweredBy: false` keeps whatever the route or another middleware sent.
|
|
147
150
|
|
|
148
151
|
## A header a route sets is kept
|
|
149
152
|
|
|
150
|
-
The
|
|
153
|
+
The middleware only fills in a header the response does not have yet. A route
|
|
151
154
|
that needs its own policy sets it on its reply, and the other defaults
|
|
152
155
|
still apply:
|
|
153
156
|
|
|
@@ -171,11 +174,10 @@ response.headers.get('x-frame-options'); // null — f
|
|
|
171
174
|
```
|
|
172
175
|
|
|
173
176
|
`set.headers` in a handler or a `derive` works the same way, since it ends
|
|
174
|
-
up on the response before the
|
|
177
|
+
up on the response before the middleware reads it.
|
|
175
178
|
|
|
176
|
-
That is how `@alxia/
|
|
177
|
-
|
|
178
|
-
needs, and `secureHeaders` keeps it.
|
|
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.
|
|
179
181
|
|
|
180
182
|
## A nonce per request
|
|
181
183
|
|
|
@@ -213,8 +215,8 @@ await response.text();
|
|
|
213
215
|
### What it is
|
|
214
216
|
|
|
215
217
|
16 bytes from `crypto.getRandomValues`, base64: 24 characters, `==` at the
|
|
216
|
-
end. Each request makes its own, when the
|
|
217
|
-
and every reader of that request — the route, its
|
|
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
|
|
218
220
|
the same one. Two requests never share one.
|
|
219
221
|
|
|
220
222
|
### Where it goes in the policy
|
|
@@ -251,14 +253,15 @@ which take no nonce, stop applying
|
|
|
251
253
|
|
|
252
254
|
### Who reads it
|
|
253
255
|
|
|
254
|
-
The header covers every response
|
|
255
|
-
|
|
256
|
-
`ctx.nonce` is typed and set on the routes declared **after** `use`, in
|
|
257
|
-
their
|
|
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`:
|
|
258
261
|
|
|
259
262
|
```ts
|
|
260
263
|
alxia()
|
|
261
|
-
.get('/early', ({ reply }) => reply(200, 'policy
|
|
264
|
+
.get('/early', ({ reply }) => reply(200, 'no policy from this middleware, no ctx.nonce'))
|
|
262
265
|
.use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
|
|
263
266
|
.derive(({ nonce }) => ({ scriptTag: (code: string) => `<script nonce="${nonce}">${code}</script>` }))
|
|
264
267
|
.get('/late', ({ scriptTag, reply }) => reply(200, scriptTag('…')));
|
|
@@ -283,17 +286,17 @@ startup, so the placeholder never reaches a browser.
|
|
|
283
286
|
|
|
284
287
|
## Which responses get the headers
|
|
285
288
|
|
|
286
|
-
The
|
|
287
|
-
|
|
289
|
+
The middleware settles `next()`: it sets the headers on the response the
|
|
290
|
+
client would get, whatever produced it.
|
|
288
291
|
|
|
289
292
|
| Response | Headers |
|
|
290
293
|
| --- | --- |
|
|
291
294
|
| a route's reply, `static` and `file` included | yes |
|
|
292
|
-
| the core's 400, 404, 405 and 500 | yes |
|
|
293
|
-
| a `Response`
|
|
294
|
-
| a
|
|
295
|
-
| a
|
|
296
|
-
|
|
|
295
|
+
| the core's 400, 404, 405 and 500, and a `try`/`catch` middleware's reply declared after it | 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 |
|
|
297
300
|
|
|
298
301
|
```ts
|
|
299
302
|
const app = alxia().use(secureHeaders());
|
|
@@ -303,58 +306,71 @@ response.status; // 404
|
|
|
303
306
|
response.headers.get('x-content-type-options'); // 'nosniff'
|
|
304
307
|
```
|
|
305
308
|
|
|
306
|
-
## Order with other
|
|
309
|
+
## Order with other middlewares
|
|
307
310
|
|
|
308
|
-
|
|
309
|
-
|
|
311
|
+
Middlewares nest: the first `use` is the outermost, and on the way out each
|
|
312
|
+
one sees the response of the ones after it. The middleware never overwrites
|
|
313
|
+
a header that is already there. So:
|
|
310
314
|
|
|
311
|
-
- a
|
|
312
|
-
wins;
|
|
313
|
-
- a
|
|
314
|
-
overwriting;
|
|
315
|
-
- a
|
|
316
|
-
it:
|
|
315
|
+
- a middleware declared **after** `secureHeaders` that sets one of its
|
|
316
|
+
headers wins: `secureHeaders` finds it already there;
|
|
317
|
+
- a middleware declared **before** it that sets one of its headers wins too,
|
|
318
|
+
by overwriting;
|
|
319
|
+
- a middleware declared **before** it that adds `X-Powered-By` or `Server`
|
|
320
|
+
keeps it: `secureHeaders` deleted them before that one ran.
|
|
317
321
|
|
|
318
322
|
```ts
|
|
319
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
323
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
320
324
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
321
325
|
|
|
326
|
+
const referrerOrigin = defineMiddleware(async (ctx, next) =>
|
|
327
|
+
withHeaders(await settle(ctx, next()), (headers) => headers.set('referrer-policy', 'origin')),
|
|
328
|
+
);
|
|
329
|
+
|
|
322
330
|
const app = alxia()
|
|
323
|
-
.onResponse((response) =>
|
|
324
|
-
withHeaders(response, (headers) => headers.set('referrer-policy', 'origin')),
|
|
325
|
-
)
|
|
326
331
|
.use(secureHeaders())
|
|
332
|
+
.use(referrerOrigin)
|
|
327
333
|
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
328
334
|
|
|
329
335
|
(await app.request('/')).headers.get('referrer-policy'); // 'origin'
|
|
330
336
|
```
|
|
331
337
|
|
|
332
|
-
|
|
338
|
+
Declare `secureHeaders()` among the observers, first: `logger()`,
|
|
339
|
+
`telemetry()`, `secureHeaders()`, `cors()`, `compress()`. An
|
|
340
|
+
error-handling middleware — a `try`/`catch` around `next()`, or
|
|
341
|
+
`janusErrors()` — goes **after** it. `secureHeaders()` settles `next()`: it
|
|
342
|
+
sets the headers on the response the error would be answered with (its
|
|
343
|
+
`HttpError`'s status, or a 500), then the error
|
|
344
|
+
goes on, so a `try`/`catch` catches it wherever it is declared; declared
|
|
345
|
+
before `secureHeaders()`, its reply does not carry the headers. A guard on the app (`bearer`, a required
|
|
346
|
+
session) declared before it answers its `401` without them.
|
|
333
347
|
|
|
334
|
-
|
|
348
|
+
## A header the middleware does not know
|
|
349
|
+
|
|
350
|
+
The middleware sends the twelve headers above and nothing else. Another one —
|
|
335
351
|
`Content-Security-Policy-Report-Only` while a policy is being tried, say —
|
|
336
|
-
is a
|
|
352
|
+
is a middleware of your own, with `settle` and `withHeaders` from
|
|
353
|
+
`@alxia/core`:
|
|
337
354
|
|
|
338
355
|
```ts
|
|
339
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
356
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
340
357
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
341
358
|
|
|
359
|
+
const reportOnly = defineMiddleware(async (ctx, next) =>
|
|
360
|
+
withHeaders(await settle(ctx, next()), (headers) => {
|
|
361
|
+
if (!headers.has('content-security-policy-report-only')) {
|
|
362
|
+
headers.set('content-security-policy-report-only', "default-src 'self'; report-to csp");
|
|
363
|
+
}
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
342
367
|
const app = alxia()
|
|
343
368
|
.use(secureHeaders())
|
|
344
|
-
.
|
|
345
|
-
withHeaders(response, (headers) => {
|
|
346
|
-
if (!headers.has('content-security-policy-report-only')) {
|
|
347
|
-
headers.set(
|
|
348
|
-
'content-security-policy-report-only',
|
|
349
|
-
"default-src 'self'; report-to csp",
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
}),
|
|
353
|
-
);
|
|
369
|
+
.use(reportOnly);
|
|
354
370
|
```
|
|
355
371
|
|
|
356
|
-
See [
|
|
357
|
-
in the core's guide for what
|
|
372
|
+
See [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
|
|
373
|
+
in the core's guide for what a middleware may do.
|
|
358
374
|
|
|
359
375
|
## Recipes
|
|
360
376
|
|
|
@@ -481,6 +497,6 @@ test('a 404 is covered too', async () => {
|
|
|
481
497
|
|
|
482
498
|
- [Troubleshooting](troubleshooting.md): a browser refuses something, or
|
|
483
499
|
TypeScript does.
|
|
484
|
-
- [
|
|
500
|
+
- [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
|
|
485
501
|
and [Groups and plugins](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md)
|
|
486
502
|
in the core's guide.
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,11 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **Secure headers as a middleware.** `app.use(secureHeaders())`, declared
|
|
11
|
+
first, sets the headers on every response that comes back through it, a
|
|
12
|
+
404's, an error's and a 500's included; `NonceMiddleware` gives `nonce` to the
|
|
13
|
+
routes after it and `SecureHeaders` is the plain one.
|
|
14
|
+
`app.plugin(secureHeaders())`, the deprecated alias, was removed in 0.5.
|
|
11
15
|
|
|
12
16
|
## Next
|
|
13
17
|
|
|
@@ -37,7 +41,7 @@ Nothing scheduled yet.
|
|
|
37
41
|
### 0.1.0
|
|
38
42
|
|
|
39
43
|
- **Secure headers on every response.** `secureHeaders()` is a plugin for
|
|
40
|
-
`
|
|
44
|
+
`app.plugin` that sends `Content-Security-Policy`, `Strict-Transport-Security`,
|
|
41
45
|
`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
|
42
46
|
`Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
|
|
43
47
|
`Origin-Agent-Cluster`, `X-DNS-Prefetch-Control` and
|
package/docs/troubleshooting.md
CHANGED
|
@@ -11,14 +11,14 @@ or a header in the wrong place.
|
|
|
11
11
|
- [`Type 'true' is not assignable to type 'Setting | undefined'`](#type-true-is-not-assignable-to-type-setting--undefined)
|
|
12
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
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
|
-
- [`
|
|
15
|
-
- [`Property 'nonce' does not exist on type '
|
|
14
|
+
- [`Type 'undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'`](#type-undefined-is-not-assignable-to-type-secureheadersoptions---readonly-nonce-true-)
|
|
15
|
+
- [`Property 'nonce' does not exist on type 'RouteBase<…>'`](#property-nonce-does-not-exist-on-type-routebase)
|
|
16
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)
|
|
17
17
|
|
|
18
18
|
**Runtime**
|
|
19
19
|
|
|
20
20
|
- [`TypeError: secureHeaders: … is empty; give false to leave the … header out`](#typeerror-secureheaders--is-empty-give-false-to-leave-the--header-out)
|
|
21
|
-
- [`
|
|
21
|
+
- [`use(): argument 1 looks like a factory (secureHeaders): call it, use(secureHeaders())`](#use-argument-1-looks-like-a-factory-secureheaders-call-it-usesecureheaders)
|
|
22
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
23
|
- [`TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`](#typeerror-secureheaders-nonce-is-on-but-contentsecuritypolicy-is-false-)
|
|
24
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)
|
|
@@ -39,7 +39,9 @@ or a header in the wrong place.
|
|
|
39
39
|
- [An option has no effect on a response](#an-option-has-no-effect-on-a-response)
|
|
40
40
|
- [`X-Powered-By` or `Server` is still sent](#x-powered-by-or-server-is-still-sent)
|
|
41
41
|
- [A response arrives without the headers](#a-response-arrives-without-the-headers)
|
|
42
|
-
- [
|
|
42
|
+
- [A 404 or a route outside a group has no headers](#a-404-or-a-route-outside-a-group-has-no-headers)
|
|
43
|
+
- [A `try`/`catch` middleware's reply has no headers](#a-trycatch-middlewares-reply-has-no-headers)
|
|
44
|
+
- [A route has no headers, though the others do](#a-route-has-no-headers-though-the-others-do)
|
|
43
45
|
|
|
44
46
|
## Types
|
|
45
47
|
|
|
@@ -49,9 +51,9 @@ or a header in the wrong place.
|
|
|
49
51
|
|
|
50
52
|
```text
|
|
51
53
|
error TS2769: No overload matches this call.
|
|
52
|
-
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
54
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
|
|
53
55
|
Type 'true' is not assignable to type 'Setting'.
|
|
54
|
-
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined):
|
|
56
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
|
|
55
57
|
Type 'true' is not assignable to type 'Setting | undefined'.
|
|
56
58
|
```
|
|
57
59
|
|
|
@@ -84,10 +86,10 @@ app.use(
|
|
|
84
86
|
|
|
85
87
|
```text
|
|
86
88
|
error TS2769: No overload matches this call.
|
|
87
|
-
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
89
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
|
|
88
90
|
Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce: true; }' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
|
|
89
91
|
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):
|
|
92
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
|
|
91
93
|
Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce?: false; }' with 'exactOptionalPropertyTypes: true'. …
|
|
92
94
|
```
|
|
93
95
|
|
|
@@ -109,9 +111,9 @@ another library's name for one.
|
|
|
109
111
|
|
|
110
112
|
```text
|
|
111
113
|
error TS2769: No overload matches this call.
|
|
112
|
-
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
114
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
|
|
113
115
|
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):
|
|
116
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
|
|
115
117
|
Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce?: false; }'.
|
|
116
118
|
```
|
|
117
119
|
|
|
@@ -119,40 +121,43 @@ error TS2769: No overload matches this call.
|
|
|
119
121
|
[guide](guide.md#the-options), plus `hidePoweredBy` and `nonce`. `X-Powered-By` and
|
|
120
122
|
`Server` are governed by `hidePoweredBy`; anything else —
|
|
121
123
|
`Content-Security-Policy-Report-Only`, `X-XSS-Protection` — is not sent by
|
|
122
|
-
the
|
|
124
|
+
the middleware.
|
|
123
125
|
|
|
124
|
-
**Fix:** `hidePoweredBy` for those two; a
|
|
126
|
+
**Fix:** `hidePoweredBy` for those two; a middleware of your own for another
|
|
125
127
|
header:
|
|
126
128
|
|
|
127
129
|
```ts
|
|
128
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
130
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
129
131
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
130
132
|
|
|
133
|
+
const reportOnly = defineMiddleware(async (ctx, next) =>
|
|
134
|
+
withHeaders(await settle(ctx, next()), (headers) =>
|
|
135
|
+
headers.set('content-security-policy-report-only', "default-src 'self'"),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
131
139
|
const app = alxia()
|
|
132
140
|
.use(secureHeaders({ hidePoweredBy: true }))
|
|
133
|
-
.
|
|
134
|
-
withHeaders(response, (headers) =>
|
|
135
|
-
headers.set('content-security-policy-report-only', "default-src 'self'"),
|
|
136
|
-
),
|
|
137
|
-
);
|
|
141
|
+
.use(reportOnly);
|
|
138
142
|
```
|
|
139
143
|
|
|
140
|
-
### `
|
|
144
|
+
### `Type 'undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'`
|
|
141
145
|
|
|
142
|
-
**When:** `secureHeaders` is given to `use` without being called.
|
|
146
|
+
**When:** `secureHeaders` is given to `app.use` without being called.
|
|
143
147
|
|
|
144
148
|
```text
|
|
145
|
-
error
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
Type '
|
|
150
|
-
|
|
149
|
+
error TS2345: Argument of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware; (options?: (SecureHeadersOptions & { ...; }) | undefined): SecureHeaders; }' is not assignable to parameter of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware; (options?: (SecureHeadersOptions & { ...; }) | undefined): SecureHeaders; } & Step<...>'.
|
|
150
|
+
Type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware; (options?: (SecureHeadersOptions & { ...; }) | undefined): SecureHeaders; }' is not assignable to type 'Step<BaseContext & Empty, (SecureHeadersOptions & { readonly nonce?: false; }) | undefined, MiddlewareReturn>'.
|
|
151
|
+
Types of parameters 'options' and 'ctx' are incompatible.
|
|
152
|
+
Type '(SecureHeadersOptions & { readonly nonce?: false; }) | undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'.
|
|
153
|
+
Type 'undefined' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'.
|
|
154
|
+
Type 'undefined' is not assignable to type 'SecureHeadersOptions'.
|
|
151
155
|
```
|
|
152
156
|
|
|
153
|
-
**Why:** `secureHeaders` makes the
|
|
154
|
-
what it returns.
|
|
155
|
-
|
|
157
|
+
**Why:** `secureHeaders` makes the middleware from its options; the
|
|
158
|
+
middleware is what it returns. Given to `use` as it is, it is read as a
|
|
159
|
+
middleware whose context is its options, and TypeScript says those do not
|
|
160
|
+
fit its first overload, the nonce one.
|
|
156
161
|
|
|
157
162
|
**Fix:** call it, with or without options:
|
|
158
163
|
|
|
@@ -160,16 +165,19 @@ overload, the nonce one, and says what that lacks.
|
|
|
160
165
|
app.use(secureHeaders());
|
|
161
166
|
```
|
|
162
167
|
|
|
163
|
-
### `Property 'nonce' does not exist on type '
|
|
168
|
+
### `Property 'nonce' does not exist on type 'RouteBase<…>'`
|
|
164
169
|
|
|
165
|
-
**When:** a route or a
|
|
170
|
+
**When:** a route or a middleware reads `nonce` from its context.
|
|
166
171
|
|
|
167
172
|
```text
|
|
168
|
-
error TS2339: Property 'nonce' does not exist on type '
|
|
173
|
+
error TS2339: Property 'nonce' does not exist on type 'RouteBase<RouteApp<"GET", Empty, "">, "/">'.
|
|
174
|
+
error TS2339: Property 'nonce' does not exist on type 'BaseContext & Empty'.
|
|
169
175
|
```
|
|
170
176
|
|
|
177
|
+
The second is a `derive` or a middleware.
|
|
178
|
+
|
|
171
179
|
**Why:** only `secureHeaders({ nonce: true })` adds it, and only to the
|
|
172
|
-
routes declared after its `use`. Without `nonce: true`, or on a route
|
|
180
|
+
routes declared after its `app.use`. Without `nonce: true`, or on a route
|
|
173
181
|
declared before it, there is none.
|
|
174
182
|
|
|
175
183
|
**Fix:** turn it on, and declare the routes that read it after it:
|
|
@@ -187,13 +195,13 @@ the environment, say.
|
|
|
187
195
|
|
|
188
196
|
```text
|
|
189
197
|
error TS2769: No overload matches this call.
|
|
190
|
-
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
198
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
|
|
191
199
|
Type 'boolean' is not assignable to type 'true'.
|
|
192
|
-
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined):
|
|
200
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
|
|
193
201
|
Type 'boolean' is not assignable to type 'false'.
|
|
194
202
|
```
|
|
195
203
|
|
|
196
|
-
**Why:** `nonce: true` changes the
|
|
204
|
+
**Why:** `nonce: true` changes the middleware's type, since it adds `nonce`
|
|
197
205
|
to the context. A `boolean` leaves TypeScript unable to say whether the
|
|
198
206
|
routes read one.
|
|
199
207
|
|
|
@@ -229,14 +237,18 @@ Only `false` leaves a header out.
|
|
|
229
237
|
app.use(secureHeaders({ xFrameOptions: false }));
|
|
230
238
|
```
|
|
231
239
|
|
|
232
|
-
### `
|
|
240
|
+
### `use(): argument 1 looks like a factory (secureHeaders): call it, use(secureHeaders())`
|
|
233
241
|
|
|
234
|
-
**When:** the
|
|
235
|
-
|
|
242
|
+
**When:** `alxia().use(secureHeaders)`, the factory given uncalled, in
|
|
243
|
+
JavaScript or past a cast. It throws where the app is declared, since
|
|
244
|
+
alxia 0.5; before, each request was answered with a 500 and the log said
|
|
245
|
+
`GET /: a middleware (secureHeaders) returned function: …`. TypeScript
|
|
246
|
+
refuses it first, with `"this looks like a factory given uncalled: …"` in
|
|
247
|
+
the error.
|
|
236
248
|
|
|
237
|
-
**Why:** `use`
|
|
238
|
-
|
|
239
|
-
|
|
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.
|
|
240
252
|
|
|
241
253
|
**Fix:**
|
|
242
254
|
|
|
@@ -257,7 +269,7 @@ TypeError: secureHeaders: nonce is on, but the content-security-policy has no sc
|
|
|
257
269
|
```
|
|
258
270
|
|
|
259
271
|
**Why:** the nonce goes in a directive. Without `script-src`, scripts fall
|
|
260
|
-
back on `default-src`, and adding a `script-src` of the
|
|
272
|
+
back on `default-src`, and adding a `script-src` of the middleware's own would
|
|
261
273
|
change what else they may load.
|
|
262
274
|
|
|
263
275
|
**Fix:** write the `script-src` you want, or say where the nonce goes:
|
|
@@ -281,7 +293,7 @@ TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: a non
|
|
|
281
293
|
no policy there is nothing for it to allow.
|
|
282
294
|
|
|
283
295
|
**Fix:** give a policy, or drop `nonce`. A route that writes its own policy
|
|
284
|
-
with `ctx.nonce` still needs the
|
|
296
|
+
with `ctx.nonce` still needs the middleware's: give the app one, and let the
|
|
285
297
|
route's replace it ([guide](guide.md#who-reads-it)).
|
|
286
298
|
|
|
287
299
|
### `TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`
|
|
@@ -476,11 +488,11 @@ until it reads `max-age=0` from the host over HTTPS.
|
|
|
476
488
|
|
|
477
489
|
**When:** an option is set, and some responses still carry another value.
|
|
478
490
|
|
|
479
|
-
**Why:** the
|
|
480
|
-
A route set it on its reply or on `set.headers`,
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
`Content-Security-Policy` on purpose.
|
|
491
|
+
**Why:** the middleware never overwrites a header the response already has.
|
|
492
|
+
A route set it on its reply or on `set.headers`, a middleware declared
|
|
493
|
+
after `secureHeaders` set it on the way out, or one declared before it
|
|
494
|
+
overwrote it.
|
|
495
|
+
`@alxia/graphql`'s IDE sets its own `Content-Security-Policy` on purpose.
|
|
484
496
|
|
|
485
497
|
**Fix:** find what sets it on that route, and change it there:
|
|
486
498
|
|
|
@@ -494,22 +506,25 @@ console.log(response.headers.get('content-security-policy'));
|
|
|
494
506
|
**When:** `hidePoweredBy` is on (the default), and one of them is on the
|
|
495
507
|
response.
|
|
496
508
|
|
|
497
|
-
**Why:** the
|
|
498
|
-
declared
|
|
499
|
-
proxy in front of the app may add its
|
|
509
|
+
**Why:** the middleware deletes them when the response comes back through
|
|
510
|
+
it. A middleware declared **before** `secureHeaders` that adds one sees the
|
|
511
|
+
response after that, and keeps it. A proxy in front of the app may add its
|
|
512
|
+
own `Server` too.
|
|
500
513
|
|
|
501
|
-
**Fix:** declare `secureHeaders`
|
|
502
|
-
stop that
|
|
514
|
+
**Fix:** declare `secureHeaders` before the middleware that adds the header,
|
|
515
|
+
or stop that middleware adding it:
|
|
503
516
|
|
|
504
517
|
```ts
|
|
505
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
518
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
506
519
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
507
520
|
|
|
521
|
+
const poweredBy = defineMiddleware(async (ctx, next) =>
|
|
522
|
+
withHeaders(await settle(ctx, next()), (headers) => headers.set('x-powered-by', 'my-app')),
|
|
523
|
+
);
|
|
524
|
+
|
|
508
525
|
const app = alxia()
|
|
509
|
-
.
|
|
510
|
-
|
|
511
|
-
)
|
|
512
|
-
.use(secureHeaders()); // its hook runs after, and deletes it
|
|
526
|
+
.use(secureHeaders()) // outermost: deletes the header on the way out
|
|
527
|
+
.use(poweredBy);
|
|
513
528
|
```
|
|
514
529
|
|
|
515
530
|
### A response arrives without the headers
|
|
@@ -517,20 +532,19 @@ const app = alxia()
|
|
|
517
532
|
**When:** a response has none of the headers, though `secureHeaders` is
|
|
518
533
|
in use.
|
|
519
534
|
|
|
520
|
-
**Why:** the
|
|
521
|
-
|
|
522
|
-
served with `page()`, which `Bun.serve` answers itself
|
|
523
|
-
when an `around` hook throws.
|
|
535
|
+
**Why:** the middleware decorates what comes back through it, and two
|
|
536
|
+
responses do not: a WebSocket upgrade that succeeds (the `101`), and a page
|
|
537
|
+
served with `page()`, which `Bun.serve` answers itself.
|
|
524
538
|
|
|
525
539
|
**Fix:** serve a page that needs the headers through a route, `file` or
|
|
526
540
|
`static` rather than `page()` — or give it its policy in its HTML with
|
|
527
541
|
`<meta http-equiv="Content-Security-Policy" content="…">`, where browsers
|
|
528
|
-
ignore `frame-ancestors`.
|
|
529
|
-
it and still returns what `next()`
|
|
530
|
-
headers:
|
|
542
|
+
ignore `frame-ancestors`. A middleware declared before `secureHeaders`,
|
|
543
|
+
whose own work may fail, catches it and still returns what `next()`
|
|
544
|
+
resolved to, which already carries the headers:
|
|
531
545
|
|
|
532
546
|
```ts
|
|
533
|
-
|
|
547
|
+
const timing = defineMiddleware(async (ctx, next) => {
|
|
534
548
|
const response = await next();
|
|
535
549
|
try {
|
|
536
550
|
recordTiming(response); // your own work
|
|
@@ -539,19 +553,26 @@ app.around(async (_ctx, next) => {
|
|
|
539
553
|
}
|
|
540
554
|
return response;
|
|
541
555
|
});
|
|
556
|
+
|
|
557
|
+
const app = alxia().use(timing).use(secureHeaders());
|
|
542
558
|
```
|
|
543
559
|
|
|
544
|
-
###
|
|
560
|
+
### A 404 or a route outside a group has no headers
|
|
545
561
|
|
|
546
|
-
**When:** `secureHeaders` is used inside a `group`,
|
|
547
|
-
|
|
562
|
+
**When:** `secureHeaders` is used inside a `group`, and a route outside the
|
|
563
|
+
group, or a request that matches no route, comes back without them.
|
|
548
564
|
|
|
549
|
-
**Why:**
|
|
565
|
+
**Why:** a middleware declared in a `group` stays inside the group: it runs
|
|
566
|
+
on its routes and on a request no route matches under its prefix, and on
|
|
567
|
+
nothing else: not on a route outside the group, nor declared after it.
|
|
550
568
|
|
|
551
569
|
**Fix:** use it on the app, and give the routes that differ their own
|
|
552
570
|
values on their replies:
|
|
553
571
|
|
|
554
572
|
```ts
|
|
573
|
+
import { alxia } from '@alxia/core';
|
|
574
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
575
|
+
|
|
555
576
|
const app = alxia()
|
|
556
577
|
.use(secureHeaders())
|
|
557
578
|
.get('/embed', ({ reply }) =>
|
|
@@ -563,3 +584,54 @@ const app = alxia()
|
|
|
563
584
|
}),
|
|
564
585
|
);
|
|
565
586
|
```
|
|
587
|
+
|
|
588
|
+
### A `try`/`catch` middleware's reply has no headers
|
|
589
|
+
|
|
590
|
+
**When:** a middleware wraps `next()` in a `try`/`catch` to answer errors in
|
|
591
|
+
its own format, is declared before `secureHeaders()`, and its reply comes
|
|
592
|
+
back without the security headers.
|
|
593
|
+
|
|
594
|
+
**Why:** `secureHeaders` settles `next()`: it sets the headers on the response
|
|
595
|
+
the route boundary would give (the `HttpError`'s status, or a 500), then the error goes on to the middlewares around it. A `try`/`catch`
|
|
596
|
+
declared **before** it catches the error, but its reply is made outside
|
|
597
|
+
`secureHeaders`, so it carries no headers; the 500 inside did.
|
|
598
|
+
|
|
599
|
+
**Fix:** declare the error-handling middleware after `secureHeaders`, so
|
|
600
|
+
the headers are set on its reply:
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
import { alxia, defineMiddleware } from '@alxia/core';
|
|
604
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
605
|
+
|
|
606
|
+
const errors = defineMiddleware(async (_ctx, next) => {
|
|
607
|
+
try {
|
|
608
|
+
return await next();
|
|
609
|
+
} catch (error) {
|
|
610
|
+
return Response.json({ error: String(error) }, { status: 500 });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const app = alxia()
|
|
615
|
+
.use(secureHeaders()) // first
|
|
616
|
+
.use(errors)
|
|
617
|
+
.get('/boom', () => {
|
|
618
|
+
throw new Error('boom');
|
|
619
|
+
});
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### A route has no headers, though the others do
|
|
623
|
+
|
|
624
|
+
**When:** one route's responses lack the headers, while a `404` or another
|
|
625
|
+
route's have them.
|
|
626
|
+
|
|
627
|
+
**Why:** a route declared **before** `app.use(secureHeaders())` does not run
|
|
628
|
+
it. A request no route matches runs every top-level middleware wherever it
|
|
629
|
+
is declared, which is why its `404` is covered.
|
|
630
|
+
|
|
631
|
+
**Fix:** declare `secureHeaders` before the routes:
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
const app = alxia()
|
|
635
|
+
.use(secureHeaders())
|
|
636
|
+
.get('/early', ({ reply }) => reply(200, 'now covered'));
|
|
637
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/secure-headers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Secure HTTP headers for alxia: HSTS, CSP, nosniff and the rest, each one adjustable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,13 +41,12 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/
|
|
45
|
-
"@alxia/core": "^0.3.0",
|
|
44
|
+
"@alxia/core": "^0.5.0",
|
|
46
45
|
"@types/bun": "^1.4.2",
|
|
47
46
|
"zod": "^4.2.0"
|
|
48
47
|
},
|
|
49
48
|
"peerDependencies": {
|
|
50
|
-
"@alxia/core": "^0.
|
|
49
|
+
"@alxia/core": "^0.5.0",
|
|
51
50
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
52
51
|
}
|
|
53
52
|
}
|