@webjsdev/server 0.8.9 → 0.8.11
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/index.js +6 -1
- package/package.json +1 -1
- package/src/actions.js +49 -7
- package/src/api.js +16 -1
- package/src/auth.js +25 -3
- package/src/body-limit.js +291 -0
- package/src/build-info.js +59 -0
- package/src/cache-fn.js +40 -0
- package/src/cache-tags.js +147 -0
- package/src/conditional-get.js +183 -0
- package/src/context.js +139 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +397 -31
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/html-cache.js +305 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/session.js +4 -0
- package/src/ssr.js +142 -24
- package/src/vendor.js +9 -6
package/src/cors.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable CORS primitive for webjs.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces share one origin-resolution + header-building core:
|
|
5
|
+
*
|
|
6
|
+
* 1. `cors(options)` returns a webjs MIDDLEWARE `(req, next) => Response`,
|
|
7
|
+
* usable in `middleware.js` (root or per-segment) or wrapped around a
|
|
8
|
+
* `route.js` handler. This is the public app-facing API.
|
|
9
|
+
* 2. The `expose()` REST path (`actions.js`) reuses `resolveOrigin` and
|
|
10
|
+
* `applyCorsHeaders` so a route's `cors` config and a standalone
|
|
11
|
+
* `cors()` middleware compute identical headers.
|
|
12
|
+
*
|
|
13
|
+
* Web-standards-first: the option shape mirrors the well-trodden
|
|
14
|
+
* `cors` npm package and the Fetch CORS protocol. We reflect the
|
|
15
|
+
* request `Origin` ONLY when the policy allows it, never blanket-reflect
|
|
16
|
+
* with credentials, and always append (never clobber) `Vary: Origin`
|
|
17
|
+
* when the allowed origin is dynamic, to keep shared caches correct.
|
|
18
|
+
*
|
|
19
|
+
* The one hard CORS-spec rule we enforce: a wildcard origin (`*`) is
|
|
20
|
+
* incompatible with `credentials: true`. The browser rejects
|
|
21
|
+
* `Access-Control-Allow-Origin: *` together with
|
|
22
|
+
* `Access-Control-Allow-Credentials: true`, so when both are configured
|
|
23
|
+
* we NARROW to the reflected request origin instead of sending `*`
|
|
24
|
+
* (and append `Vary: Origin`, since the response now varies by origin).
|
|
25
|
+
*
|
|
26
|
+
* ```js
|
|
27
|
+
* // middleware.js
|
|
28
|
+
* import { cors } from '@webjsdev/server';
|
|
29
|
+
* export default cors({ origin: ['https://app.example.com'], credentials: true });
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @module cors
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {string | RegExp | ((origin: string) => boolean)} OriginRule
|
|
37
|
+
* A single origin rule. A function receives the request `Origin` and
|
|
38
|
+
* returns whether it is allowed.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} CorsOptions
|
|
43
|
+
* @property {'*' | true | OriginRule | OriginRule[]} [origin]
|
|
44
|
+
* Allowed origin policy. `'*'` / `true` allows any origin. A string is
|
|
45
|
+
* an exact match. A `RegExp` tests the origin. A function returns a
|
|
46
|
+
* boolean. An array is an allow-list of any of the above (matches if
|
|
47
|
+
* ANY entry matches). Defaults to `'*'`.
|
|
48
|
+
* @property {boolean} [credentials]
|
|
49
|
+
* Send `Access-Control-Allow-Credentials: true`. Forces a wildcard
|
|
50
|
+
* origin to narrow to the reflected request origin (spec requirement).
|
|
51
|
+
* @property {string[] | string} [methods]
|
|
52
|
+
* Methods advertised on a preflight. Defaults to the common verb set.
|
|
53
|
+
* @property {string[] | string} [allowedHeaders]
|
|
54
|
+
* Request headers advertised on a preflight. Defaults to reflecting
|
|
55
|
+
* the preflight's `Access-Control-Request-Headers`.
|
|
56
|
+
* @property {string[] | string} [exposedHeaders]
|
|
57
|
+
* Response headers exposed to client JS via
|
|
58
|
+
* `Access-Control-Expose-Headers`.
|
|
59
|
+
* @property {number} [maxAge]
|
|
60
|
+
* Preflight cache lifetime in seconds (`Access-Control-Max-Age`).
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Module-level dedupe flag for the credentials+wildcard warning. Reflecting
|
|
67
|
+
* the request origin under `credentials: true` grants credentialed access to
|
|
68
|
+
* EVERY origin, a real footgun, so we warn ONCE (not per request) to keep
|
|
69
|
+
* logs readable while still surfacing it. The request still proceeds.
|
|
70
|
+
*/
|
|
71
|
+
let warnedCredentialedWildcard = false;
|
|
72
|
+
|
|
73
|
+
/** Emit the one-time credentials+wildcard warning (deduped across requests). */
|
|
74
|
+
function warnCredentialedWildcard() {
|
|
75
|
+
if (warnedCredentialedWildcard) return;
|
|
76
|
+
warnedCredentialedWildcard = true;
|
|
77
|
+
console.warn(
|
|
78
|
+
'cors(): credentials with a wildcard origin reflects ANY origin with credentials. ' +
|
|
79
|
+
'Use an explicit origin allowlist for credentialed requests.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Testing hook: reset the one-time warning dedupe flag. */
|
|
84
|
+
export function _resetCorsWarnings() {
|
|
85
|
+
warnedCredentialedWildcard = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string[] | string | undefined} v @returns {string | undefined} */
|
|
89
|
+
function csv(v) {
|
|
90
|
+
if (v == null) return undefined;
|
|
91
|
+
return Array.isArray(v) ? v.join(', ') : String(v);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decide whether `origin` is allowed by a single rule.
|
|
96
|
+
*
|
|
97
|
+
* @param {OriginRule | '*' | true} rule
|
|
98
|
+
* @param {string} origin
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
function ruleAllows(rule, origin) {
|
|
102
|
+
if (rule === '*' || rule === true) return true;
|
|
103
|
+
if (typeof rule === 'string') return rule === origin;
|
|
104
|
+
if (rule instanceof RegExp) return rule.test(origin);
|
|
105
|
+
if (typeof rule === 'function') return rule(origin) === true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a CORS origin policy against a request `Origin` header.
|
|
111
|
+
*
|
|
112
|
+
* Returns the value to send in `Access-Control-Allow-Origin`, or `null`
|
|
113
|
+
* when the origin is NOT allowed (the caller then omits the header so the
|
|
114
|
+
* browser blocks the cross-origin read). The shape:
|
|
115
|
+
*
|
|
116
|
+
* - `{ allowOrigin: '*', dynamic: false }` any origin, no credentials.
|
|
117
|
+
* - `{ allowOrigin: '<origin>', dynamic: true }` reflected, varies by origin.
|
|
118
|
+
* - `null` disallowed.
|
|
119
|
+
*
|
|
120
|
+
* `credentials: true` forces a wildcard to narrow: `*` is invalid with
|
|
121
|
+
* credentials, so we reflect the concrete request origin (and mark the
|
|
122
|
+
* result dynamic so `Vary: Origin` is appended). With no `Origin` header
|
|
123
|
+
* a wildcard policy still resolves to `*` (or, under credentials, yields
|
|
124
|
+
* no reflected value), matching same-origin / server-to-server traffic.
|
|
125
|
+
*
|
|
126
|
+
* @param {'*' | true | OriginRule | OriginRule[]} policy
|
|
127
|
+
* @param {string} origin request `Origin` header (`''` if absent)
|
|
128
|
+
* @param {boolean} credentials
|
|
129
|
+
* @returns {{ allowOrigin: string, dynamic: boolean } | null}
|
|
130
|
+
*/
|
|
131
|
+
export function resolveOrigin(policy, origin, credentials) {
|
|
132
|
+
const rules = Array.isArray(policy) ? policy : [policy];
|
|
133
|
+
const isWildcard = rules.some((r) => r === '*' || r === true);
|
|
134
|
+
|
|
135
|
+
if (isWildcard) {
|
|
136
|
+
// Wildcard + credentials is invalid per the CORS spec: a literal `*`
|
|
137
|
+
// ACAO can never be combined with `Allow-Credentials: true`. Narrow
|
|
138
|
+
// to the reflected origin (dynamic), or refuse if no origin is given.
|
|
139
|
+
// Reflecting under credentials effectively allows EVERY origin, so warn
|
|
140
|
+
// once (the request still proceeds) to flag the footgun.
|
|
141
|
+
if (credentials) {
|
|
142
|
+
warnCredentialedWildcard();
|
|
143
|
+
if (!origin) return null;
|
|
144
|
+
return { allowOrigin: origin, dynamic: true };
|
|
145
|
+
}
|
|
146
|
+
return { allowOrigin: '*', dynamic: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!origin) return null;
|
|
150
|
+
const allowed = rules.some((r) => ruleAllows(r, origin));
|
|
151
|
+
if (!allowed) return null;
|
|
152
|
+
return { allowOrigin: origin, dynamic: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @param {Headers} headers append `Vary: Origin` without clobbering existing Vary */
|
|
156
|
+
function appendVaryOrigin(headers) {
|
|
157
|
+
const existing = headers.get('vary');
|
|
158
|
+
if (!existing) {
|
|
159
|
+
headers.set('vary', 'Origin');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const parts = existing.split(',').map((s) => s.trim().toLowerCase());
|
|
163
|
+
if (parts.includes('*') || parts.includes('origin')) return;
|
|
164
|
+
headers.append('vary', 'Origin');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Mutate `headers` in place to carry the actual-request CORS headers for
|
|
169
|
+
* a resolved origin: `Access-Control-Allow-Origin`, optional
|
|
170
|
+
* `Allow-Credentials`, optional `Expose-Headers`, and `Vary: Origin`
|
|
171
|
+
* when the origin is dynamic.
|
|
172
|
+
*
|
|
173
|
+
* @param {Headers} headers
|
|
174
|
+
* @param {{ allowOrigin: string, dynamic: boolean }} resolved
|
|
175
|
+
* @param {{ credentials?: boolean, exposedHeaders?: string[] | string }} cfg
|
|
176
|
+
*/
|
|
177
|
+
export function applyCorsHeaders(headers, resolved, cfg) {
|
|
178
|
+
headers.set('access-control-allow-origin', resolved.allowOrigin);
|
|
179
|
+
if (cfg.credentials && resolved.allowOrigin !== '*') {
|
|
180
|
+
headers.set('access-control-allow-credentials', 'true');
|
|
181
|
+
}
|
|
182
|
+
const exposed = csv(cfg.exposedHeaders);
|
|
183
|
+
if (exposed) headers.set('access-control-expose-headers', exposed);
|
|
184
|
+
if (resolved.dynamic) appendVaryOrigin(headers);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Build a CORS middleware `(req, next) => Response`.
|
|
189
|
+
*
|
|
190
|
+
* Behavior:
|
|
191
|
+
* - On an OPTIONS preflight (carries `Access-Control-Request-Method`),
|
|
192
|
+
* short-circuit with a 204 carrying Allow-Methods / Allow-Headers /
|
|
193
|
+
* Max-Age. `next()` is NOT called.
|
|
194
|
+
* - On an actual request, call `next()` then attach the actual-request
|
|
195
|
+
* CORS headers to the response.
|
|
196
|
+
* - A disallowed origin gets NO `Access-Control-Allow-Origin` (the
|
|
197
|
+
* browser blocks the read). The server still serves the actual request
|
|
198
|
+
* (CORS is browser-enforced); a disallowed PREFLIGHT returns a bare 204
|
|
199
|
+
* with no CORS headers, so the browser blocks the follow-up.
|
|
200
|
+
*
|
|
201
|
+
* @param {CorsOptions} [options]
|
|
202
|
+
* @returns {(req: Request, next: () => Promise<Response>) => Promise<Response>}
|
|
203
|
+
*/
|
|
204
|
+
export function cors(options = {}) {
|
|
205
|
+
const policy = options.origin ?? '*';
|
|
206
|
+
const credentials = options.credentials === true;
|
|
207
|
+
const methods = csv(options.methods) || DEFAULT_METHODS.join(', ');
|
|
208
|
+
const allowedHeaders = csv(options.allowedHeaders);
|
|
209
|
+
const exposedHeaders = options.exposedHeaders;
|
|
210
|
+
const maxAge = options.maxAge;
|
|
211
|
+
|
|
212
|
+
return async function corsMiddleware(req, next) {
|
|
213
|
+
const origin = req.headers.get('origin') || '';
|
|
214
|
+
const resolved = resolveOrigin(policy, origin, credentials);
|
|
215
|
+
const isPreflight =
|
|
216
|
+
req.method === 'OPTIONS' && req.headers.has('access-control-request-method');
|
|
217
|
+
|
|
218
|
+
if (isPreflight) {
|
|
219
|
+
const headers = new Headers();
|
|
220
|
+
// Disallowed preflight: bare 204, no CORS headers. The browser then
|
|
221
|
+
// blocks the actual request, which is the correct CORS posture.
|
|
222
|
+
if (resolved) {
|
|
223
|
+
applyCorsHeaders(headers, resolved, { credentials, exposedHeaders });
|
|
224
|
+
headers.set('access-control-allow-methods', methods);
|
|
225
|
+
const reqHeaders =
|
|
226
|
+
allowedHeaders || req.headers.get('access-control-request-headers') || 'content-type';
|
|
227
|
+
headers.set('access-control-allow-headers', reqHeaders);
|
|
228
|
+
if (maxAge != null) headers.set('access-control-max-age', String(maxAge));
|
|
229
|
+
}
|
|
230
|
+
return new Response(null, { status: 204, headers });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const resp = await next();
|
|
234
|
+
if (!resolved) return resp;
|
|
235
|
+
// Some synthetic Responses have immutable headers; fall back to a copy.
|
|
236
|
+
try {
|
|
237
|
+
applyCorsHeaders(resp.headers, resolved, { credentials, exposedHeaders });
|
|
238
|
+
return resp;
|
|
239
|
+
} catch {
|
|
240
|
+
const headers = new Headers(resp.headers);
|
|
241
|
+
applyCorsHeaders(headers, resolved, { credentials, exposedHeaders });
|
|
242
|
+
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers });
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
package/src/csp.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Security-Policy: mint a per-request nonce and emit the matching
|
|
3
|
+
* header (issue #233).
|
|
4
|
+
*
|
|
5
|
+
* webjs's CSP support used to be consume-only: `ssr.js` read a nonce out
|
|
6
|
+
* of the INBOUND request's `Content-Security-Policy` header and applied it
|
|
7
|
+
* to its inline boot script, the importmap, and modulepreload hints. That
|
|
8
|
+
* only did anything if some upstream proxy already minted a nonce and set
|
|
9
|
+
* the header, so the advertised "CSP via nonce" protection was off by
|
|
10
|
+
* default and effectively dead.
|
|
11
|
+
*
|
|
12
|
+
* This module wires the GENERATING half. When CSP is enabled (opt-in, via
|
|
13
|
+
* `package.json` -> `webjs.csp`), the request handler:
|
|
14
|
+
* 1. mints a fresh CSPRNG nonce per request (`mintNonce`),
|
|
15
|
+
* 2. makes it the value `cspNonce()` returns for that request (so the
|
|
16
|
+
* same nonce lands on every inline `<script>` / meta / modulepreload
|
|
17
|
+
* the SSR pipeline emits), and
|
|
18
|
+
* 3. sets a literal `Content-Security-Policy` response header carrying
|
|
19
|
+
* that EXACT nonce (`buildCspHeader`).
|
|
20
|
+
*
|
|
21
|
+
* The header value and the nonce on the inline scripts are the same
|
|
22
|
+
* minted string, so there is no drift: a single value flows
|
|
23
|
+
* mint -> request store -> SSR (`cspNonce()`) -> header.
|
|
24
|
+
*
|
|
25
|
+
* The default policy when enabled is a strict-dynamic + nonce posture
|
|
26
|
+
* that works with webjs's own inline boot script and importmap. CSP is
|
|
27
|
+
* OFF by default: an unconfigured app is byte-for-byte unchanged (no
|
|
28
|
+
* nonce minted, `cspNonce()` stays '', no CSP header).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {{
|
|
33
|
+
* enabled: boolean,
|
|
34
|
+
* directives: Record<string, string>,
|
|
35
|
+
* reportOnly: boolean,
|
|
36
|
+
* }} CspConfig
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Any C0/C1 control character, including CR and LF. A directive name or
|
|
41
|
+
* value carrying one of these would make `Headers.set` throw, so such a
|
|
42
|
+
* directive is dropped from the policy at config-read time (fail closed).
|
|
43
|
+
*/
|
|
44
|
+
// eslint-disable-next-line no-control-regex
|
|
45
|
+
const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The strict-by-default directive set used when `webjs.csp` is `true`
|
|
49
|
+
* (or an object that does not override a given directive). Tuned to work
|
|
50
|
+
* with webjs's own output, which is: nonce-signed inline `<script>` tags
|
|
51
|
+
* (the boot script, the public-env shim, the importmap), nonce-signed
|
|
52
|
+
* `<link rel="modulepreload">`, ES modules fetched same-origin and from
|
|
53
|
+
* the configured vendor CDN, and Tailwind's runtime which injects a
|
|
54
|
+
* `<style>` element (so inline styles must be allowed).
|
|
55
|
+
*
|
|
56
|
+
* `script-src` uses `'strict-dynamic'` so a nonce-loaded module can pull
|
|
57
|
+
* in its own dependencies (the importmap-driven per-file ESM graph)
|
|
58
|
+
* without each fetched URL needing to be allow-listed. `'self'` and
|
|
59
|
+
* `https:` are kept as a fallback for browsers that do not honor
|
|
60
|
+
* `'strict-dynamic'` (they are ignored where it IS honored).
|
|
61
|
+
*
|
|
62
|
+
* The `__NONCE__` placeholder is substituted per request in
|
|
63
|
+
* `buildCspHeader`.
|
|
64
|
+
*
|
|
65
|
+
* @type {Record<string, string>}
|
|
66
|
+
*/
|
|
67
|
+
const DEFAULT_DIRECTIVES = {
|
|
68
|
+
'default-src': "'self'",
|
|
69
|
+
'script-src': "'nonce-__NONCE__' 'strict-dynamic' 'self' https:",
|
|
70
|
+
// Tailwind's browser runtime injects a <style> element, and webjs
|
|
71
|
+
// emits an inline <style> for adopted component styles, so inline
|
|
72
|
+
// styles must be permitted. Style elements are not a script-injection
|
|
73
|
+
// vector, so this does not weaken the script protection.
|
|
74
|
+
'style-src': "'self' 'unsafe-inline'",
|
|
75
|
+
'img-src': "'self' data: https:",
|
|
76
|
+
'font-src': "'self' data: https:",
|
|
77
|
+
'connect-src': "'self'",
|
|
78
|
+
'base-uri': "'self'",
|
|
79
|
+
'form-action': "'self'",
|
|
80
|
+
'frame-ancestors': "'self'",
|
|
81
|
+
'object-src': "'none'",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read and normalize the CSP config from the app's package.json
|
|
86
|
+
* (`webjs.csp`). Never throws: a malformed config disables CSP rather
|
|
87
|
+
* than taking the app down (a broken security knob must fail closed and
|
|
88
|
+
* visible, not crash every request).
|
|
89
|
+
*
|
|
90
|
+
* Accepted shapes:
|
|
91
|
+
* "csp": false | undefined -> disabled (the default)
|
|
92
|
+
* "csp": true -> enabled, default strict policy
|
|
93
|
+
* "csp": { ...overrides } -> enabled, custom directives
|
|
94
|
+
*
|
|
95
|
+
* Object shape:
|
|
96
|
+
* {
|
|
97
|
+
* "directives": { "connect-src": "'self' https://api.example.com" },
|
|
98
|
+
* "reportOnly": true
|
|
99
|
+
* }
|
|
100
|
+
* `directives` is merged OVER the strict defaults (per-directive
|
|
101
|
+
* override), so an app customizes one directive without restating the
|
|
102
|
+
* rest. A directive whose value is null/false/'' is DROPPED from the
|
|
103
|
+
* emitted policy (the escape hatch to remove a default directive).
|
|
104
|
+
* `reportOnly: true` emits `Content-Security-Policy-Report-Only` instead
|
|
105
|
+
* of the enforcing header (the standard staged-rollout path).
|
|
106
|
+
*
|
|
107
|
+
* A bare object with no `directives` key is also accepted and treated as
|
|
108
|
+
* the directive map directly, so `{ "connect-src": "..." }` works as a
|
|
109
|
+
* terse form. `reportOnly` is always recognized at the top level, so the
|
|
110
|
+
* terse form never mistakes it for a directive.
|
|
111
|
+
*
|
|
112
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
113
|
+
* @returns {CspConfig}
|
|
114
|
+
*/
|
|
115
|
+
export function readCspConfig(pkg) {
|
|
116
|
+
const off = { enabled: false, directives: {}, reportOnly: false };
|
|
117
|
+
const raw =
|
|
118
|
+
pkg &&
|
|
119
|
+
typeof pkg === 'object' &&
|
|
120
|
+
/** @type {any} */ (pkg).webjs &&
|
|
121
|
+
/** @type {any} */ (pkg).webjs.csp;
|
|
122
|
+
if (raw === undefined || raw === null || raw === false) return off;
|
|
123
|
+
if (raw === true) {
|
|
124
|
+
return { enabled: true, directives: { ...DEFAULT_DIRECTIVES }, reportOnly: false };
|
|
125
|
+
}
|
|
126
|
+
if (typeof raw !== 'object') return off; // a string/number is malformed: fail closed
|
|
127
|
+
|
|
128
|
+
// `reportOnly` is a reserved top-level key in BOTH shapes, so the terse
|
|
129
|
+
// bare-directive-map form never treats it as a directive. The wrapped
|
|
130
|
+
// shape ({ directives, reportOnly }) is distinguished by a `directives`
|
|
131
|
+
// key; otherwise the object IS the directive map (minus reportOnly).
|
|
132
|
+
const obj = /** @type {any} */ (raw);
|
|
133
|
+
const reportOnly = Boolean(obj.reportOnly);
|
|
134
|
+
let overrides;
|
|
135
|
+
if (Object.prototype.hasOwnProperty.call(obj, 'directives')) {
|
|
136
|
+
overrides = obj.directives;
|
|
137
|
+
} else {
|
|
138
|
+
overrides = { ...obj };
|
|
139
|
+
delete overrides.reportOnly;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const directives = { ...DEFAULT_DIRECTIVES };
|
|
143
|
+
if (overrides && typeof overrides === 'object') {
|
|
144
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
145
|
+
if (typeof k !== 'string' || !k) continue;
|
|
146
|
+
// null / false / '' removes a default directive.
|
|
147
|
+
if (v === null || v === false || v === '') {
|
|
148
|
+
delete directives[k];
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const value = String(v);
|
|
152
|
+
// Reject a directive NAME or VALUE carrying CR/LF or other control
|
|
153
|
+
// chars. Author-controlled config (no injection vector, the only
|
|
154
|
+
// request-derived input is the safe base64 nonce), but a CRLF in a
|
|
155
|
+
// value would make `Headers.set` THROW in `buildCspHeader`'s caller,
|
|
156
|
+
// a self-inflicted 500 on every request that breaks this file's
|
|
157
|
+
// "never a throw, fail closed" promise. Drop the bad directive with a
|
|
158
|
+
// one-line warning, consistent with how headers.js drops a bad
|
|
159
|
+
// header directive. `__NONCE__` is substituted later with a base64
|
|
160
|
+
// nonce, so the post-substitution value stays control-char-free.
|
|
161
|
+
if (CONTROL_CHARS.test(k) || CONTROL_CHARS.test(value)) {
|
|
162
|
+
// eslint-disable-next-line no-console
|
|
163
|
+
console.warn(`[webjs] dropping invalid webjs.csp directive "${k}" (control character in name or value)`);
|
|
164
|
+
delete directives[k];
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
directives[k] = value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { enabled: true, directives, reportOnly };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Mint a fresh per-request nonce with native crypto (CSPRNG). 16 random
|
|
175
|
+
* bytes, base64-encoded, which clears the CSP spec's 128-bit-entropy
|
|
176
|
+
* recommendation and is in the nonce charset (`[A-Za-z0-9+/=]`). Changes
|
|
177
|
+
* every call, so every request gets a distinct value.
|
|
178
|
+
*
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
export function mintNonce() {
|
|
182
|
+
const bytes = new Uint8Array(16);
|
|
183
|
+
crypto.getRandomValues(bytes);
|
|
184
|
+
// Buffer is available in every webjs server runtime (Node 24+). Avoids a
|
|
185
|
+
// hand-rolled base64 of a binary string.
|
|
186
|
+
return Buffer.from(bytes).toString('base64');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build the literal `Content-Security-Policy` header VALUE from a config
|
|
191
|
+
* and a minted nonce, substituting the `__NONCE__` placeholder so the
|
|
192
|
+
* header carries the exact nonce the SSR pipeline put on the inline
|
|
193
|
+
* scripts. Returns the policy string (directives joined by `; `).
|
|
194
|
+
*
|
|
195
|
+
* @param {CspConfig} config
|
|
196
|
+
* @param {string} nonce
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function buildCspHeader(config, nonce) {
|
|
200
|
+
const parts = [];
|
|
201
|
+
for (const [name, value] of Object.entries(config.directives)) {
|
|
202
|
+
const v = String(value).replaceAll('__NONCE__', nonce);
|
|
203
|
+
parts.push(v ? `${name} ${v}` : name);
|
|
204
|
+
}
|
|
205
|
+
return parts.join('; ');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The header NAME to emit for a config (enforcing vs report-only).
|
|
210
|
+
*
|
|
211
|
+
* @param {CspConfig} config
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
export function cspHeaderName(config) {
|
|
215
|
+
return config.reportOnly
|
|
216
|
+
? 'Content-Security-Policy-Report-Only'
|
|
217
|
+
: 'Content-Security-Policy';
|
|
218
|
+
}
|