@alxia/secure-headers 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +10 -0
- package/dist/secure-headers.d.ts +32 -0
- package/dist/secure-headers.d.ts.map +1 -0
- package/docs/README.md +11 -0
- package/docs/guide.md +369 -0
- package/docs/roadmap.md +44 -0
- package/docs/troubleshooting.md +388 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Tsala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @alxia/secure-headers
|
|
2
|
+
|
|
3
|
+
Secure HTTP headers on every response of an
|
|
4
|
+
[alxia](https://www.npmjs.com/package/@alxia/core) app, 404s included, with
|
|
5
|
+
no dependency.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @alxia/secure-headers @alxia/core
|
|
9
|
+
bun add -d typescript
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
16
|
+
|
|
17
|
+
app.use(secureHeaders());
|
|
18
|
+
app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'", xFrameOptions: false }));
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A header a route sets itself is kept: a page that needs its own policy sets
|
|
22
|
+
it on its reply.
|
|
23
|
+
|
|
24
|
+
## Defaults
|
|
25
|
+
|
|
26
|
+
| header | default | option |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `Content-Security-Policy` | `default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'` | `contentSecurityPolicy` |
|
|
29
|
+
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | `strictTransportSecurity` |
|
|
30
|
+
| `X-Content-Type-Options` | `nosniff` | `xContentTypeOptions` |
|
|
31
|
+
| `X-Frame-Options` | `DENY` | `xFrameOptions` |
|
|
32
|
+
| `Referrer-Policy` | `no-referrer` | `referrerPolicy` |
|
|
33
|
+
| `Cross-Origin-Opener-Policy` | `same-origin` | `crossOriginOpenerPolicy` |
|
|
34
|
+
| `Cross-Origin-Resource-Policy` | `same-origin` | `crossOriginResourcePolicy` |
|
|
35
|
+
| `Cross-Origin-Embedder-Policy` | not sent | `crossOriginEmbedderPolicy` |
|
|
36
|
+
| `Origin-Agent-Cluster` | `?1` | `originAgentCluster` |
|
|
37
|
+
| `X-DNS-Prefetch-Control` | `off` | `xDnsPrefetchControl` |
|
|
38
|
+
| `X-Permitted-Cross-Domain-Policies` | `none` | `xPermittedCrossDomainPolicies` |
|
|
39
|
+
| `Permissions-Policy` | not sent | `permissionsPolicy` |
|
|
40
|
+
|
|
41
|
+
Each option takes a value, or `false` to leave the header out; an empty
|
|
42
|
+
value is refused at startup.
|
|
43
|
+
`X-Powered-By` and `Server` are removed unless `hidePoweredBy: false`.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
| export | |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `secureHeaders(options?)` | the plugin |
|
|
50
|
+
| `SecureHeadersOptions` | its options |
|
|
51
|
+
| `Setting` | a header option's type: its value, or `false` to leave it out |
|
|
52
|
+
|
|
53
|
+
## Documentation
|
|
54
|
+
|
|
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.
|
|
56
|
+
- [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
|
+
- [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
ADDED
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/secure-headers.ts
|
|
2
|
+
import { withHeaders } from "@alxia/core";
|
|
3
|
+
var DEFAULTS = {
|
|
4
|
+
"content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
5
|
+
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
|
6
|
+
"x-content-type-options": "nosniff",
|
|
7
|
+
"x-frame-options": "DENY",
|
|
8
|
+
"referrer-policy": "no-referrer",
|
|
9
|
+
"cross-origin-opener-policy": "same-origin",
|
|
10
|
+
"cross-origin-resource-policy": "same-origin",
|
|
11
|
+
"cross-origin-embedder-policy": false,
|
|
12
|
+
"origin-agent-cluster": "?1",
|
|
13
|
+
"x-dns-prefetch-control": "off",
|
|
14
|
+
"x-permitted-cross-domain-policies": "none",
|
|
15
|
+
"permissions-policy": false
|
|
16
|
+
};
|
|
17
|
+
var OPTION = {
|
|
18
|
+
"content-security-policy": "contentSecurityPolicy",
|
|
19
|
+
"strict-transport-security": "strictTransportSecurity",
|
|
20
|
+
"x-content-type-options": "xContentTypeOptions",
|
|
21
|
+
"x-frame-options": "xFrameOptions",
|
|
22
|
+
"referrer-policy": "referrerPolicy",
|
|
23
|
+
"cross-origin-opener-policy": "crossOriginOpenerPolicy",
|
|
24
|
+
"cross-origin-resource-policy": "crossOriginResourcePolicy",
|
|
25
|
+
"cross-origin-embedder-policy": "crossOriginEmbedderPolicy",
|
|
26
|
+
"origin-agent-cluster": "originAgentCluster",
|
|
27
|
+
"x-dns-prefetch-control": "xDnsPrefetchControl",
|
|
28
|
+
"x-permitted-cross-domain-policies": "xPermittedCrossDomainPolicies",
|
|
29
|
+
"permissions-policy": "permissionsPolicy"
|
|
30
|
+
};
|
|
31
|
+
function secureHeaders(options = {}) {
|
|
32
|
+
const headers = [];
|
|
33
|
+
for (const [name, fallback] of Object.entries(DEFAULTS)) {
|
|
34
|
+
const setting = options[OPTION[name]];
|
|
35
|
+
if (typeof setting === "string" && setting.trim() === "") {
|
|
36
|
+
throw new TypeError(`secureHeaders: ${OPTION[name]} is empty; give false to leave the ${name} header out`);
|
|
37
|
+
}
|
|
38
|
+
const value = setting === undefined ? fallback : setting;
|
|
39
|
+
if (typeof value === "string")
|
|
40
|
+
headers.push([name, value]);
|
|
41
|
+
}
|
|
42
|
+
const hide = options.hidePoweredBy ?? true;
|
|
43
|
+
return (app) => app.onResponse((response) => withHeaders(response, (current) => {
|
|
44
|
+
for (const [name, value] of headers) {
|
|
45
|
+
if (!current.has(name))
|
|
46
|
+
current.set(name, value);
|
|
47
|
+
}
|
|
48
|
+
if (hide) {
|
|
49
|
+
current.delete("x-powered-by");
|
|
50
|
+
current.delete("server");
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
secureHeaders
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
//# debugId=DD4A7A8B39A4596664756E2164756E21
|
|
59
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/secure-headers.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { type Plugin, withHeaders } from '@alxia/core';\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\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 */\nexport function secureHeaders(options: SecureHeadersOptions = {}): Plugin {\n\tconst headers: [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.push([name, value]);\n\t}\n\tconst hide = options.hidePoweredBy ?? true;\n\treturn (app) =>\n\t\tapp.onResponse((response) =>\n\t\t\twithHeaders(response, (current) => {\n\t\t\t\tfor (const [name, value] of headers) {\n\t\t\t\t\tif (!current.has(name)) current.set(name, value);\n\t\t\t\t}\n\t\t\t\tif (hide) {\n\t\t\t\t\tcurrent.delete('x-powered-by');\n\t\t\t\t\tcurrent.delete('server');\n\t\t\t\t}\n\t\t\t}),\n\t\t);\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";AAAA;AAwBA,IAAM,WAAW;AAAA,EAChB,2BACC;AAAA,EACD,6BAA6B;AAAA,EAC7B,0BAA0B;AAAA,EAC1B,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,8BAA8B;AAAA,EAC9B,gCAAgC;AAAA,EAChC,gCAAgC;AAAA,EAChC,wBAAwB;AAAA,EACxB,0BAA0B;AAAA,EAC1B,qCAAqC;AAAA,EACrC,sBAAsB;AACvB;AAEA,IAAM,SAAoE;AAAA,EACzE,2BAA2B;AAAA,EAC3B,6BAA6B;AAAA,EAC7B,0BAA0B;AAAA,EAC1B,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,8BAA8B;AAAA,EAC9B,gCAAgC;AAAA,EAChC,gCAAgC;AAAA,EAChC,wBAAwB;AAAA,EACxB,0BAA0B;AAAA,EAC1B,qCAAqC;AAAA,EACrC,sBAAsB;AACvB;AAWO,SAAS,aAAa,CAAC,UAAgC,CAAC,GAAW;AAAA,EACzE,MAAM,UAA8B,CAAC;AAAA,EACrC,YAAY,MAAM,aAAa,OAAO,QAAQ,QAAQ,GAGjD;AAAA,IACJ,MAAM,UAAU,QAAQ,OAAO;AAAA,IAC/B,IAAI,OAAO,YAAY,YAAY,QAAQ,KAAK,MAAM,IAAI;AAAA,MAEzD,MAAM,IAAI,UACT,kBAAkB,OAAO,2CAA2C,iBACrE;AAAA,IACD;AAAA,IACA,MAAM,QAAQ,YAAY,YAAY,WAAW;AAAA,IACjD,IAAI,OAAO,UAAU;AAAA,MAAU,QAAQ,KAAK,CAAC,MAAM,KAAK,CAAC;AAAA,EAC1D;AAAA,EACA,MAAM,OAAO,QAAQ,iBAAiB;AAAA,EACtC,OAAO,CAAC,QACP,IAAI,WAAW,CAAC,aACf,YAAY,UAAU,CAAC,YAAY;AAAA,IAClC,YAAY,MAAM,UAAU,SAAS;AAAA,MACpC,IAAI,CAAC,QAAQ,IAAI,IAAI;AAAA,QAAG,QAAQ,IAAI,MAAM,KAAK;AAAA,IAChD;AAAA,IACA,IAAI,MAAM;AAAA,MACT,QAAQ,OAAO,cAAc;AAAA,MAC7B,QAAQ,OAAO,QAAQ;AAAA,IACxB;AAAA,GACA,CACF;AAAA;",
|
|
8
|
+
"debugId": "DD4A7A8B39A4596664756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Plugin } from '@alxia/core';
|
|
2
|
+
/** A header's value, or `false` to leave it out. An empty value is refused. */
|
|
3
|
+
export type Setting = string | false;
|
|
4
|
+
export interface SecureHeadersOptions {
|
|
5
|
+
/** Defaults to a policy for an API: nothing loads, nothing frames it. */
|
|
6
|
+
readonly contentSecurityPolicy?: Setting;
|
|
7
|
+
readonly strictTransportSecurity?: Setting;
|
|
8
|
+
readonly xContentTypeOptions?: Setting;
|
|
9
|
+
readonly xFrameOptions?: Setting;
|
|
10
|
+
readonly referrerPolicy?: Setting;
|
|
11
|
+
readonly crossOriginOpenerPolicy?: Setting;
|
|
12
|
+
readonly crossOriginResourcePolicy?: Setting;
|
|
13
|
+
readonly crossOriginEmbedderPolicy?: Setting;
|
|
14
|
+
readonly originAgentCluster?: Setting;
|
|
15
|
+
readonly xDnsPrefetchControl?: Setting;
|
|
16
|
+
readonly xPermittedCrossDomainPolicies?: Setting;
|
|
17
|
+
/** Off by default: a policy is the app's to write. */
|
|
18
|
+
readonly permissionsPolicy?: Setting;
|
|
19
|
+
/** Whether `X-Powered-By` and `Server` are removed. On by default. */
|
|
20
|
+
readonly hidePoweredBy?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Secure headers on every response, as a plugin. A header a route set
|
|
24
|
+
* itself is kept: a page that needs its own `Content-Security-Policy` —
|
|
25
|
+
* `@alxia/openapi`'s reference page — sets it.
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'" }));
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function secureHeaders(options?: SecureHeadersOptions): Plugin;
|
|
32
|
+
//# sourceMappingURL=secure-headers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAe,MAAM,aAAa,CAAC;AAEvD,+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;AAiCD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,MAAM,CA6BxE"}
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @alxia/secure-headers documentation
|
|
2
|
+
|
|
3
|
+
The [package README](../README.md) is the short version. This folder is
|
|
4
|
+
the long one: every option with its default, which responses get the
|
|
5
|
+
headers, and what to do when a browser refuses what they forbid.
|
|
6
|
+
|
|
7
|
+
| Page | Read it when |
|
|
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 |
|
|
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
|
+
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
package/docs/guide.md
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# Guide
|
|
2
|
+
|
|
3
|
+
This page covers `secureHeaders`: what it sends, how to change or drop each
|
|
4
|
+
header, which responses it reaches, and how a page or another plugin sets
|
|
5
|
+
its own policy.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { alxia } from '@alxia/core';
|
|
9
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
10
|
+
|
|
11
|
+
const app = alxia()
|
|
12
|
+
.use(secureHeaders())
|
|
13
|
+
.get('/health', ({ reply }) => reply(200, 'ok'));
|
|
14
|
+
|
|
15
|
+
app.listen(3000);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Every response of that app now carries the headers below, and none carries
|
|
19
|
+
`X-Powered-By` or `Server`.
|
|
20
|
+
|
|
21
|
+
## The signature
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
function secureHeaders(options?: SecureHeadersOptions): Plugin;
|
|
25
|
+
|
|
26
|
+
interface SecureHeadersOptions {
|
|
27
|
+
readonly contentSecurityPolicy?: string | false;
|
|
28
|
+
readonly strictTransportSecurity?: string | false;
|
|
29
|
+
readonly xContentTypeOptions?: string | false;
|
|
30
|
+
readonly xFrameOptions?: string | false;
|
|
31
|
+
readonly referrerPolicy?: string | false;
|
|
32
|
+
readonly crossOriginOpenerPolicy?: string | false;
|
|
33
|
+
readonly crossOriginResourcePolicy?: string | false;
|
|
34
|
+
readonly crossOriginEmbedderPolicy?: string | false;
|
|
35
|
+
readonly originAgentCluster?: string | false;
|
|
36
|
+
readonly xDnsPrefetchControl?: string | false;
|
|
37
|
+
readonly xPermittedCrossDomainPolicies?: string | false;
|
|
38
|
+
readonly permissionsPolicy?: string | false;
|
|
39
|
+
readonly hidePoweredBy?: boolean;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`secureHeaders` returns a function `Plugin` from `@alxia/core`: give it to
|
|
44
|
+
`use`, called, and the app keeps its type. It adds one global `onResponse`
|
|
45
|
+
hook, so where it sits in the chain does not matter for which routes it
|
|
46
|
+
reaches: every route, declared before or after it, is covered. Used inside
|
|
47
|
+
a `group`, it still covers the whole app — a group's global hooks are the
|
|
48
|
+
app's. To vary a header for some routes, set it on their replies
|
|
49
|
+
([below](#a-header-a-route-sets-is-kept)).
|
|
50
|
+
|
|
51
|
+
The options are read once, when `secureHeaders(…)` is called; the header
|
|
52
|
+
values are fixed from then on.
|
|
53
|
+
|
|
54
|
+
## The options
|
|
55
|
+
|
|
56
|
+
Each header option takes the header's value as a string, or `false` to
|
|
57
|
+
leave the header out. An option left out keeps the default.
|
|
58
|
+
|
|
59
|
+
| Option | Header | Default | What the default does |
|
|
60
|
+
| --- | --- | --- | --- |
|
|
61
|
+
| `contentSecurityPolicy` | `Content-Security-Policy` | `default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'` | a policy for an API: the response may load nothing, set no `<base>`, post no form, and be framed by no page |
|
|
62
|
+
| `strictTransportSecurity` | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | over HTTPS, the browser uses HTTPS for this host and its subdomains for a year |
|
|
63
|
+
| `xContentTypeOptions` | `X-Content-Type-Options` | `nosniff` | the browser trusts `Content-Type` and does not guess |
|
|
64
|
+
| `xFrameOptions` | `X-Frame-Options` | `DENY` | no page may frame the response, for browsers that predate `frame-ancestors` |
|
|
65
|
+
| `referrerPolicy` | `Referrer-Policy` | `no-referrer` | links and requests from the response send no `Referer` |
|
|
66
|
+
| `crossOriginOpenerPolicy` | `Cross-Origin-Opener-Policy` | `same-origin` | a window of another origin opened from, or opening, this one gets no handle on it |
|
|
67
|
+
| `crossOriginResourcePolicy` | `Cross-Origin-Resource-Policy` | `same-origin` | another origin cannot embed the response with `<img>`, `<script>` or a `no-cors` fetch |
|
|
68
|
+
| `crossOriginEmbedderPolicy` | `Cross-Origin-Embedder-Policy` | not sent | — |
|
|
69
|
+
| `originAgentCluster` | `Origin-Agent-Cluster` | `?1` | the browser isolates the origin in its own agent cluster |
|
|
70
|
+
| `xDnsPrefetchControl` | `X-DNS-Prefetch-Control` | `off` | the browser does not resolve the response's links ahead of time |
|
|
71
|
+
| `xPermittedCrossDomainPolicies` | `X-Permitted-Cross-Domain-Policies` | `none` | Adobe clients load no cross-domain policy file |
|
|
72
|
+
| `permissionsPolicy` | `Permissions-Policy` | not sent | — the policy is the app's to write |
|
|
73
|
+
|
|
74
|
+
| Option | Type | Default | Effect |
|
|
75
|
+
| --- | --- | --- | --- |
|
|
76
|
+
| `hidePoweredBy` | `boolean` | `true` | removes `X-Powered-By` and `Server` from every response, a route's own included |
|
|
77
|
+
|
|
78
|
+
### A value
|
|
79
|
+
|
|
80
|
+
A string replaces the default as it is, with no parsing: write the header
|
|
81
|
+
exactly as it should go out.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
app.use(
|
|
85
|
+
secureHeaders({
|
|
86
|
+
contentSecurityPolicy: "default-src 'self'; img-src 'self' data:",
|
|
87
|
+
referrerPolicy: 'strict-origin-when-cross-origin',
|
|
88
|
+
permissionsPolicy: 'camera=(), microphone=(), geolocation=()',
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `false`
|
|
94
|
+
|
|
95
|
+
`false` leaves the header out of every response the plugin touches.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
app.use(secureHeaders({ xFrameOptions: false, strictTransportSecurity: false }));
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
An empty string is refused: `secureHeaders({ xFrameOptions: '' })` throws a
|
|
102
|
+
`TypeError` at startup
|
|
103
|
+
([troubleshooting](troubleshooting.md#typeerror-secureheaders--is-empty-give-false-to-leave-the--header-out)).
|
|
104
|
+
Use `false` to drop a header.
|
|
105
|
+
|
|
106
|
+
### The defaults, explicitly
|
|
107
|
+
|
|
108
|
+
With `exactOptionalPropertyTypes` on, an option set to `undefined` is a
|
|
109
|
+
compile error; with it off, `undefined` means the default. To keep the
|
|
110
|
+
default under a condition, leave the key out:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const production = Bun.env.NODE_ENV === 'production';
|
|
114
|
+
|
|
115
|
+
app.use(
|
|
116
|
+
secureHeaders({
|
|
117
|
+
...(production ? {} : { strictTransportSecurity: false }),
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `hidePoweredBy`
|
|
123
|
+
|
|
124
|
+
On by default: `X-Powered-By` and `Server` are deleted from the response,
|
|
125
|
+
even when a route set them.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const app = alxia()
|
|
129
|
+
.use(secureHeaders())
|
|
130
|
+
.get('/', ({ reply }) => reply(200, 'hi', { headers: { 'x-powered-by': 'php' } }));
|
|
131
|
+
|
|
132
|
+
(await app.request('/')).headers.get('x-powered-by'); // null
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`hidePoweredBy: false` keeps whatever the route or another hook sent.
|
|
136
|
+
|
|
137
|
+
## A header a route sets is kept
|
|
138
|
+
|
|
139
|
+
The plugin only fills in a header the response does not have yet. A route
|
|
140
|
+
that needs its own policy sets it on its reply, and the other defaults
|
|
141
|
+
still apply:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { alxia } from '@alxia/core';
|
|
145
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
146
|
+
|
|
147
|
+
const app = alxia()
|
|
148
|
+
.use(secureHeaders({ referrerPolicy: 'same-origin', xFrameOptions: false }))
|
|
149
|
+
.get('/page', ({ reply }) =>
|
|
150
|
+
reply(200, '<p>hi</p>', {
|
|
151
|
+
headers: { 'content-security-policy': "default-src 'self'" },
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const response = await app.request('/page');
|
|
156
|
+
response.headers.get('content-security-policy'); // "default-src 'self'" — the route's
|
|
157
|
+
response.headers.get('x-content-type-options'); // 'nosniff' — the default
|
|
158
|
+
response.headers.get('referrer-policy'); // 'same-origin' — the option
|
|
159
|
+
response.headers.get('x-frame-options'); // null — false
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`set.headers` in a handler or a `derive` works the same way, since it ends
|
|
163
|
+
up on the response before the plugin reads it.
|
|
164
|
+
|
|
165
|
+
That is how `@alxia/openapi`'s reference page and `@alxia/graphql`'s IDE
|
|
166
|
+
load under the strict default: each sets the `Content-Security-Policy` it
|
|
167
|
+
needs, and `secureHeaders` keeps it.
|
|
168
|
+
|
|
169
|
+
## Which responses get the headers
|
|
170
|
+
|
|
171
|
+
The plugin runs as an `onResponse` hook, so it reaches what the app's
|
|
172
|
+
`onResponse` hooks reach:
|
|
173
|
+
|
|
174
|
+
| Response | Headers |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| a route's reply, `static` and `file` included | yes |
|
|
177
|
+
| the core's 400, 404, 405 and 500 | yes |
|
|
178
|
+
| a `Response` an `onRequest` hook returned (a CORS preflight, a redirect) | yes |
|
|
179
|
+
| a WebSocket upgrade that succeeds (`101`) | no: `onResponse` hooks do not run for it |
|
|
180
|
+
| a page served with `page()` | no: `Bun.serve` serves it, around no hook |
|
|
181
|
+
| the 500 sent when an `around` hook itself throws | no |
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const app = alxia().use(secureHeaders());
|
|
185
|
+
|
|
186
|
+
const response = await app.request('/nope');
|
|
187
|
+
response.status; // 404
|
|
188
|
+
response.headers.get('x-content-type-options'); // 'nosniff'
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Order with other `onResponse` hooks
|
|
192
|
+
|
|
193
|
+
`onResponse` hooks run in the order they are declared, and the plugin never
|
|
194
|
+
overwrites a header that is already there. So:
|
|
195
|
+
|
|
196
|
+
- a hook declared **before** `secureHeaders` that sets one of its headers
|
|
197
|
+
wins;
|
|
198
|
+
- a hook declared **after** it that sets one of its headers wins too, by
|
|
199
|
+
overwriting;
|
|
200
|
+
- a hook declared **after** it that adds `X-Powered-By` or `Server` keeps
|
|
201
|
+
it: the plugin deleted them before that hook ran.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { alxia, withHeaders } from '@alxia/core';
|
|
205
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
206
|
+
|
|
207
|
+
const app = alxia()
|
|
208
|
+
.onResponse((response) =>
|
|
209
|
+
withHeaders(response, (headers) => headers.set('referrer-policy', 'origin')),
|
|
210
|
+
)
|
|
211
|
+
.use(secureHeaders())
|
|
212
|
+
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
213
|
+
|
|
214
|
+
(await app.request('/')).headers.get('referrer-policy'); // 'origin'
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## A header the plugin does not know
|
|
218
|
+
|
|
219
|
+
The plugin sends the twelve headers above and nothing else. Another one —
|
|
220
|
+
`Content-Security-Policy-Report-Only` while a policy is being tried, say —
|
|
221
|
+
is a hook of your own, with `withHeaders` from `@alxia/core`:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { alxia, withHeaders } from '@alxia/core';
|
|
225
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
226
|
+
|
|
227
|
+
const app = alxia()
|
|
228
|
+
.use(secureHeaders())
|
|
229
|
+
.onResponse((response) =>
|
|
230
|
+
withHeaders(response, (headers) => {
|
|
231
|
+
if (!headers.has('content-security-policy-report-only')) {
|
|
232
|
+
headers.set(
|
|
233
|
+
'content-security-policy-report-only',
|
|
234
|
+
"default-src 'self'; report-to csp",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
See [Hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md#onresponse)
|
|
242
|
+
in the core's guide for what an `onResponse` hook may do.
|
|
243
|
+
|
|
244
|
+
## Recipes
|
|
245
|
+
|
|
246
|
+
### An app that serves HTML too
|
|
247
|
+
|
|
248
|
+
The default policy blocks everything a page loads. Loosen it for the whole
|
|
249
|
+
app when it serves its own pages, and keep framing refused:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
app.use(
|
|
253
|
+
secureHeaders({
|
|
254
|
+
contentSecurityPolicy:
|
|
255
|
+
"default-src 'self'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Or keep the strict default for the API and give the pages their policy on
|
|
261
|
+
their reply, as in [A header a route sets is kept](#a-header-a-route-sets-is-kept).
|
|
262
|
+
|
|
263
|
+
### Framed by your own origin
|
|
264
|
+
|
|
265
|
+
Both `frame-ancestors` in the policy and `X-Frame-Options` refuse framing;
|
|
266
|
+
change both:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
app.use(
|
|
270
|
+
secureHeaders({
|
|
271
|
+
contentSecurityPolicy: "default-src 'none'; frame-ancestors 'self'",
|
|
272
|
+
xFrameOptions: 'SAMEORIGIN',
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Images or files embedded by another origin
|
|
278
|
+
|
|
279
|
+
`Cross-Origin-Resource-Policy: same-origin` stops another origin's
|
|
280
|
+
`<img>`, `<script>` or `no-cors` fetch from reading the response. A
|
|
281
|
+
CORS `fetch` is not affected. For a public asset host:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
app.use(secureHeaders({ crossOriginResourcePolicy: 'cross-origin' }));
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Sign-in in a popup
|
|
288
|
+
|
|
289
|
+
`Cross-Origin-Opener-Policy: same-origin` cuts the link between a page and
|
|
290
|
+
a popup of another origin, which a sign-in popup that reports back through
|
|
291
|
+
`window.opener` needs:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
app.use(secureHeaders({ crossOriginOpenerPolicy: 'same-origin-allow-popups' }));
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Cross-origin isolation
|
|
298
|
+
|
|
299
|
+
`SharedArrayBuffer` and precise timers need the page to be cross-origin
|
|
300
|
+
isolated, which takes `Cross-Origin-Embedder-Policy` beside the default
|
|
301
|
+
`Cross-Origin-Opener-Policy: same-origin`:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
app.use(secureHeaders({ crossOriginEmbedderPolicy: 'require-corp' }));
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## In a real app
|
|
308
|
+
|
|
309
|
+
An API with a sign-in page, its own pages' policy, HSTS only in production,
|
|
310
|
+
and a test that holds the headers in place:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
// app.ts
|
|
314
|
+
import { alxia } from '@alxia/core';
|
|
315
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
316
|
+
|
|
317
|
+
const production = Bun.env.NODE_ENV === 'production';
|
|
318
|
+
const PAGE_POLICY =
|
|
319
|
+
"default-src 'self'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
|
|
320
|
+
|
|
321
|
+
export const app = alxia()
|
|
322
|
+
.use(
|
|
323
|
+
secureHeaders({
|
|
324
|
+
permissionsPolicy: 'camera=(), microphone=(), geolocation=()',
|
|
325
|
+
...(production ? {} : { strictTransportSecurity: false }),
|
|
326
|
+
}),
|
|
327
|
+
)
|
|
328
|
+
.get('/api/me', ({ reply }) => reply(200, { name: 'Ada' }))
|
|
329
|
+
.get('/sign-in', ({ reply }) =>
|
|
330
|
+
reply(200, '<form method="post" action="/sign-in">…</form>', {
|
|
331
|
+
headers: {
|
|
332
|
+
'content-type': 'text/html; charset=utf-8',
|
|
333
|
+
'content-security-policy': PAGE_POLICY,
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
// app.spec.ts
|
|
341
|
+
import { expect, test } from 'bun:test';
|
|
342
|
+
import { app } from './app';
|
|
343
|
+
|
|
344
|
+
test('the API keeps the strict policy', async () => {
|
|
345
|
+
const response = await app.request('/api/me');
|
|
346
|
+
expect(response.headers.get('content-security-policy')).toStartWith("default-src 'none'");
|
|
347
|
+
expect(response.headers.get('x-frame-options')).toBe('DENY');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('the sign-in page has its own', async () => {
|
|
351
|
+
const response = await app.request('/sign-in');
|
|
352
|
+
expect(response.headers.get('content-security-policy')).toContain("form-action 'self'");
|
|
353
|
+
expect(response.headers.get('permissions-policy')).toBe('camera=(), microphone=(), geolocation=()');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('a 404 is covered too', async () => {
|
|
357
|
+
const response = await app.request('/nope');
|
|
358
|
+
expect(response.status).toBe(404);
|
|
359
|
+
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## See also
|
|
364
|
+
|
|
365
|
+
- [Troubleshooting](troubleshooting.md): a browser refuses something, or
|
|
366
|
+
TypeScript does.
|
|
367
|
+
- [Hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md)
|
|
368
|
+
and [Groups and plugins](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md)
|
|
369
|
+
in the core's guide.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@alxia/secure-headers` gives an app, and what is coming. This page is
|
|
4
|
+
a direction, not a commitment: the version something shipped in is the only
|
|
5
|
+
number on it. Every release, with each change it made, is in
|
|
6
|
+
[`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/secure-headers/CHANGELOG.md).
|
|
7
|
+
|
|
8
|
+
## Now
|
|
9
|
+
|
|
10
|
+
Nothing scheduled yet.
|
|
11
|
+
|
|
12
|
+
## Next
|
|
13
|
+
|
|
14
|
+
Nothing scheduled yet.
|
|
15
|
+
|
|
16
|
+
## Later
|
|
17
|
+
|
|
18
|
+
Nothing scheduled yet.
|
|
19
|
+
|
|
20
|
+
## Not planned
|
|
21
|
+
|
|
22
|
+
- **A runtime dependency.** The package installs nothing beside itself: the
|
|
23
|
+
headers are written on `@alxia/core`'s public API, its peer, so adding it
|
|
24
|
+
to an app adds no transitive package to audit or update.
|
|
25
|
+
|
|
26
|
+
## Shipped
|
|
27
|
+
|
|
28
|
+
### 0.1.0
|
|
29
|
+
|
|
30
|
+
- **Secure headers on every response.** `secureHeaders()` is a plugin for
|
|
31
|
+
`use` that sends `Content-Security-Policy`, `Strict-Transport-Security`,
|
|
32
|
+
`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
|
33
|
+
`Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
|
|
34
|
+
`Origin-Agent-Cluster`, `X-DNS-Prefetch-Control` and
|
|
35
|
+
`X-Permitted-Cross-Domain-Policies` with strict defaults for an API, on
|
|
36
|
+
every response — 404s, 405s and 500s included.
|
|
37
|
+
- **Each header adjustable.** Every option takes the header's value, or
|
|
38
|
+
`false` to leave it out, and an empty value is refused at startup;
|
|
39
|
+
`Cross-Origin-Embedder-Policy` and `Permissions-Policy` are sent only when
|
|
40
|
+
given.
|
|
41
|
+
- **A route's own header wins.** A header a route sets on its reply is kept,
|
|
42
|
+
so a page that needs its own policy sets it there.
|
|
43
|
+
- **No `X-Powered-By` or `Server`.** Both are removed from every response,
|
|
44
|
+
unless `hidePoweredBy: false`.
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Each entry is headed by the text you see: a TypeScript error, an exception,
|
|
4
|
+
a browser console message, or a header that is or is not on a response.
|
|
5
|
+
`secureHeaders` itself throws nothing; what goes wrong is a type, a
|
|
6
|
+
browser refusing what a header forbids, or a header in the wrong place.
|
|
7
|
+
|
|
8
|
+
**Types**
|
|
9
|
+
|
|
10
|
+
- [`Type 'true' is not assignable to type 'Setting | undefined'`](#type-true-is-not-assignable-to-type-setting--undefined)
|
|
11
|
+
- [`… is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`](#-is-not-assignable-to-parameter-of-type-secureheadersoptions-with-exactoptionalpropertytypes-true)
|
|
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` … `has no properties in common with type 'SecureHeadersOptions'`](#no-overload-matches-this-call--has-no-properties-in-common-with-type-secureheadersoptions)
|
|
14
|
+
|
|
15
|
+
**Runtime**
|
|
16
|
+
|
|
17
|
+
- [`TypeError: secureHeaders: … is empty; give false to leave the … header out`](#typeerror-secureheaders--is-empty-give-false-to-leave-the--header-out)
|
|
18
|
+
- [`TypeError: alxia().use(secureHeaders).get is not a function`](#typeerror-alxiausesecureheadersget-is-not-a-function)
|
|
19
|
+
|
|
20
|
+
**In the browser**
|
|
21
|
+
|
|
22
|
+
- [`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
|
+
- [`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)
|
|
24
|
+
- [`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
|
+
- [`net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin`](#neterr_blocked_by_responsenotsameorigin)
|
|
26
|
+
- [`window.opener` is `null` in a sign-in popup](#windowopener-is-null-in-a-sign-in-popup)
|
|
27
|
+
- [A subdomain on plain HTTP no longer opens](#a-subdomain-on-plain-http-no-longer-opens)
|
|
28
|
+
|
|
29
|
+
**Headers**
|
|
30
|
+
|
|
31
|
+
- [An option has no effect on a response](#an-option-has-no-effect-on-a-response)
|
|
32
|
+
- [`X-Powered-By` or `Server` is still sent](#x-powered-by-or-server-is-still-sent)
|
|
33
|
+
- [A response arrives without the headers](#a-response-arrives-without-the-headers)
|
|
34
|
+
- [Routes outside a group get the headers](#routes-outside-a-group-get-the-headers)
|
|
35
|
+
|
|
36
|
+
## Types
|
|
37
|
+
|
|
38
|
+
### `Type 'true' is not assignable to type 'Setting | undefined'`
|
|
39
|
+
|
|
40
|
+
**When:** a header option is given `true`, an object or an array.
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
error TS2322: Type 'true' is not assignable to type 'Setting | undefined'.
|
|
44
|
+
error TS2322: Type '{ defaultSrc: string[]; }' is not assignable to type 'Setting | undefined'.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`hidePoweredBy` given anything but a boolean is the same error, against
|
|
48
|
+
`'boolean | undefined'`.
|
|
49
|
+
|
|
50
|
+
**Why:** a header option is the header's value as a string, or `false` to
|
|
51
|
+
leave it out (`Setting`, exported, is `string | false`). There is no
|
|
52
|
+
`true` — leaving the key out is the default — and no object form for a
|
|
53
|
+
policy.
|
|
54
|
+
|
|
55
|
+
**Fix:** write the header as it goes out, or leave the key out:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
app.use(
|
|
59
|
+
secureHeaders({
|
|
60
|
+
contentSecurityPolicy: "default-src 'self'; img-src 'self' data:",
|
|
61
|
+
// xFrameOptions: true → leave it out for 'DENY'
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### `… is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`
|
|
67
|
+
|
|
68
|
+
**When:** an option is set to `undefined`, often from a condition, with
|
|
69
|
+
`exactOptionalPropertyTypes` on.
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
error TS2379: Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Why:** the options are optional, not `undefined`-able. Under that flag a
|
|
76
|
+
key present with `undefined` is not a key left out.
|
|
77
|
+
|
|
78
|
+
**Fix:** spread the key in only when it has a value:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const production = Bun.env.NODE_ENV === 'production';
|
|
82
|
+
|
|
83
|
+
app.use(secureHeaders({ ...(production ? {} : { strictTransportSecurity: false }) }));
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions'`
|
|
87
|
+
|
|
88
|
+
**When:** an option names a header the plugin does not send, or uses
|
|
89
|
+
another library's name for one.
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
error TS2353: Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions'.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Why:** the options are the twelve headers in the
|
|
96
|
+
[guide](guide.md#the-options), plus `hidePoweredBy`. `X-Powered-By` and
|
|
97
|
+
`Server` are governed by `hidePoweredBy`; anything else —
|
|
98
|
+
`Content-Security-Policy-Report-Only`, `X-XSS-Protection` — is not sent by
|
|
99
|
+
the plugin.
|
|
100
|
+
|
|
101
|
+
**Fix:** `hidePoweredBy` for those two; a hook of your own for another
|
|
102
|
+
header:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { alxia, withHeaders } from '@alxia/core';
|
|
106
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
107
|
+
|
|
108
|
+
const app = alxia()
|
|
109
|
+
.use(secureHeaders({ hidePoweredBy: true }))
|
|
110
|
+
.onResponse((response) =>
|
|
111
|
+
withHeaders(response, (headers) =>
|
|
112
|
+
headers.set('content-security-policy-report-only', "default-src 'self'"),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `No overload matches this call` … `has no properties in common with type 'SecureHeadersOptions'`
|
|
118
|
+
|
|
119
|
+
**When:** `secureHeaders` is given to `use` without being called.
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
error TS2769: No overload matches this call.
|
|
123
|
+
Overload 1 of 2, '(plugin: (app: Alxia<Empty, Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
|
|
124
|
+
Argument of type '(options?: SecureHeadersOptions | undefined) => Plugin' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => AnyAlxia'.
|
|
125
|
+
Types of parameters 'options' and 'app' are incompatible.
|
|
126
|
+
Type 'Alxia<Empty, Empty, "", never>' has no properties in common with type 'SecureHeadersOptions'.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Why:** `secureHeaders` makes the plugin from its options; the plugin is
|
|
130
|
+
what it returns.
|
|
131
|
+
|
|
132
|
+
**Fix:** call it, with or without options:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
app.use(secureHeaders());
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Runtime
|
|
139
|
+
|
|
140
|
+
### `TypeError: secureHeaders: … is empty; give false to leave the … header out`
|
|
141
|
+
|
|
142
|
+
**When:** `secureHeaders()` is called with a header option set to `''`, or
|
|
143
|
+
to spaces only, usually to turn a header off. It throws at once, so the app
|
|
144
|
+
fails at startup:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
TypeError: secureHeaders: xFrameOptions is empty; give false to leave the x-frame-options header out
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Why:** a header with an empty value says nothing a browser can act on.
|
|
151
|
+
Only `false` leaves a header out.
|
|
152
|
+
|
|
153
|
+
**Fix:**
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
app.use(secureHeaders({ xFrameOptions: false }));
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `TypeError: alxia().use(secureHeaders).get is not a function`
|
|
160
|
+
|
|
161
|
+
**When:** the same mistake as above in JavaScript, or past a cast: the
|
|
162
|
+
chain breaks on the next method after `use(secureHeaders)`.
|
|
163
|
+
|
|
164
|
+
**Why:** `use` calls the function with the app. Uncalled, `secureHeaders`
|
|
165
|
+
reads the app as its options and returns a plugin, not the app, so the
|
|
166
|
+
next `.get` is called on a function.
|
|
167
|
+
|
|
168
|
+
**Fix:**
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const app = alxia()
|
|
172
|
+
.use(secureHeaders())
|
|
173
|
+
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## In the browser
|
|
177
|
+
|
|
178
|
+
These messages are Chrome's wording; Firefox and Safari report the same
|
|
179
|
+
refusal in their own words.
|
|
180
|
+
|
|
181
|
+
### `Refused to load the … because it violates the following Content Security Policy directive: "default-src 'none'"`
|
|
182
|
+
|
|
183
|
+
**When:** the app serves an HTML page, and the page's styles, scripts,
|
|
184
|
+
images or fonts do not load. Chrome adds `Note that 'style-src-elem' was
|
|
185
|
+
not explicitly set, so 'default-src' is used as a fallback.` or similar.
|
|
186
|
+
Inline code reads `Refused to execute inline script …` or
|
|
187
|
+
`Refused to apply inline style …`.
|
|
188
|
+
|
|
189
|
+
**Why:** the default `Content-Security-Policy` is for an API:
|
|
190
|
+
`default-src 'none'` lets a response load nothing.
|
|
191
|
+
|
|
192
|
+
**Fix:** give the pages a policy on their reply, and keep the strict one for
|
|
193
|
+
the API:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
app.get('/', ({ reply }) =>
|
|
197
|
+
reply(200, html, {
|
|
198
|
+
headers: {
|
|
199
|
+
'content-type': 'text/html; charset=utf-8',
|
|
200
|
+
'content-security-policy': "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'",
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
or set `contentSecurityPolicy` for the whole app. See the
|
|
207
|
+
[guide](guide.md#an-app-that-serves-html-too).
|
|
208
|
+
|
|
209
|
+
### `Refused to send form data to '…' because it violates the following Content Security Policy directive: "form-action 'none'"`
|
|
210
|
+
|
|
211
|
+
**When:** a `<form>` on a page the app serves is submitted, and nothing
|
|
212
|
+
happens.
|
|
213
|
+
|
|
214
|
+
**Why:** the default policy has `form-action 'none'`: the page may post no
|
|
215
|
+
form, even to itself.
|
|
216
|
+
|
|
217
|
+
**Fix:** allow your origin in that page's policy:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
const PAGE_POLICY = "default-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'";
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### `Refused to frame '…' because an ancestor violates the following Content Security Policy directive: "frame-ancestors 'none'"`
|
|
224
|
+
|
|
225
|
+
**When:** a page puts a response of the app in an `<iframe>`. With the
|
|
226
|
+
policy turned off, the message becomes `Refused to display '…' in a frame
|
|
227
|
+
because it set 'X-Frame-Options' to 'deny'.`
|
|
228
|
+
|
|
229
|
+
**Why:** two headers refuse framing: `frame-ancestors 'none'` in the
|
|
230
|
+
default policy, and `X-Frame-Options: DENY`. Turning one off leaves the
|
|
231
|
+
other.
|
|
232
|
+
|
|
233
|
+
**Fix:** change both:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
app.use(
|
|
237
|
+
secureHeaders({
|
|
238
|
+
contentSecurityPolicy: "default-src 'none'; frame-ancestors 'self'",
|
|
239
|
+
xFrameOptions: 'SAMEORIGIN',
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### `net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin`
|
|
245
|
+
|
|
246
|
+
**When:** a page on another origin embeds a response of the app — an
|
|
247
|
+
`<img>`, a `<script>`, a `no-cors` fetch — and the network panel shows it
|
|
248
|
+
blocked, although it answered 200.
|
|
249
|
+
|
|
250
|
+
**Why:** `Cross-Origin-Resource-Policy: same-origin` lets only the app's own
|
|
251
|
+
origin embed its responses. A CORS `fetch` is not concerned.
|
|
252
|
+
|
|
253
|
+
**Fix:** open it for the app when it serves public assets, or for the
|
|
254
|
+
routes that need it, on their reply:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
app.use(secureHeaders({ crossOriginResourcePolicy: 'cross-origin' }));
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### `window.opener` is `null` in a sign-in popup
|
|
261
|
+
|
|
262
|
+
**When:** a page of the app opens a popup to another origin — a sign-in,
|
|
263
|
+
a payment — and the popup cannot report back, or the page sees the popup
|
|
264
|
+
as closed at once.
|
|
265
|
+
|
|
266
|
+
**Why:** `Cross-Origin-Opener-Policy: same-origin` puts the page in a
|
|
267
|
+
browsing context group of its own, cut from any window of another origin.
|
|
268
|
+
|
|
269
|
+
**Fix:**
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
app.use(secureHeaders({ crossOriginOpenerPolicy: 'same-origin-allow-popups' }));
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### A subdomain on plain HTTP no longer opens
|
|
276
|
+
|
|
277
|
+
**When:** after a browser has visited the app over HTTPS, another host
|
|
278
|
+
under the same domain, served over plain HTTP, is forced to HTTPS and fails.
|
|
279
|
+
|
|
280
|
+
**Why:** the default `Strict-Transport-Security` is
|
|
281
|
+
`max-age=31536000; includeSubDomains`: the browser remembers, for a year,
|
|
282
|
+
to use HTTPS for the host **and every subdomain**. A browser ignores the
|
|
283
|
+
header on a plain-HTTP response, so it bites only once the app is reached
|
|
284
|
+
over HTTPS.
|
|
285
|
+
|
|
286
|
+
**Fix:** drop `includeSubDomains` when a subdomain is still on HTTP, or the
|
|
287
|
+
header altogether where the app is not served over HTTPS:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
app.use(secureHeaders({ strictTransportSecurity: 'max-age=31536000' }));
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
A browser that already stored the policy keeps it until it expires, or
|
|
294
|
+
until it reads `max-age=0` from the host over HTTPS.
|
|
295
|
+
|
|
296
|
+
## Headers
|
|
297
|
+
|
|
298
|
+
### An option has no effect on a response
|
|
299
|
+
|
|
300
|
+
**When:** an option is set, and some responses still carry another value.
|
|
301
|
+
|
|
302
|
+
**Why:** the plugin never overwrites a header the response already has.
|
|
303
|
+
A route set it on its reply or on `set.headers`, an `onResponse` hook
|
|
304
|
+
declared before `secureHeaders` set it, or one declared after overwrote it.
|
|
305
|
+
`@alxia/openapi`'s reference page and `@alxia/graphql`'s IDE set their own
|
|
306
|
+
`Content-Security-Policy` on purpose.
|
|
307
|
+
|
|
308
|
+
**Fix:** find what sets it on that route, and change it there:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
const response = await app.request('/the-route');
|
|
312
|
+
console.log(response.headers.get('content-security-policy'));
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### `X-Powered-By` or `Server` is still sent
|
|
316
|
+
|
|
317
|
+
**When:** `hidePoweredBy` is on (the default), and one of them is on the
|
|
318
|
+
response.
|
|
319
|
+
|
|
320
|
+
**Why:** the plugin deletes them when its hook runs. An `onResponse` hook
|
|
321
|
+
declared after `secureHeaders` that adds one runs later, and keeps it. A
|
|
322
|
+
proxy in front of the app may add its own `Server` too.
|
|
323
|
+
|
|
324
|
+
**Fix:** declare `secureHeaders` after the hook that adds the header, or
|
|
325
|
+
stop that hook adding it:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import { alxia, withHeaders } from '@alxia/core';
|
|
329
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
330
|
+
|
|
331
|
+
const app = alxia()
|
|
332
|
+
.onResponse((response) =>
|
|
333
|
+
withHeaders(response, (headers) => headers.set('x-powered-by', 'my-app')),
|
|
334
|
+
)
|
|
335
|
+
.use(secureHeaders()); // its hook runs after, and deletes it
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### A response arrives without the headers
|
|
339
|
+
|
|
340
|
+
**When:** a response has none of the headers, though `secureHeaders` is
|
|
341
|
+
in use.
|
|
342
|
+
|
|
343
|
+
**Why:** the plugin is an `onResponse` hook, and three responses run no
|
|
344
|
+
`onResponse` hook: a WebSocket upgrade that succeeds (the `101`), a page
|
|
345
|
+
served with `page()`, which `Bun.serve` answers itself, and the 500 sent
|
|
346
|
+
when an `around` hook throws.
|
|
347
|
+
|
|
348
|
+
**Fix:** serve a page that needs the headers through a route, `file` or
|
|
349
|
+
`static` rather than `page()` — or give it its policy in its HTML with
|
|
350
|
+
`<meta http-equiv="Content-Security-Policy" content="…">`, where browsers
|
|
351
|
+
ignore `frame-ancestors`. An `around` hook whose own work may fail catches
|
|
352
|
+
it and still returns what `next()` resolved to, which already carries the
|
|
353
|
+
headers:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
app.around(async (_ctx, next) => {
|
|
357
|
+
const response = await next();
|
|
358
|
+
try {
|
|
359
|
+
recordTiming(response); // your own work
|
|
360
|
+
} catch (error) {
|
|
361
|
+
console.error(error);
|
|
362
|
+
}
|
|
363
|
+
return response;
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Routes outside a group get the headers
|
|
368
|
+
|
|
369
|
+
**When:** `secureHeaders` is used inside a `group`, meant for its routes
|
|
370
|
+
only, and every route of the app gets the headers.
|
|
371
|
+
|
|
372
|
+
**Why:** it adds a global hook, and a group's global hooks are the app's.
|
|
373
|
+
|
|
374
|
+
**Fix:** use it on the app, and give the routes that differ their own
|
|
375
|
+
values on their replies:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const app = alxia()
|
|
379
|
+
.use(secureHeaders())
|
|
380
|
+
.get('/embed', ({ reply }) =>
|
|
381
|
+
reply(200, 'ok', {
|
|
382
|
+
headers: {
|
|
383
|
+
'content-security-policy': "default-src 'none'; frame-ancestors 'self'",
|
|
384
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alxia/secure-headers",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Secure HTTP headers for alxia: HSTS, CSP, nosniff and the rest, each one adjustable",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/softistx/alxia.git",
|
|
27
|
+
"directory": "packages/secure-headers"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "bun run ../../build.ts",
|
|
35
|
+
"test": "bun test src",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"alxia": {
|
|
39
|
+
"entrypoints": [
|
|
40
|
+
"src/index.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@alxia/client": "^0.1.0",
|
|
45
|
+
"@alxia/core": "^0.1.0",
|
|
46
|
+
"@types/bun": "^1.4.2",
|
|
47
|
+
"zod": "^4.2.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@alxia/core": "^0.1.0",
|
|
51
|
+
"typescript": "^6.0.3 || ^7.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|