@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 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.
@@ -0,0 +1,2 @@
1
+ export { type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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.
@@ -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
+ }