@alxia/secure-headers 0.4.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,96 @@ with `nonce: true`. Without `nonce: true`, the headers are what they always
71
71
  were. With `@alxia/react-router`, `nonceOf(loadContext)` hands it to
72
72
  `entry.server.tsx` ([its README](https://github.com/softistx/alxia/tree/develop/packages/react-router#a-csp-nonce)).
73
73
 
74
+ ## A policy from directives
75
+
76
+ `csp()` writes the policy from directives, in the order you list them, and
77
+ checks it once at startup. A list of sources is an array, `true` is a bare
78
+ directive and `false` leaves one out; `NONCE` passes through, for
79
+ `nonce: true` to replace on each request.
80
+
81
+ ```ts
82
+ import { csp, NONCE, secureHeaders } from '@alxia/secure-headers';
83
+
84
+ app.use(
85
+ secureHeaders({
86
+ nonce: true,
87
+ contentSecurityPolicy: csp({
88
+ 'default-src': ["'self'"],
89
+ 'script-src': ["'self'", NONCE],
90
+ 'object-src': ["'none'"],
91
+ 'upgrade-insecure-requests': true,
92
+ }),
93
+ }),
94
+ );
95
+ // default-src 'self'; script-src 'self' 'nonce-…'; object-src 'none'; upgrade-insecure-requests
96
+ ```
97
+
98
+ An empty source list, a source holding `;`, `,` or a space, a name that is
99
+ not lowercase letters, digits and hyphens starting with a letter, and a policy with no directive are
100
+ refused with a `TypeError` that starts `csp():`.
101
+
102
+ ## Clearing the browser on sign-out
103
+
104
+ ```ts
105
+ import { clearSiteData } from '@alxia/secure-headers';
106
+
107
+ const cleared = clearSiteData(['cache']);
108
+
109
+ app.use('/sign-out', cleared).use('/sign-out.data', cleared);
110
+ ```
111
+
112
+ `Clear-Site-Data: "cache"` is sent on a 2xx or 3xx answer to a `POST`, so
113
+ nothing of the visit stays in the browser's HTTP cache on a shared device. A
114
+ GET, a 400, a 403 or a 500 clears nothing. `{ methods: ['POST', 'DELETE'] }`
115
+ widens the rule. A route's own `Clear-Site-Data` is kept. React Router's client-side sign-out posts `/sign-out.data`,
116
+ so mount both paths. An empty list or an unknown type is refused with a
117
+ `TypeError` that starts `clearSiteData():`.
118
+
119
+ ## Pages
120
+
121
+ `pageHeaders()` is `secureHeaders` for server-rendered pages: a strict policy
122
+ with a nonce per request, HSTS and `upgrade-insecure-requests` only over
123
+ HTTPS, `Referrer-Policy: strict-origin-when-cross-origin`, and
124
+ `Cache-Control: private, no-store`, since a cache must never store a nonce.
125
+
126
+ ```ts
127
+ import { createServer } from '@alxia/react-router';
128
+ import { pageHeaders } from '@alxia/secure-headers';
129
+
130
+ export default createServer({
131
+ configure: (app) => app.use(pageHeaders({ https: import.meta.env.PROD })),
132
+ });
133
+ ```
134
+
135
+ `https` is required and never read from the environment. `PAGE_DIRECTIVES`
136
+ is the policy: `'self'` for everything, scripts only with the nonce (and what
137
+ they load, `'strict-dynamic'`), nonced `<style>` elements, no `<base>`, forms
138
+ to this site, no framing. `directives` merges over it: a list replaces a
139
+ directive's sources, `false` removes the directive.
140
+
141
+ ```ts
142
+ app.use(
143
+ pageHeaders({
144
+ https: true,
145
+ directives: {
146
+ 'img-src': ["'self'", 'https://images.example'],
147
+ 'manifest-src': false,
148
+ },
149
+ }),
150
+ );
151
+ ```
152
+
153
+ Any other `secureHeaders` option passes through and wins over the preset's;
154
+ `contentSecurityPolicy` and `nonce` are not options here, and an explicit
155
+ `undefined` keeps the preset's value. A `script-src`, or a `script-src-elem`
156
+ list, without `NONCE` is refused with a `TypeError` that starts
157
+ `pageHeaders():`; so is removing `default-src`, `base-uri` or `object-src`.
158
+ `csp()` refuses a keyword written without its quotes (`self` for `'self'`) with
159
+ a `TypeError` that starts `csp():`. `clearSiteData()` sends nothing for a
160
+ request another site made (`Sec-Fetch-Site`, else `Origin` against the
161
+ `origin` option, which a TLS-terminating proxy needs for older browsers),
162
+ unless `crossSite: true`.
163
+
74
164
  ## Defaults
75
165
 
76
166
  | header | default | option |
@@ -87,18 +177,34 @@ were. With `@alxia/react-router`, `nonceOf(loadContext)` hands it to
87
177
  | `X-DNS-Prefetch-Control` | `off` | `xDnsPrefetchControl` |
88
178
  | `X-Permitted-Cross-Domain-Policies` | `none` | `xPermittedCrossDomainPolicies` |
89
179
  | `Permissions-Policy` | not sent | `permissionsPolicy` |
180
+ | `Cache-Control` | not sent | `cacheControl` |
90
181
 
91
182
  Each option takes a value, or `false` to leave the header out; an empty
92
183
  value is refused at startup. `nonce` is off by default.
93
184
  `X-Powered-By` and `Server` are removed unless `hidePoweredBy: false`.
94
185
 
186
+ `cacheControl` is for pages that carry a nonce or a viewer's data:
187
+ `cacheControl: 'private, no-store'` keeps a cache from storing them. A route's
188
+ own `Cache-Control` wins, and a 404 or a thrown error gets it too.
189
+
95
190
  ## API
96
191
 
97
192
  | export | |
98
193
  | --- | --- |
99
194
  | `secureHeaders(options?)` | the middleware, for `app.use`: a `SecureHeaders`, or with `nonce: true` a `NonceMiddleware` |
100
- | `SecureHeadersOptions` | its options: the headers and `hidePoweredBy`; `nonce` is added by each overload |
195
+ | `SecureHeadersOptions` | its options: the headers (`cacheControl` included) and `hidePoweredBy`; `nonce` is added by each overload |
101
196
  | `Setting` | a header option's type: its value, or `false` to leave it out |
197
+ | `csp(directives)` | a `Content-Security-Policy` string from directives, checked at startup |
198
+ | `CspDirectives` | its argument: directive names to a `CspValue`, any name allowed |
199
+ | `CspDirectiveName` | the directive names it completes |
200
+ | `CspValue` | a directive's value: a list of sources, `true` for a bare directive, `false` to leave it out |
201
+ | `clearSiteData(types, options?)` | a middleware sending `Clear-Site-Data` on a 2xx or 3xx answer to a `POST` (or `options.methods`) |
202
+ | `ClearSiteDataType` | a value of the header: `'cache'`, `'cookies'`, `'storage'`, `'executionContexts'`, `'clientHints'`, `'prefetchCache'`, `'prerenderCache'` or `'*'` |
203
+ | `ClearSiteDataOptions` | its options: `methods`, `['POST']` by default |
204
+ | `ClearSiteData` | what `clearSiteData()` returns: a middleware that adds nothing to the context |
205
+ | `pageHeaders(options)` | `secureHeaders({ nonce: true, … })` for server-rendered pages: `PAGE_DIRECTIVES` with a nonce, HSTS with `https`, no-store |
206
+ | `PageHeadersOptions` | its options: `https` (required), `directives`, and every `secureHeaders` option but the policy and `nonce` |
207
+ | `PAGE_DIRECTIVES` | the page policy's 13 directives, a `CspDirectives` |
102
208
  | `NONCE` | where the nonce goes in `contentSecurityPolicy`, each time it is named |
103
209
  | `NonceContext` | what `nonce: true` adds to the context: `nonce`, a string |
104
210
  | `SecureHeaders` | what `secureHeaders()` returns: a middleware that adds nothing to the context |
@@ -119,6 +225,6 @@ const app = alxia().use(secureHeaders()).use(errorHandler).get(...);
119
225
 
120
226
  ## Documentation
121
227
 
122
- - [Guide](https://github.com/softistx/alxia/tree/develop/packages/secure-headers/docs): every option and its default, the per-request nonce, which responses get the headers, a page's own policy, the order with other middlewares, and recipes.
228
+ - [Guide](https://github.com/softistx/alxia/tree/develop/packages/secure-headers/docs): every option and its default, the per-request nonce, `csp()`, `pageHeaders()`, `cacheControl` and `clearSiteData()`, which responses get the headers, a page's own policy, the order with other middlewares, and recipes.
123
229
  - [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.
124
230
  - [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,49 @@
1
+ /**
2
+ * `clearSiteData`: `Clear-Site-Data` on a successful, same-site sign-out, as a
3
+ * middleware for the sign-out path.
4
+ */
5
+ import { type Empty, type Middleware } from '@alxia/core';
6
+ declare const TYPES: readonly ["cache", "cookies", "storage", "executionContexts", "clientHints", "prefetchCache", "prerenderCache", "*"];
7
+ /** A value of the `Clear-Site-Data` header, without its quotes. */
8
+ export type ClearSiteDataType = (typeof TYPES)[number];
9
+ export interface ClearSiteDataOptions {
10
+ /** Defaults to `['POST']`: a GET to a sign-out URL clears nothing. */
11
+ readonly methods?: readonly string[];
12
+ /**
13
+ * Also clear on a request another site made. Defaults to `false`: a
14
+ * request whose `Sec-Fetch-Site` is not `same-origin` or `none`, or whose
15
+ * `Origin` is not the request's own, clears nothing.
16
+ */
17
+ readonly crossSite?: boolean;
18
+ /**
19
+ * The origin a request must come from, such as `https://app.example.com`:
20
+ * required behind a TLS-terminating proxy, where the request's own URL
21
+ * says `http:`, for a browser that sends no `Sec-Fetch-Site`. A getter is
22
+ * read at each request. Else the request's own origin.
23
+ */
24
+ readonly origin?: string | (() => string | undefined);
25
+ }
26
+ /**
27
+ * What `clearSiteData()` returns: a middleware, for `app.use`, that adds
28
+ * nothing to the context.
29
+ */
30
+ export type ClearSiteData = Middleware<Empty, Promise<Response>>;
31
+ /**
32
+ * `Clear-Site-Data` on a 2xx or 3xx answer to one of `methods` (`POST` by
33
+ * default). A request clears nothing when it is refused (a 403, a failure) or
34
+ * when another site made it (`Sec-Fetch-Site` other than `same-origin` or
35
+ * `none`; without that header, a foreign `Origin`), unless `crossSite` is `true`; a request
36
+ * with neither header, such as curl's, is cleared. Give it to `use(path, …)` for the sign-out path; React Router's
37
+ * single fetch posts `.data` too, so mount both:
38
+ *
39
+ * ```ts
40
+ * const cleared = clearSiteData(['cache']);
41
+ * app.use('/sign-out', cleared).use('/sign-out.data', cleared);
42
+ * ```
43
+ *
44
+ * Throws a `TypeError` at declaration on an empty `types`, a type that is not
45
+ * one of `ClearSiteDataType`, and an empty `methods` or an empty method name.
46
+ */
47
+ export declare function clearSiteData(types: readonly ClearSiteDataType[], options?: ClearSiteDataOptions): ClearSiteData;
48
+ export {};
49
+ //# sourceMappingURL=clear-site-data.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clear-site-data.d.ts","sourceRoot":"","sources":["../src/clear-site-data.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAEN,KAAK,KAAK,EACV,KAAK,UAAU,EAIf,MAAM,aAAa,CAAC;AAErB,QAAA,MAAM,KAAK,sHASD,CAAC;AAEX,mEAAmE;AACnE,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC;AAEvD,MAAM,WAAW,oBAAoB;IACpC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC;CACtD;AAED;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;AAwBjE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAC5B,KAAK,EAAE,SAAS,iBAAiB,EAAE,EACnC,OAAO,GAAE,oBAAyB,GAChC,aAAa,CAgCf"}
package/dist/csp.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `csp`: a `Content-Security-Policy` written as directives, checked once at
3
+ * startup, instead of one long string.
4
+ */
5
+ /**
6
+ * A directive's value: its sources; `true` for a bare directive
7
+ * (`upgrade-insecure-requests`); `false` to leave it out.
8
+ */
9
+ export type CspValue = readonly string[] | boolean;
10
+ /** The directives a policy usually names, for completion. */
11
+ export type CspDirectiveName = 'default-src' | 'script-src' | 'script-src-elem' | 'script-src-attr' | 'style-src' | 'style-src-elem' | 'style-src-attr' | 'img-src' | 'font-src' | 'connect-src' | 'media-src' | 'object-src' | 'frame-src' | 'child-src' | 'worker-src' | 'manifest-src' | 'base-uri' | 'form-action' | 'frame-ancestors' | 'upgrade-insecure-requests' | 'sandbox';
12
+ /** A policy's directives: the known names complete, any other name is allowed. */
13
+ export type CspDirectives = {
14
+ readonly [K in CspDirectiveName]?: CspValue | undefined;
15
+ } & {
16
+ readonly [name: string]: CspValue | undefined;
17
+ };
18
+ /**
19
+ * The policy, written in insertion order: `name src src; name src`. `NONCE`
20
+ * passes through as it is, for `secureHeaders({ nonce: true })` to replace.
21
+ * Throws a `TypeError` at startup on a policy a browser would misread, such
22
+ * as a keyword written without its quotes (`self` for `'self'`).
23
+ *
24
+ * ```ts
25
+ * secureHeaders({
26
+ * nonce: true,
27
+ * contentSecurityPolicy: csp({
28
+ * 'default-src': ["'self'"],
29
+ * 'script-src': ["'self'", NONCE],
30
+ * 'upgrade-insecure-requests': true,
31
+ * }),
32
+ * });
33
+ * ```
34
+ */
35
+ export declare function csp(directives: CspDirectives): string;
36
+ //# sourceMappingURL=csp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csp.d.ts","sourceRoot":"","sources":["../src/csp.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC;AAEnD,6DAA6D;AAC7D,MAAM,MAAM,gBAAgB,GACzB,aAAa,GACb,YAAY,GACZ,iBAAiB,GACjB,iBAAiB,GACjB,WAAW,GACX,gBAAgB,GAChB,gBAAgB,GAChB,SAAS,GACT,UAAU,GACV,aAAa,GACb,WAAW,GACX,YAAY,GACZ,WAAW,GACX,WAAW,GACX,YAAY,GACZ,cAAc,GACd,UAAU,GACV,aAAa,GACb,iBAAiB,GACjB,2BAA2B,GAC3B,SAAS,CAAC;AAEb,kFAAkF;AAClF,MAAM,MAAM,aAAa,GAAG;IAC3B,QAAQ,EAAE,CAAC,IAAI,gBAAgB,CAAC,CAAC,EAAE,QAAQ,GAAG,SAAS;CACvD,GAAG;IACH,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;CAC9C,CAAC;AAuBF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,GAAG,CAAC,UAAU,EAAE,aAAa,GAAG,MAAM,CAwCrD"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export { type ClearSiteData, type ClearSiteDataOptions, type ClearSiteDataType, clearSiteData, } from './clear-site-data';
2
+ export { type CspDirectiveName, type CspDirectives, type CspValue, csp, } from './csp';
1
3
  export { NONCE, type NonceContext } from './nonce';
4
+ export { PAGE_DIRECTIVES, type PageHeadersOptions, pageHeaders, } from './page';
2
5
  export { type NonceMiddleware, type SecureHeaders, type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
3
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACtB,aAAa,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACN,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,GAAG,GACH,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,eAAe,EACf,KAAK,kBAAkB,EACvB,WAAW,GACX,MAAM,QAAQ,CAAC;AAChB,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,112 @@
1
+ // src/clear-site-data.ts
2
+ import {
3
+ defineMiddleware,
4
+ markFactory,
5
+ settle,
6
+ withHeaders
7
+ } from "@alxia/core";
8
+ var TYPES = [
9
+ "cache",
10
+ "cookies",
11
+ "storage",
12
+ "executionContexts",
13
+ "clientHints",
14
+ "prefetchCache",
15
+ "prerenderCache",
16
+ "*"
17
+ ];
18
+ function quoted(names) {
19
+ return names.map((name) => `"${name}"`).join(", ");
20
+ }
21
+ function isCrossSite(request, own) {
22
+ const site = request.headers.get("sec-fetch-site");
23
+ if (site !== null)
24
+ return site !== "same-origin" && site !== "none";
25
+ const origin = request.headers.get("origin");
26
+ if (origin === null)
27
+ return false;
28
+ const set = typeof own === "function" ? own() : own;
29
+ return origin !== (set ?? new URL(request.url).origin);
30
+ }
31
+ function clearSiteData(types, options = {}) {
32
+ if (types.length === 0) {
33
+ throw new TypeError('clearSiteData(): no type is given: write at least one, such as ["cache"]');
34
+ }
35
+ for (const type of types) {
36
+ if (!TYPES.includes(type)) {
37
+ throw new TypeError(`clearSiteData(): "${type}" is not a type: write one of ${quoted(TYPES)}`);
38
+ }
39
+ }
40
+ const value = quoted(types);
41
+ const given = options.methods ?? ["POST"];
42
+ if (given.length === 0 || given.some((method) => method === "")) {
43
+ throw new TypeError('clearSiteData(): methods is empty or holds an empty method: write at least one, such as ["POST"], or leave methods out');
44
+ }
45
+ const methods = new Set(given.map((method) => method.toUpperCase()));
46
+ const crossSite = options.crossSite === true;
47
+ return defineMiddleware(async function clearSiteData(ctx, next) {
48
+ const response = await settle(ctx, next());
49
+ if (!methods.has(ctx.request.method))
50
+ return response;
51
+ if (response.status < 200 || response.status >= 400)
52
+ return response;
53
+ if (!crossSite && isCrossSite(ctx.request, options.origin))
54
+ return response;
55
+ return withHeaders(response, (headers) => {
56
+ if (!headers.has("clear-site-data"))
57
+ headers.set("clear-site-data", value);
58
+ });
59
+ });
60
+ }
61
+ markFactory(clearSiteData);
62
+ // src/csp.ts
63
+ var KEYWORDS = new Set([
64
+ "self",
65
+ "none",
66
+ "unsafe-inline",
67
+ "unsafe-eval",
68
+ "unsafe-hashes",
69
+ "strict-dynamic",
70
+ "wasm-unsafe-eval",
71
+ "report-sample",
72
+ "inline-speculation-rules"
73
+ ]);
74
+ function isUnquotedKeyword(source) {
75
+ const lower = source.toLowerCase();
76
+ return KEYWORDS.has(lower) || /^(nonce|sha256|sha384|sha512)-[a-z0-9+/_-]+=*$/.test(lower);
77
+ }
78
+ function csp(directives) {
79
+ const written = [];
80
+ for (const [name, value] of Object.entries(directives)) {
81
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
82
+ throw new TypeError(`csp(): "${name}" is not a directive name: write lowercase letters, digits and hyphens, starting with a letter, as in "script-src"`);
83
+ }
84
+ if (value === undefined || value === false)
85
+ continue;
86
+ if (value === true) {
87
+ written.push(name);
88
+ continue;
89
+ }
90
+ if (value.length === 0) {
91
+ throw new TypeError(`csp(): ${name} has no source: write ["'none'"] to allow nothing, or false to leave it out`);
92
+ }
93
+ for (const source of value) {
94
+ if (/[;,\s]/.test(source) || source === "") {
95
+ throw new TypeError(`csp(): ${name} has the source "${source}", which is empty or holds ";", "," or a space: a source is one token, so split a list into several sources, and write a comma inside a URL as %2C`);
96
+ }
97
+ }
98
+ for (const source of value) {
99
+ if (isUnquotedKeyword(source)) {
100
+ throw new TypeError(`csp(): ${source} in ${name} is a keyword: write it quoted, '${source}'`);
101
+ }
102
+ }
103
+ written.push(`${name} ${value.join(" ")}`);
104
+ }
105
+ if (written.length === 0) {
106
+ throw new TypeError(`csp(): no directive is left: give at least one, such as { "default-src": ["'none'"] }`);
107
+ }
108
+ return written.join("; ");
109
+ }
1
110
  // src/nonce.ts
2
111
  var NONCE = "'nonce-{alxia}'";
3
112
  var SCRIPT_DIRECTIVES = new Set(["script-src", "script-src-elem"]);
@@ -16,12 +125,15 @@ function freshNonce() {
16
125
  const bytes = crypto.getRandomValues(new Uint8Array(16));
17
126
  return btoa(String.fromCharCode(...bytes));
18
127
  }
128
+ // src/page.ts
129
+ import { markFactory as markFactory3 } from "@alxia/core";
130
+
19
131
  // src/secure-headers.ts
20
132
  import {
21
- defineMiddleware,
22
- markFactory,
23
- settle,
24
- withHeaders
133
+ defineMiddleware as defineMiddleware2,
134
+ markFactory as markFactory2,
135
+ settle as settle2,
136
+ withHeaders as withHeaders2
25
137
  } from "@alxia/core";
26
138
  var DEFAULTS = {
27
139
  "content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
@@ -35,7 +147,8 @@ var DEFAULTS = {
35
147
  "origin-agent-cluster": "?1",
36
148
  "x-dns-prefetch-control": "off",
37
149
  "x-permitted-cross-domain-policies": "none",
38
- "permissions-policy": false
150
+ "permissions-policy": false,
151
+ "cache-control": false
39
152
  };
40
153
  var OPTION = {
41
154
  "content-security-policy": "contentSecurityPolicy",
@@ -49,7 +162,8 @@ var OPTION = {
49
162
  "origin-agent-cluster": "originAgentCluster",
50
163
  "x-dns-prefetch-control": "xDnsPrefetchControl",
51
164
  "x-permitted-cross-domain-policies": "xPermittedCrossDomainPolicies",
52
- "permissions-policy": "permissionsPolicy"
165
+ "permissions-policy": "permissionsPolicy",
166
+ "cache-control": "cacheControl"
53
167
  };
54
168
  function secureHeaders(options = {}) {
55
169
  const headers = fixedHeaders(options);
@@ -60,8 +174,8 @@ function secureHeaders(options = {}) {
60
174
  throw new TypeError("secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true");
61
175
  }
62
176
  const set = setter(headers, hide);
63
- return defineMiddleware(async function secureHeaders(ctx, next) {
64
- return set(await settle(ctx, next()));
177
+ return defineMiddleware2(async function secureHeaders(ctx, next) {
178
+ return set(await settle2(ctx, next()));
65
179
  });
66
180
  }
67
181
  if (policy === undefined) {
@@ -70,9 +184,9 @@ function secureHeaders(options = {}) {
70
184
  headers.delete("content-security-policy");
71
185
  const withNonce = policyWithNonce(policy);
72
186
  const set = setter(headers, hide);
73
- return defineMiddleware(async function secureHeaders(ctx, next) {
187
+ return defineMiddleware2(async function secureHeaders(ctx, next) {
74
188
  const added = { nonce: freshNonce() };
75
- const response = await settle(ctx, next(added));
189
+ const response = await settle2(ctx, next(added));
76
190
  return set(response, withNonce(added.nonce));
77
191
  });
78
192
  }
@@ -90,7 +204,7 @@ function fixedHeaders(options) {
90
204
  return headers;
91
205
  }
92
206
  function setter(headers, hide) {
93
- return (response, policy) => withHeaders(response, (current) => {
207
+ return (response, policy) => withHeaders2(response, (current) => {
94
208
  for (const [name, value] of headers) {
95
209
  if (!current.has(name))
96
210
  current.set(name, value);
@@ -104,11 +218,75 @@ function setter(headers, hide) {
104
218
  }
105
219
  });
106
220
  }
107
- markFactory(secureHeaders);
221
+ markFactory2(secureHeaders);
222
+
223
+ // src/page.ts
224
+ var PAGE_POLICY = {
225
+ "default-src": ["'self'"],
226
+ "script-src": ["'self'", NONCE, "'strict-dynamic'"],
227
+ "style-src-elem": ["'self'", NONCE],
228
+ "style-src-attr": ["'unsafe-inline'"],
229
+ "img-src": ["'self'"],
230
+ "font-src": ["'self'"],
231
+ "connect-src": ["'self'"],
232
+ "worker-src": ["'self'"],
233
+ "manifest-src": ["'self'"],
234
+ "object-src": ["'none'"],
235
+ "base-uri": ["'none'"],
236
+ "form-action": ["'self'"],
237
+ "frame-ancestors": ["'none'"]
238
+ };
239
+ var PAGE_DIRECTIVES = Object.freeze(Object.fromEntries(Object.entries(PAGE_POLICY).map(([name, sources]) => [
240
+ name,
241
+ Object.freeze(sources)
242
+ ])));
243
+ var REQUIRED = {
244
+ "default-src": "it covers frame, media and child sources",
245
+ "base-uri": "it has no fallback, so an injected <base> would redirect nonced relative scripts",
246
+ "object-src": "it blocks plugin content"
247
+ };
248
+ function pageHeaders(options) {
249
+ const { https, directives: asGiven = {}, ...given } = options;
250
+ const defined = (entries) => Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined));
251
+ const rest = defined(given);
252
+ const directives = defined(asGiven);
253
+ for (const [name, why] of Object.entries(REQUIRED)) {
254
+ const value = directives[name];
255
+ if (value === false || value === true) {
256
+ throw new TypeError(`pageHeaders(): ${name} cannot be removed from a page policy: ${why}, so give it a list of sources`);
257
+ }
258
+ }
259
+ const merged = {
260
+ ...PAGE_DIRECTIVES,
261
+ ...directives,
262
+ "upgrade-insecure-requests": directives["upgrade-insecure-requests"] ?? https
263
+ };
264
+ const scripts = merged["script-src"];
265
+ if (!Array.isArray(scripts) || !scripts.includes(NONCE)) {
266
+ throw new TypeError(`pageHeaders(): script-src has no NONCE: a page script runs only with the response's nonce, so write script-src with NONCE in directives, such as ["'self'", NONCE, "'strict-dynamic'"]`);
267
+ }
268
+ const elements = merged["script-src-elem"];
269
+ if (Array.isArray(elements) && !elements.includes(NONCE)) {
270
+ throw new TypeError("pageHeaders(): script-src-elem has no NONCE: script elements follow it instead of script-src, so add NONCE to it, or give false to remove it");
271
+ }
272
+ return secureHeaders({
273
+ ...https ? {} : { strictTransportSecurity: false },
274
+ referrerPolicy: "strict-origin-when-cross-origin",
275
+ cacheControl: "private, no-store",
276
+ ...rest,
277
+ nonce: true,
278
+ contentSecurityPolicy: csp(merged)
279
+ });
280
+ }
281
+ markFactory3(pageHeaders);
108
282
  export {
109
283
  NONCE,
284
+ PAGE_DIRECTIVES,
285
+ clearSiteData,
286
+ csp,
287
+ pageHeaders,
110
288
  secureHeaders
111
289
  };
112
290
 
113
- //# debugId=DA6BBAE79DDA945364756E2164756E21
291
+ //# debugId=544F13C33DDC4CC564756E2164756E21
114
292
  //# sourceMappingURL=index.js.map