@mochi.js/core 0.3.0 → 0.6.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.
@@ -0,0 +1,644 @@
1
+ /**
2
+ * Init-script delivery via `Fetch.fulfillRequest` body injection + CSP rewriter.
3
+ *
4
+ * Architectural pivot — see PLAN.md §8.4. This REPLACES
5
+ * `Page.addScriptToEvaluateOnNewDocument` as the inject delivery mechanism.
6
+ *
7
+ * Why
8
+ * ---
9
+ * Scripts installed via `Page.addScriptToEvaluateOnNewDocument` carry a
10
+ * source-attribution leak: the "Vanilla CDP" detection probe inspects how
11
+ * the very first script entered the page and recognises the
12
+ * `addScriptToEvaluateOnNewDocument` channel. Patchright sidesteps the leak
13
+ * entirely (see `crNetworkManagerPatch.ts:166-453`,
14
+ * `RouteImpl._fixCSP`/`_injectIntoHead`/`fulfill`) by intercepting the
15
+ * document response itself, rewriting any restrictive CSP, splicing the
16
+ * payload as an inline `<script>` at end-of-`<head>` (before the first
17
+ * non-comment `<script>`), and emitting `Fetch.fulfillRequest` with the
18
+ * rewritten body.
19
+ *
20
+ * After this lands the inject is byte-indistinguishable from a same-origin
21
+ * developer's own `<script>` tag.
22
+ *
23
+ * Wire algorithm
24
+ * --------------
25
+ * `Fetch.enable` is sent once with patterns
26
+ * `[{ urlPattern: "*", resourceType: "Document" }, { urlPattern: "*" }]`
27
+ * — the more-specific Document pattern matches first; the catch-all wildcard
28
+ * ensures every request also pauses (so we can answer `Fetch.authRequired`
29
+ * via the same domain when credentials are configured).
30
+ *
31
+ * On `Fetch.requestPaused`:
32
+ * - resourceType === "Document":
33
+ * - request stage (no `responseStatusCode`) → `Fetch.continueRequest`
34
+ * with `interceptResponse: true` so we get the response stage too.
35
+ * - response stage (has `responseStatusCode`) → fetch body, rewrite
36
+ * CSP, splice script, `Fetch.fulfillRequest`.
37
+ * - other resourceType → `Fetch.continueRequest` immediately (zero-cost
38
+ * pass-through; the request hangs if we don't reply).
39
+ *
40
+ * On `Fetch.authRequired` (only when `auth` is set): `Fetch.continueWithAuth`
41
+ * with `ProvideCredentials`.
42
+ *
43
+ * Self-removing payload
44
+ * ---------------------
45
+ * The injected `<script>` runs an IIFE whose first statement is
46
+ * `document.currentScript?.remove()`. A defensive post-`load` walk via
47
+ * `document.querySelectorAll(".${MOCHI_INIT_SCRIPT_CLASS}")` strips any leftover
48
+ * — added by the same IIFE so the DOM-walk happens once we know the document
49
+ * is loaded.
50
+ *
51
+ * The injected `<script>` tag MUST NOT carry `defer`, `async`, or
52
+ * `type="module"` — those defer execution past first parse and re-introduce
53
+ * the same race window `runImmediately:true` originally closed.
54
+ *
55
+ * CSP rewriting
56
+ * -------------
57
+ * See {@link rewriteCsp}. We handle:
58
+ * - `Content-Security-Policy:` and `Content-Security-Policy-Report-Only:`
59
+ * response headers.
60
+ * - `<meta http-equiv="Content-Security-Policy" content="…">` tags in the
61
+ * HTML head.
62
+ * - nonce reuse (`'nonce-abc123'` → tag carries `nonce="abc123"`).
63
+ * - `'strict-dynamic'` (admits any script the original allowed dispatched —
64
+ * reusing the existing nonce is sufficient).
65
+ * - missing-nonce / `'self'`-only policies → add `'unsafe-inline'`.
66
+ * - multiple CSPs (header + meta) → most-restrictive wins, so we rewrite
67
+ * them ALL.
68
+ *
69
+ * §8.2 invariant
70
+ * --------------
71
+ * `Fetch.enable` is NOT on the forbidden list (only `Runtime.enable` and
72
+ * `Page.createIsolatedWorld`). Fetch operates at the network layer below
73
+ * page script and is invisible from JS — see PLAN.md §8.2.
74
+ *
75
+ * @see PLAN.md §8.2, §8.4
76
+ * @see docs/audits/patchright.md HIGH §"Init-script delivery via Fetch.fulfillRequest"
77
+ */
78
+
79
+ import type { MessageRouter, Unsubscribe } from "./router";
80
+
81
+ /**
82
+ * The class attribute applied to every injected `<script>` tag. Patchright
83
+ * uses a similar marker (`__playwright_init_script__`) — we publish ours so
84
+ * the post-`load` cleanup walk (and any future probe-friendly diagnostic)
85
+ * can find leftovers.
86
+ */
87
+ export const MOCHI_INIT_SCRIPT_CLASS = "__mochi_init_script__";
88
+
89
+ /**
90
+ * Public global the injected payload sets to `true` on first run. Used by the
91
+ * live conformance test to assert "our inject ran BEFORE the document's first
92
+ * `<script>`" — a property of execution order that distinguishes the
93
+ * fulfill-rewrite mechanism from a post-parse alternative.
94
+ */
95
+ export const MOCHI_INIT_MARKER = "__mochi_inject_marker";
96
+
97
+ /** What the `Fetch.requestPaused` event carries (subset we consume). */
98
+ interface FetchRequestPausedEvent {
99
+ requestId: string;
100
+ request: { url: string; method?: string };
101
+ resourceType?: string;
102
+ responseStatusCode?: number;
103
+ responseHeaders?: { name: string; value: string }[];
104
+ /** Set by Chromium when the response intercept was opted in via continueRequest. */
105
+ responseErrorReason?: string;
106
+ frameId?: string;
107
+ }
108
+
109
+ /** Options for {@link installInitInjector}. */
110
+ export interface InitInjectorOptions {
111
+ /** The compiled payload source code. May be empty when bypassInject is on. */
112
+ payloadCode: string | null;
113
+ /** Optional proxy credentials. When set, `Fetch.authRequired` is answered via `continueWithAuth`. */
114
+ auth?: { username: string; password: string };
115
+ }
116
+
117
+ /** Lifecycle handle. `dispose()` removes listeners and sends `Fetch.disable`. Idempotent. */
118
+ export interface InitInjectorHandle {
119
+ dispose(): Promise<void>;
120
+ }
121
+
122
+ /**
123
+ * Wire the unified Fetch-domain handler. Sends `Fetch.enable` once with the
124
+ * Document-first patterns, subscribes to `Fetch.requestPaused` (and
125
+ * `Fetch.authRequired` when `auth` is set), and tears down on `dispose()`.
126
+ *
127
+ * No-op fallback: when `payloadCode` is `null` AND `auth` is undefined, the
128
+ * function returns a disposed handle without sending `Fetch.enable` — this
129
+ * is the bypassInject + no-proxy capture path that wants zero protocol
130
+ * surface.
131
+ */
132
+ export async function installInitInjector(
133
+ router: MessageRouter,
134
+ opts: InitInjectorOptions,
135
+ ): Promise<InitInjectorHandle> {
136
+ const { payloadCode, auth } = opts;
137
+
138
+ // Capture flow with no proxy auth has nothing to do — return a disposed
139
+ // no-op handle so callers can construct uniformly.
140
+ if (payloadCode === null && auth === undefined) {
141
+ return {
142
+ async dispose(): Promise<void> {
143
+ // no-op
144
+ },
145
+ };
146
+ }
147
+
148
+ const wrappedPayload = payloadCode === null ? null : wrapSelfRemovingPayload(payloadCode);
149
+ // Per-request id-set — `requestPaused` may fire twice for the same id
150
+ // (request stage + response stage when interceptResponse: true) and we
151
+ // want one fulfillment per request.
152
+ const fulfilled = new Set<string>();
153
+
154
+ // Subscribe BEFORE Fetch.enable so we never miss the first event.
155
+ const offAuth: Unsubscribe | null = auth
156
+ ? router.on("Fetch.authRequired", (params) => {
157
+ const requestId = (params as { requestId?: string } | null)?.requestId;
158
+ if (typeof requestId !== "string") return;
159
+ router
160
+ .send("Fetch.continueWithAuth", {
161
+ requestId,
162
+ authChallengeResponse: {
163
+ response: "ProvideCredentials",
164
+ username: auth.username,
165
+ password: auth.password,
166
+ },
167
+ })
168
+ .catch((err: unknown) => {
169
+ if (!isClosedError(err)) {
170
+ console.warn("[mochi] Fetch.continueWithAuth failed:", err);
171
+ }
172
+ });
173
+ })
174
+ : null;
175
+
176
+ const offPaused: Unsubscribe = router.on("Fetch.requestPaused", (params, sessionId) => {
177
+ const ev = params as FetchRequestPausedEvent | null;
178
+ if (ev === null || typeof ev.requestId !== "string") return;
179
+ void handlePaused(router, ev, sessionId, wrappedPayload, fulfilled).catch((err: unknown) => {
180
+ if (!isClosedError(err)) {
181
+ console.warn(
182
+ `[mochi] init-injector: failed handling ${ev.resourceType ?? "?"} ${ev.request?.url ?? "?"}:`,
183
+ err,
184
+ );
185
+ }
186
+ // Best-effort: continue the request so it doesn't hang. Errors here
187
+ // most commonly mean we can't get the body (already consumed) — we
188
+ // still want the page to load.
189
+ router
190
+ .send(
191
+ "Fetch.continueRequest",
192
+ { requestId: ev.requestId },
193
+ sessionId !== undefined ? { sessionId } : {},
194
+ )
195
+ .catch(() => {
196
+ /* ignore */
197
+ });
198
+ });
199
+ });
200
+
201
+ // Patterns: Document first (matches first per CDP semantics), wildcard
202
+ // catch-all second so every other request also pauses for proxy-auth
203
+ // forwarding. Both stages of a Document request reach us when we set
204
+ // `interceptResponse: true` on the request-stage continueRequest.
205
+ await router.send("Fetch.enable", {
206
+ handleAuthRequests: auth !== undefined,
207
+ patterns: [{ urlPattern: "*", resourceType: "Document" }, { urlPattern: "*" }],
208
+ });
209
+
210
+ let disposed = false;
211
+ return {
212
+ async dispose(): Promise<void> {
213
+ if (disposed) return;
214
+ disposed = true;
215
+ offAuth?.();
216
+ offPaused();
217
+ try {
218
+ await router.send("Fetch.disable");
219
+ } catch (err) {
220
+ if (!isClosedError(err)) {
221
+ console.warn("[mochi] Fetch.disable failed:", err);
222
+ }
223
+ }
224
+ },
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Per-event dispatcher. Document responses get the body-splice path; every
230
+ * other resource is waved through with `continueRequest`.
231
+ */
232
+ async function handlePaused(
233
+ router: MessageRouter,
234
+ ev: FetchRequestPausedEvent,
235
+ sessionId: string | undefined,
236
+ wrappedPayload: string | null,
237
+ fulfilled: Set<string>,
238
+ ): Promise<void> {
239
+ const sendOpts = sessionId !== undefined ? { sessionId } : {};
240
+ const isDocument = ev.resourceType === "Document";
241
+ const isResponseStage = typeof ev.responseStatusCode === "number";
242
+
243
+ // Non-Document or no-payload: forward immediately.
244
+ if (!isDocument || wrappedPayload === null) {
245
+ await router.send("Fetch.continueRequest", { requestId: ev.requestId }, sendOpts);
246
+ return;
247
+ }
248
+
249
+ // Document, request stage (no response yet): opt into the response
250
+ // intercept so we get a second `requestPaused` carrying the body.
251
+ if (!isResponseStage) {
252
+ await router.send(
253
+ "Fetch.continueRequest",
254
+ { requestId: ev.requestId, interceptResponse: true },
255
+ sendOpts,
256
+ );
257
+ return;
258
+ }
259
+
260
+ // Document, response stage: fetch body, rewrite CSP, splice payload,
261
+ // fulfill. Guard against a duplicate fulfillment for the same id (shouldn't
262
+ // happen, but the set is cheap insurance).
263
+ if (fulfilled.has(ev.requestId)) return;
264
+ fulfilled.add(ev.requestId);
265
+
266
+ const bodyResp = await router.send<{ body: string; base64Encoded: boolean }>(
267
+ "Fetch.getResponseBody",
268
+ { requestId: ev.requestId },
269
+ sendOpts,
270
+ );
271
+ const originalBody = bodyResp.base64Encoded ? base64Decode(bodyResp.body) : bodyResp.body;
272
+
273
+ // Existing response headers may carry CSP. We rewrite them and pass them
274
+ // through to fulfillRequest. fulfill defaults Content-Length to body
275
+ // length, but we still strip any explicit Content-Length to avoid mismatch
276
+ // and the CSP pair.
277
+ const originalHeaders = ev.responseHeaders ?? [];
278
+ const { headers: rewrittenHeaders, scriptNonce } = rewriteHeaders(originalHeaders);
279
+ // CSP-in-meta rewrite happens on the body string (HTML).
280
+ const { html: cspFixedBody } = rewriteMetaCsp(originalBody);
281
+ const splicedBody = injectIntoHead(cspFixedBody, wrappedPayload, scriptNonce);
282
+
283
+ await router.send(
284
+ "Fetch.fulfillRequest",
285
+ {
286
+ requestId: ev.requestId,
287
+ responseCode: ev.responseStatusCode ?? 200,
288
+ responseHeaders: rewrittenHeaders,
289
+ body: base64Encode(splicedBody),
290
+ },
291
+ sendOpts,
292
+ );
293
+ }
294
+
295
+ // ---- payload wrapper --------------------------------------------------------
296
+
297
+ /**
298
+ * Wrap the raw payload in a self-removing IIFE that:
299
+ * 1. Removes its own `<script>` node from the DOM as the very first action.
300
+ * 2. Sets `__mochi_inject_marker = true` for conformance tests.
301
+ * 3. Schedules a post-`load` DOM walk to strip any sibling marker tags
302
+ * (defence in depth — the same payload may run in many frames during
303
+ * a single page lifecycle).
304
+ * 4. Runs the original payload.
305
+ *
306
+ * The wrapper produces no detectable global; the marker is gone after the
307
+ * conformance test reads it (the test reads via `Runtime.callFunctionOn`
308
+ * synchronously after `goto`).
309
+ */
310
+ export function wrapSelfRemovingPayload(payloadCode: string): string {
311
+ // Note: the inner IIFE wrapping is preserved AS-IS — buildPayload already
312
+ // emits `(()=>{ ... })()`. We just prepend the self-remove + marker block.
313
+ return [
314
+ "(function(){",
315
+ // Self-remove: keep the line short so a syntax error here is obvious.
316
+ "try{document.currentScript&&document.currentScript.remove&&document.currentScript.remove();}catch(_){}",
317
+ // Marker — set on every `globalThis` regardless of frame; the conformance
318
+ // test reads it on the top-level frame.
319
+ `try{Object.defineProperty(globalThis,${JSON.stringify(MOCHI_INIT_MARKER)},{value:true,writable:false,configurable:true});}catch(_){try{globalThis[${JSON.stringify(MOCHI_INIT_MARKER)}]=true;}catch(__){}}`,
320
+ // Belt: post-load DOM walk that strips any leftover marker tags.
321
+ `try{var __mochi_strip=function(){try{var ns=document.querySelectorAll(${JSON.stringify(`.${MOCHI_INIT_SCRIPT_CLASS}`)});for(var i=0;i<ns.length;i++){try{ns[i].parentNode&&ns[i].parentNode.removeChild(ns[i]);}catch(_){}}}catch(_){}}; if(document.readyState==='complete'){__mochi_strip();}else{addEventListener('load',__mochi_strip,{once:true});}}catch(_){}`,
322
+ // Original payload — already wrapped in its own IIFE by buildPayload, so
323
+ // we just splice it in. The trailing semicolon is defensive.
324
+ payloadCode,
325
+ ";})();",
326
+ ].join("\n");
327
+ }
328
+
329
+ // ---- HTML splice ------------------------------------------------------------
330
+
331
+ /**
332
+ * Splice an inline `<script>` carrying our payload into the document `<head>`
333
+ * AHEAD of any existing non-comment `<script>` tag. When no head exists,
334
+ * insert one at top of `<html>`. When that's also missing, prepend at the
335
+ * very top of the body.
336
+ *
337
+ * Critical: the tag MUST NOT carry `defer`, `async`, or `type="module"` —
338
+ * those defer execution past first parse and re-introduce the race window.
339
+ *
340
+ * @param html HTML source
341
+ * @param payloadCode JS source to inline (already self-removing-wrapped)
342
+ * @param nonce optional nonce attribute (when CSP requires nonce reuse)
343
+ */
344
+ export function injectIntoHead(
345
+ html: string,
346
+ payloadCode: string,
347
+ nonce: string | undefined,
348
+ ): string {
349
+ const idAttr = ` id="__mochi_init_${randomHex(16)}"`;
350
+ const classAttr = ` class="${MOCHI_INIT_SCRIPT_CLASS}"`;
351
+ const nonceAttr = nonce !== undefined && nonce.length > 0 ? ` nonce="${nonce}"` : "";
352
+ const tag = `<script${classAttr}${idAttr}${nonceAttr}>${payloadCode}</script>`;
353
+
354
+ // Find first `<script>` in the head, insert before it. We're not running
355
+ // an HTML parser; this is deliberately simple and matches every valid
356
+ // markup mochi tests against. patchright does the same.
357
+ const headOpen = findHeadOpen(html);
358
+ if (headOpen === null) {
359
+ // No <head>. Try <html> insert.
360
+ const htmlOpen = matchAfterTag(html, /<html[^>]*>/i);
361
+ if (htmlOpen !== null) {
362
+ return `${html.slice(0, htmlOpen)}<head>${tag}</head>${html.slice(htmlOpen)}`;
363
+ }
364
+ // No <html> either — prepend at top.
365
+ return tag + html;
366
+ }
367
+
368
+ // Search for the first `<script>` AFTER headOpen but inside <head>.
369
+ // Strip HTML comments before matching so we don't fall for
370
+ // `<!-- <script>... -->`.
371
+ const headEnd = findHeadClose(html, headOpen);
372
+ const headInner = headEnd === null ? html.slice(headOpen) : html.slice(headOpen, headEnd);
373
+ const stripped = stripHtmlComments(headInner);
374
+ // First `<script>` (with or without attributes) in the stripped slice.
375
+ const scriptMatch = /<script(\s|>)/i.exec(stripped);
376
+ if (scriptMatch !== null) {
377
+ // Translate the stripped-buffer offset back to the original buffer.
378
+ const orig = mapStrippedOffsetToOriginal(headInner, scriptMatch.index);
379
+ const insertAt = headOpen + orig;
380
+ return html.slice(0, insertAt) + tag + html.slice(insertAt);
381
+ }
382
+ // No existing <script> in head — splice just after `<head…>` so we still
383
+ // run before any inline script the body may carry.
384
+ return html.slice(0, headOpen) + tag + html.slice(headOpen);
385
+ }
386
+
387
+ /**
388
+ * Return the offset just after the opening `<head…>` tag, or null when
389
+ * there's no head.
390
+ */
391
+ function findHeadOpen(html: string): number | null {
392
+ return matchAfterTag(html, /<head(\s[^>]*)?>/i);
393
+ }
394
+
395
+ function findHeadClose(html: string, from: number): number | null {
396
+ const re = /<\/head\s*>/i;
397
+ const slice = html.slice(from);
398
+ const m = re.exec(slice);
399
+ return m === null ? null : from + m.index;
400
+ }
401
+
402
+ function matchAfterTag(html: string, re: RegExp): number | null {
403
+ const m = re.exec(html);
404
+ if (m === null) return null;
405
+ return m.index + m[0].length;
406
+ }
407
+
408
+ /** Replace `<!-- ... -->` with whitespace of identical length so offsets line up. */
409
+ function stripHtmlComments(s: string): string {
410
+ return s.replace(/<!--[\s\S]*?-->/g, (m) => " ".repeat(m.length));
411
+ }
412
+
413
+ /** Identity mapping — we used same-length whitespace replacement above. */
414
+ function mapStrippedOffsetToOriginal(_original: string, strippedOffset: number): number {
415
+ return strippedOffset;
416
+ }
417
+
418
+ // ---- CSP rewrite — headers --------------------------------------------------
419
+
420
+ /**
421
+ * Walk the response-header list, rewriting any `Content-Security-Policy[-
422
+ * Report-Only]` entries so an inline `<script>` we splice will be admitted.
423
+ *
424
+ * Returns the rewritten header list AND a nonce (extracted from the original
425
+ * `script-src 'nonce-…'` directive, when present) so we can attach it to the
426
+ * tag we splice. When multiple CSPs collide (rare — generally header + meta)
427
+ * we adopt the FIRST nonce we encounter; the rule "most-restrictive wins"
428
+ * means picking any one nonce always works because each policy is
429
+ * independently rewritten.
430
+ *
431
+ * Header names are case-insensitive on the wire; we preserve the original
432
+ * casing in the output to keep the response shape stable.
433
+ */
434
+ export function rewriteHeaders(headers: { name: string; value: string }[]): {
435
+ headers: { name: string; value: string }[];
436
+ scriptNonce: string | undefined;
437
+ } {
438
+ let scriptNonce: string | undefined;
439
+ const out: { name: string; value: string }[] = [];
440
+ for (const h of headers) {
441
+ const lower = h.name.toLowerCase();
442
+ if (lower === "content-security-policy" || lower === "content-security-policy-report-only") {
443
+ const { value, nonce } = rewriteCsp(h.value);
444
+ if (scriptNonce === undefined && nonce !== undefined) scriptNonce = nonce;
445
+ out.push({ name: h.name, value });
446
+ continue;
447
+ }
448
+ if (lower === "content-length") {
449
+ // Body changes shape; let Chromium recompute (omitting the header
450
+ // makes fulfillRequest set Content-Length itself).
451
+ continue;
452
+ }
453
+ out.push(h);
454
+ }
455
+ return { headers: out, scriptNonce };
456
+ }
457
+
458
+ // ---- CSP rewrite — meta tag in body -----------------------------------------
459
+
460
+ /**
461
+ * Rewrite any `<meta http-equiv="Content-Security-Policy" content="...">`
462
+ * tags in the HTML head. We do NOT remove the tag (callers may rely on its
463
+ * presence for non-script directives like `frame-ancestors`); we surgically
464
+ * relax the script-related directives in the `content` attribute instead.
465
+ */
466
+ export function rewriteMetaCsp(html: string): { html: string; firstNonce: string | undefined } {
467
+ // case-insensitive `<meta ... http-equiv="Content-Security-Policy" ... content="...">`
468
+ // Allow either order of attrs, single or double quotes.
469
+ const re =
470
+ /<meta\b[^>]*?http-equiv\s*=\s*("Content-Security-Policy"|'Content-Security-Policy')[^>]*>/gi;
471
+ let firstNonce: string | undefined;
472
+ const out = html.replace(re, (tag) => {
473
+ const contentRe = /content\s*=\s*("([^"]*)"|'([^']*)')/i;
474
+ const m = contentRe.exec(tag);
475
+ if (m === null) return tag;
476
+ const raw = (m[2] ?? m[3] ?? "") as string;
477
+ const decoded = htmlAttrDecode(raw);
478
+ const { value: rewritten, nonce } = rewriteCsp(decoded);
479
+ if (firstNonce === undefined && nonce !== undefined) firstNonce = nonce;
480
+ const encoded = htmlAttrEncode(rewritten);
481
+ const quoteChar = m[1]?.[0] ?? '"';
482
+ return tag.replace(contentRe, `content=${quoteChar}${encoded}${quoteChar}`);
483
+ });
484
+ return { html: out, firstNonce };
485
+ }
486
+
487
+ // ---- core CSP transformer ---------------------------------------------------
488
+
489
+ /**
490
+ * Transform a single CSP string so an inline script we splice is admitted.
491
+ *
492
+ * Rules
493
+ * -----
494
+ * - Walks every directive (`;`-separated). Only `script-src`,
495
+ * `script-src-elem`, and `default-src` are mutated; others pass through.
496
+ * - When the directive includes a nonce token (`'nonce-abc123'`), the
497
+ * nonce is returned and the directive is left intact — reusing the
498
+ * existing nonce on our `<script>` is sufficient.
499
+ * - When the directive includes `'strict-dynamic'`, the directive is left
500
+ * intact AND the nonce (if any) is extracted; strict-dynamic admits
501
+ * anything an already-admitted script then loads, but our INITIAL inline
502
+ * tag still needs a nonce. If no nonce exists alongside strict-dynamic
503
+ * the directive is invalid and we fall through to the unsafe-inline path.
504
+ * - Otherwise we ensure `'unsafe-inline'` is present.
505
+ *
506
+ * The function does not strip `'unsafe-eval'` or any unrelated directives.
507
+ */
508
+ export function rewriteCsp(input: string): { value: string; nonce: string | undefined } {
509
+ const directives = input
510
+ .split(";")
511
+ .map((s) => s.trim())
512
+ .filter((s) => s.length > 0);
513
+
514
+ let extractedNonce: string | undefined;
515
+ const out: string[] = [];
516
+ // Track whether we've already mutated either of the script-relevant
517
+ // directives so we can fall back to default-src if neither is present.
518
+ let sawScriptSrc = false;
519
+ let sawScriptSrcElem = false;
520
+
521
+ for (const directive of directives) {
522
+ const parts = directive.split(/\s+/);
523
+ const name = (parts[0] ?? "").toLowerCase();
524
+ const tokens = parts.slice(1);
525
+ if (name === "script-src" || name === "script-src-elem" || name === "default-src") {
526
+ const { tokens: rewritten, nonce } = adjustScriptSrcTokens(tokens);
527
+ if (extractedNonce === undefined && nonce !== undefined) extractedNonce = nonce;
528
+ out.push([name, ...rewritten].join(" "));
529
+ if (name === "script-src") sawScriptSrc = true;
530
+ if (name === "script-src-elem") sawScriptSrcElem = true;
531
+ continue;
532
+ }
533
+ out.push(directive);
534
+ }
535
+
536
+ // If a `default-src` was the only fallback for scripts and didn't admit
537
+ // inline, the rewrite already added `'unsafe-inline'` to default-src.
538
+ // Nothing more to do.
539
+ void sawScriptSrc;
540
+ void sawScriptSrcElem;
541
+
542
+ return { value: out.join("; "), nonce: extractedNonce };
543
+ }
544
+
545
+ /**
546
+ * Inspect `script-src`-style tokens. If a nonce is present, extract it and
547
+ * leave the tokens as-is. Otherwise add `'unsafe-inline'` (idempotent).
548
+ */
549
+ function adjustScriptSrcTokens(tokens: string[]): { tokens: string[]; nonce: string | undefined } {
550
+ let nonce: string | undefined;
551
+ for (const t of tokens) {
552
+ const m = /^'nonce-([^']+)'$/i.exec(t);
553
+ if (m !== null) {
554
+ nonce = m[1];
555
+ break;
556
+ }
557
+ }
558
+ if (nonce !== undefined) {
559
+ // Keep tokens intact — strict-dynamic et al stay live.
560
+ return { tokens, nonce };
561
+ }
562
+ // No nonce: ensure 'unsafe-inline' admits us. Idempotent.
563
+ if (!tokens.some((t) => t.toLowerCase() === "'unsafe-inline'")) {
564
+ return { tokens: [...tokens, "'unsafe-inline'"], nonce: undefined };
565
+ }
566
+ return { tokens, nonce: undefined };
567
+ }
568
+
569
+ // ---- helpers ----------------------------------------------------------------
570
+
571
+ /** Cryptographically-random hex string of `len` bytes. */
572
+ function randomHex(len: number): string {
573
+ const bytes = new Uint8Array(len);
574
+ crypto.getRandomValues(bytes);
575
+ let out = "";
576
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
577
+ return out;
578
+ }
579
+
580
+ /**
581
+ * Decode HTML attribute entities we might encounter inside a meta-tag's
582
+ * `content="…"` attribute. Spec-rigorous decoding is out of scope; the
583
+ * common entities are enough for the policies real-world sites ship.
584
+ */
585
+ function htmlAttrDecode(s: string): string {
586
+ return s
587
+ .replace(/&amp;/g, "&")
588
+ .replace(/&lt;/g, "<")
589
+ .replace(/&gt;/g, ">")
590
+ .replace(/&quot;/g, '"')
591
+ .replace(/&#39;/g, "'")
592
+ .replace(/&apos;/g, "'");
593
+ }
594
+
595
+ function htmlAttrEncode(s: string): string {
596
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
597
+ }
598
+
599
+ /** Decode a Chromium `Fetch.getResponseBody` base64 string into UTF-8. */
600
+ function base64Decode(s: string): string {
601
+ // Bun + browsers expose `atob`; fall back through Buffer for older runtimes.
602
+ if (typeof atob === "function") {
603
+ const bin = atob(s);
604
+ const bytes = new Uint8Array(bin.length);
605
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
606
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
607
+ }
608
+ // Buffer path — Bun has it globally.
609
+ const buf = (
610
+ globalThis as {
611
+ Buffer?: { from: (s: string, enc: string) => { toString(enc: string): string } };
612
+ }
613
+ ).Buffer;
614
+ if (buf !== undefined) return buf.from(s, "base64").toString("utf-8");
615
+ throw new Error("[mochi] no base64 decoder available");
616
+ }
617
+
618
+ /** Encode a UTF-8 string back into base64 for `Fetch.fulfillRequest`. */
619
+ function base64Encode(s: string): string {
620
+ const bytes = new TextEncoder().encode(s);
621
+ if (typeof btoa === "function") {
622
+ let bin = "";
623
+ for (const b of bytes) bin += String.fromCharCode(b);
624
+ return btoa(bin);
625
+ }
626
+ const buf = (
627
+ globalThis as {
628
+ Buffer?: { from: (b: Uint8Array) => { toString(enc: string): string } };
629
+ }
630
+ ).Buffer;
631
+ if (buf !== undefined) return buf.from(bytes).toString("base64");
632
+ throw new Error("[mochi] no base64 encoder available");
633
+ }
634
+
635
+ /** Closed-pipe / browser-crashed errors are non-actionable during teardown. */
636
+ function isClosedError(err: unknown): boolean {
637
+ if (err instanceof Error) {
638
+ return (
639
+ err.name === "BrowserCrashedError" ||
640
+ /transport already closed|pipe closed|browser process exited/i.test(err.message)
641
+ );
642
+ }
643
+ return false;
644
+ }