@webjsdev/server 0.8.8 → 0.8.10

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/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
+ }