@mochi.js/core 0.2.2 → 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.
- package/README.md +19 -10
- package/package.json +4 -4
- package/src/__tests__/cookies-jar.test.ts +361 -0
- package/src/__tests__/default-profile.test.ts +181 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +245 -0
- package/src/__tests__/geo-consistency.test.ts +277 -0
- package/src/__tests__/geo-probe.test.ts +415 -0
- package/src/__tests__/init-injector.e2e.test.ts +144 -0
- package/src/__tests__/init-injector.test.ts +249 -0
- package/src/__tests__/inject.test.ts +80 -162
- package/src/__tests__/integration.e2e.test.ts +24 -0
- package/src/__tests__/page-dx-cluster.test.ts +292 -0
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proxy-auth.test.ts +22 -55
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/cdp/init-injector.ts +644 -0
- package/src/default-profile.ts +112 -0
- package/src/geo-consistency.ts +343 -0
- package/src/geo-probe.ts +603 -0
- package/src/index.ts +43 -1
- package/src/launch.ts +277 -17
- package/src/linux-server.ts +157 -0
- package/src/page.ts +420 -9
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +595 -78
|
@@ -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(/&/g, "&")
|
|
588
|
+
.replace(/</g, "<")
|
|
589
|
+
.replace(/>/g, ">")
|
|
590
|
+
.replace(/"/g, '"')
|
|
591
|
+
.replace(/'/g, "'")
|
|
592
|
+
.replace(/'/g, "'");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function htmlAttrEncode(s: string): string {
|
|
596
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
}
|