@alxia/secure-headers 0.4.9 → 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 +108 -2
- package/dist/clear-site-data.d.ts +49 -0
- package/dist/clear-site-data.d.ts.map +1 -0
- package/dist/csp.d.ts +36 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +191 -13
- package/dist/index.js.map +7 -4
- package/dist/page.d.ts +58 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/secure-headers.d.ts +5 -0
- package/dist/secure-headers.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide.md +252 -5
- package/docs/roadmap.md +26 -6
- package/docs/troubleshooting.md +334 -12
- package/package.json +3 -3
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,KAAK,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
|
|
64
|
-
return set(await
|
|
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
|
|
187
|
+
return defineMiddleware2(async function secureHeaders(ctx, next) {
|
|
74
188
|
const added = { nonce: freshNonce() };
|
|
75
|
-
const response = await
|
|
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) =>
|
|
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
|
-
|
|
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=
|
|
291
|
+
//# debugId=544F13C33DDC4CC564756E2164756E21
|
|
114
292
|
//# sourceMappingURL=index.js.map
|