playwright-write-guard 0.2.0 → 0.3.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/src/install.mjs CHANGED
@@ -3,13 +3,16 @@
3
3
  // The network side judges each request at egress, where it would leave the browser: a
4
4
  // catch-all context route registered first (so it runs last), and the continue and fetch of
5
5
  // every route a caller registers later. A caller's handler sees every request and may fulfill
6
- // or abort it. The page side is an init script built from the decision core's own source,
7
- // with capture-phase listeners that refuse destructive controls before the app sees them.
6
+ // or abort it, which is recorded as a mock. Sends through the context's request client, and
7
+ // through clients and fetch functions wrapped with the handle, are judged before they are
8
+ // made. The page side is an init script built from the decision core's own source, with
9
+ // capture-phase listeners that refuse destructive controls before the app sees them, and, under
10
+ // read-only, judges the writes of a frame being left and removes SharedWorker and fetchLater.
8
11
  // No playwright import: everything goes through the context it is handed.
9
12
 
10
13
  import { existsSync, promises as fsp, readFileSync } from 'node:fs';
11
14
  import { resolve } from 'node:path';
12
- import { compileConfig, decideControl, decideRequest } from './core.mjs';
15
+ import { compileConfig, decideControl, decideFrame, decideRequest, decideSuspect } from './core.mjs';
13
16
 
14
17
  const BINDING = '__writeGuardReport';
15
18
  const ALREADY = 'write-guard: this context is already guarded by another process; use one mode, not both';
@@ -26,23 +29,26 @@ export async function install(context, config = {}) {
26
29
 
27
30
  async function setup(context, raw, cfg) {
28
31
  const events = [];
29
- const counts = { routed: 0, blocked: 0, observed: 0 };
32
+ // By event action; a websocket `open` counts in none of them.
33
+ const counts = { block: 0, observe: 0, mocked: 0, suspect: 0 };
34
+ let routed = 0;
30
35
  const logPath = cfg.log === null ? null : resolve(cfg.log);
31
36
  let pending = Promise.resolve();
32
37
 
33
38
  const record = () => ({
34
39
  installed: true,
35
40
  policy: cfg.policy,
36
- routed_requests: counts.routed,
37
- blocked: counts.blocked,
38
- observed: counts.observed,
39
- events: events.map((e) => ({ ...e })),
41
+ routed_requests: routed,
42
+ blocked: counts.block,
43
+ observed: counts.observe,
44
+ mocked: counts.mocked,
45
+ suspect: counts.suspect,
46
+ events: structuredClone(events),
40
47
  });
41
48
  const emit = (event) => {
42
49
  const e = { ...event, at: new Date().toISOString() };
43
50
  events.push(e);
44
- if (e.action === 'block') counts.blocked++;
45
- else if (e.action === 'observe') counts.observed++;
51
+ if (Object.hasOwn(counts, e.action)) counts[e.action]++;
46
52
  if (logPath) {
47
53
  const line = `${JSON.stringify(e)}\n`;
48
54
  pending = pending.then(() => fsp.appendFile(logPath, line))
@@ -61,42 +67,44 @@ async function setup(context, raw, cfg) {
61
67
  throw err;
62
68
  }
63
69
 
64
- // Judges the request as it would leave through `kind` ('catch-all', 'continue' or 'fetch'):
65
- // each of method, URL and body from `options` when given, else as the request reached this
66
- // point (route.request() reflects earlier fallback overrides). Headers are a whole set,
67
- // never merged, as Playwright replaces them. Then blocks it or lets it proceed.
70
+ const baseURL = context._options?.baseURL;
71
+ // Requests that reached egress through a caller's route.fetch: judged there, so a later
72
+ // fulfill or abort of them is not a mock.
73
+ const fetched = new WeakSet();
68
74
  const counted = new WeakSet();
75
+ // Judges the request as it would leave through `kind` ('catch-all', 'continue' or 'fetch'),
76
+ // then blocks it or lets it proceed. route.fetch sends what Playwright merges for its
77
+ // request client; the others send the request as it reached this point with `options`
78
+ // applied.
69
79
  const egress = async (route, options, kind) => {
70
80
  const req = route.request();
71
- const o = options ?? {};
72
- const method = String(o.method || req.method()).toUpperCase();
73
- const url = o.url || req.url();
74
- const p = o.postData;
75
- let body = null;
76
- if (p != null) body = typeof p === 'string' ? p : Buffer.isBuffer(p) ? p.toString('utf8') : JSON.stringify(p);
77
- else try { body = req.postData(); } catch { /* an undecodable body is judged as none */ }
78
- const headers = {};
79
- for (const [k, val] of Object.entries(o.headers || req.headers())) headers[k.toLowerCase()] = String(val);
80
- if (!counted.has(req)) { counted.add(req); counts.routed++; }
81
-
82
- const v = decideRequest({ method, url, body, headers }, cfg);
83
- if (v.action !== 'allow') {
84
- emit({
85
- kind: 'request', action: v.action, reason: v.reason, method, url,
86
- ...(headers['content-type'] === undefined ? {} : { contentType: headers['content-type'] }),
87
- ...(cfg.logBodies && body != null ? { body: body.slice(0, 500) } : {}),
88
- });
81
+ let r;
82
+ if (kind === 'fetch') {
83
+ fetched.add(req);
84
+ r = clientRequest({ request: req, data: options?.postData, ...options }, baseURL);
85
+ } else {
86
+ r = browserRequest(req, options);
89
87
  }
90
- if (v.action === 'block') {
91
- if (kind === 'fetch') throw new Error(`write-guard: route.fetch of ${method} ${url} is refused under read-only`);
88
+ if (!counted.has(req)) { counted.add(req); routed++; }
89
+
90
+ if (judge(r, cfg, emit) === 'block') {
91
+ if (kind === 'fetch') throw new Error(`write-guard: route.fetch of ${r.method} ${r.url} is refused under read-only`);
92
92
  if (req.resourceType() === 'document') {
93
- return route.fulfill({ status: 403, contentType: 'text/html; charset=utf-8', body: blockedPage(method, url) });
93
+ return route.fulfill({ status: 403, contentType: 'text/html; charset=utf-8', body: blockedPage(r.method, r.url) });
94
94
  }
95
95
  return route.abort('blockedbyclient');
96
96
  }
97
97
  // fallback, never continue, so handlers registered behind the catch-all still run.
98
98
  return kind === 'catch-all' ? route.fallback() : route[kind](options);
99
99
  };
100
+ // A caller's fulfill or abort: recorded when the request, as that handler received it, is a write.
101
+ const mock = (route) => {
102
+ const req = route.request();
103
+ if (fetched.has(req)) return;
104
+ const r = browserRequest(req);
105
+ const v = decideRequest(r, cfg);
106
+ if (v.action !== 'allow') emit(requestEvent('mocked', v.reason, r, cfg));
107
+ };
100
108
 
101
109
  // A predicate, not a glob, so no caller's unroute(url) can name it.
102
110
  await context.route(() => true, (route) => egress(route, undefined, 'catch-all'));
@@ -105,24 +113,36 @@ async function setup(context, raw, cfg) {
105
113
  if (cfg.websockets === 'block') {
106
114
  emit({ kind: 'request', action: 'block', reason: 'websockets is "block": every WebSocket is refused', url: ws.url() });
107
115
  await ws.close({ code: 1008, reason: 'write-guard' }).catch(() => {});
108
- } else {
109
- emit({ kind: 'websocket', action: 'open', url: ws.url() });
110
- ws.connectToServer();
116
+ return;
111
117
  }
118
+ emit({ kind: 'websocket', action: 'open', url: ws.url() });
119
+ const server = ws.connectToServer();
120
+ if (!cfg.websocketWrites.length) return;
121
+ // Only frames the page sends; the server's messages still reach the page untouched.
122
+ ws.onMessage((message) => {
123
+ const v = decideFrame(message, cfg);
124
+ if (v.action !== 'allow') {
125
+ emit({ kind: 'websocket', action: v.action, reason: v.reason, url: ws.url(), ...(cfg.logBodies ? { frame: message.slice(0, 500) } : {}) });
126
+ }
127
+ if (v.action !== 'block') server.send(message);
128
+ });
112
129
  });
113
130
 
114
131
  await context.addInitScript({ content: pageScript(raw) });
115
132
 
116
- patchRoutes(context, egress);
133
+ // One wrapper for the context's client; a page's `request` is the same object.
134
+ const client = judgeClient(context.request, cfg, { baseURL, emit });
135
+ const pinRequest = (target) => Object.defineProperty(target, 'request', { get: () => client, configurable: true });
136
+ pinRequest(context);
137
+ patchRoutes(context, egress, mock);
117
138
  if (cfg.policy === 'read-only') {
118
- lockRequest(context);
119
139
  // The context's dialog event is emitted before any page's, so this runs ahead of every
120
140
  // listener a caller adds later; their accept() then finds the dialog already handled.
121
141
  context.on('dialog', (d) => d.dismiss().catch(() => {}));
122
142
  }
123
143
  const onPage = (page) => {
124
- patchRoutes(page, egress);
125
- if (cfg.policy === 'read-only') lockRequest(page);
144
+ patchRoutes(page, egress, mock);
145
+ pinRequest(page);
126
146
  };
127
147
  for (const p of context.pages()) onPage(p);
128
148
  context.on('page', onPage);
@@ -135,19 +155,161 @@ async function setup(context, raw, cfg) {
135
155
  do { last = pending; await last; } while (last !== pending);
136
156
  return record();
137
157
  },
158
+ guardRequestClient: (target, { baseURL: base } = {}) => judgeClient(target, cfg, { baseURL: base, emit }),
159
+ guardFetch: (fetchFn) => async (...args) => {
160
+ const r = fetchRequest(args[0], args[1]);
161
+ if (judge(r, cfg, emit, 'node-fetch') === 'block') throw new Error(refused(r, 'fetch'));
162
+ return fetchFn(...args);
163
+ },
138
164
  };
139
165
  }
140
166
 
167
+ // Judges one effective request: emits a write's `block` or `observe` event, or a `suspect`
168
+ // event for an allowed GET or HEAD that looks side effecting. Returns the decideRequest action.
169
+ function judge(r, cfg, emit, via) {
170
+ const v = decideRequest(r, cfg);
171
+ if (v.action !== 'allow') {
172
+ emit(requestEvent(v.action, v.reason, r, cfg, via));
173
+ } else {
174
+ const s = decideSuspect(r, cfg);
175
+ if (s.suspect) emit(requestEvent('suspect', s.reason, r, cfg, via));
176
+ }
177
+ return v.action;
178
+ }
179
+
180
+ // `r.headers` has lower-case names, each once, so its keys are the header names.
181
+ const requestEvent = (action, reason, r, cfg, via) => ({
182
+ kind: 'request', action, reason, method: r.method, url: r.url,
183
+ ...(via ? { via } : {}),
184
+ ...(r.headers['content-type'] === undefined ? {} : { contentType: r.headers['content-type'] }),
185
+ headerNames: Object.keys(r.headers).sort(),
186
+ ...(cfg.logBodies && r.body != null ? { body: r.body.slice(0, 500) } : {}),
187
+ });
188
+
189
+ const refused = (r, via) => `write-guard: ${r.method} ${r.url} through ${via} is refused under read-only`;
190
+
191
+ // A browser request at a route: each of method, URL and body from `options` when given, else
192
+ // as the request reached this point (route.request() reflects earlier fallback overrides).
193
+ // Headers are a whole set, never merged, as Playwright replaces them.
194
+ function browserRequest(req, options) {
195
+ const o = options ?? {};
196
+ const method = String(o.method || req.method()).toUpperCase();
197
+ const url = o.url || req.url();
198
+ const p = o.postData;
199
+ let body = null;
200
+ if (p != null) body = typeof p === 'string' ? p : Buffer.isBuffer(p) ? p.toString('utf8') : JSON.stringify(p);
201
+ else try { body = req.postData(); } catch { /* an undecodable body is judged as none */ }
202
+ const headers = {};
203
+ for (const [k, val] of Object.entries(o.headers || req.headers())) headers[k.toLowerCase()] = String(val);
204
+ return { method, url, body, headers };
205
+ }
206
+
207
+ // Playwright's isString.
208
+ const isString = (v) => typeof v === 'string' || v instanceof String;
209
+ const parses = (s) => { try { JSON.parse(s); return true; } catch { return false; } };
210
+ const parseURL = (...args) => { try { return new URL(...args); } catch { return null; } };
211
+
212
+ // The effective request of an APIRequestContext send, read from the options `m` that
213
+ // Playwright 1.64 merges before it sends, the way it reads them.
214
+ function clientRequest(m, baseURL) {
215
+ const req = m.request;
216
+ const method = String(m.method || req?.method() || 'GET').toUpperCase();
217
+
218
+ const raw = m.url !== undefined ? m.url : req.url();
219
+ const u = (baseURL ? parseURL(raw, baseURL) : null) ?? parseURL(raw);
220
+ let url = String(raw);
221
+ if (u) {
222
+ const p = m.params;
223
+ if (typeof p === 'string' || p instanceof URLSearchParams) {
224
+ if (String(p)) u.search = String(p);
225
+ } else if (p !== null && typeof p === 'object') {
226
+ for (const [k, v] of Object.entries(p)) if (v !== undefined) u.searchParams.append(k, String(v));
227
+ }
228
+ u.hash = '';
229
+ url = u.href;
230
+ }
231
+
232
+ // As for...in enumerates them; the last of names equal after lower-casing is what is sent.
233
+ const h = m.headers || req?.headers();
234
+ const headers = {};
235
+ let jsonType = null;
236
+ for (const name in h ?? {}) {
237
+ const value = h[name];
238
+ if (value === undefined) continue;
239
+ const k = name.toLowerCase();
240
+ if (k === 'content-type' && jsonType === null) jsonType = value === 'application/json';
241
+ headers[k] = String(value);
242
+ }
243
+
244
+ let body = null;
245
+ const d = m.data;
246
+ if (d !== undefined) {
247
+ // Read as Playwright reads it: Buffer.from takes a String object's valueOf().
248
+ if (isString(d)) body = jsonType && !(typeof d === 'string' && parses(d)) ? JSON.stringify(d) : Buffer.from(d, 'utf8').toString('utf8');
249
+ else if (Buffer.isBuffer(d)) body = d.toString('utf8');
250
+ else body = JSON.stringify(d) ?? null;
251
+ } else if (!m.form && !m.multipart && req) {
252
+ body = req.postDataBuffer()?.toString('utf8') ?? null;
253
+ }
254
+ return { method, url, body, headers };
255
+ }
256
+
257
+ // The effective request of a Node fetch(input, init) call.
258
+ function fetchRequest(input, init) {
259
+ const isRequest = input instanceof globalThis.Request;
260
+ const method = String(init?.method || (isRequest ? input.method : '') || 'GET').toUpperCase();
261
+ const raw = typeof input === 'string' ? input : isRequest ? input.url : String(input);
262
+ const u = parseURL(raw);
263
+ if (u) u.hash = '';
264
+ const h = init?.headers !== undefined ? init.headers : isRequest ? input.headers : undefined;
265
+ const headers = {};
266
+ if (h !== undefined) for (const [k, v] of new Headers(h)) headers[k] = v;
267
+ return { method, url: u ? u.href : raw, body: typeof init?.body === 'string' ? init.body : null, headers };
268
+ }
269
+
270
+ // `client` with each send judged with the compiled `cfg`, its events passed to `emit`.
271
+ // Everything that is not a send reads through to the raw client, with methods bound to it,
272
+ // so Playwright's own calls (route.fetch reaches _innerFetch) are not judged again here.
273
+ function judgeClient(client, cfg, { baseURL, emit }) {
274
+ return new Proxy(client, {
275
+ get(obj, prop) {
276
+ if (typeof prop === 'string' && SENDS.has(prop)) {
277
+ return async (...args) => {
278
+ const [a, options] = args;
279
+ const m = { url: isString(a) ? a : undefined, request: isString(a) ? undefined : a, ...options };
280
+ if (prop !== 'fetch') m.method = prop.toUpperCase();
281
+ const r = clientRequest(m, baseURL);
282
+ if (judge(r, cfg, emit, 'request-client') === 'block') throw new Error(refused(r, 'APIRequestContext'));
283
+ // The raw client's own method: its named methods call its own fetch, not this wrapper's.
284
+ return obj[prop](...args);
285
+ };
286
+ }
287
+ const v = Reflect.get(obj, prop, obj);
288
+ return typeof v === 'function' ? v.bind(obj) : v;
289
+ },
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Internal, not part of the API: `client` with each send judged with the compiled config `cfg`
295
+ * and recorded nowhere, for a request client no guard records.
296
+ */
297
+ export function _judgeRequestClient(client, cfg, { baseURL } = {}) {
298
+ return judgeClient(client, cfg, { baseURL, emit: () => {} });
299
+ }
300
+
141
301
  const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
142
302
  const blockedPage = (method, url) => `<!doctype html><html><head><meta charset="utf-8"><title>write-guard</title></head>`
143
303
  + `<body><p>write-guard blocked ${escapeHtml(method)} ${escapeHtml(url)}</p></body></html>`;
144
304
 
145
- // The route a caller's handler gets: continue and fetch are egress points, judged first, and
146
- // everything else (fulfill, abort, fallback) is the original Route's.
147
- function judgedRoute(r, egress) {
305
+ // The route a caller's handler gets: continue and fetch are egress points, judged first;
306
+ // fulfill and abort are mock points, recorded first and then called unchanged; everything
307
+ // else (fallback) is the original Route's.
308
+ function judgedRoute(r, egress, mock) {
148
309
  return new Proxy(r, {
149
310
  get(obj, prop) {
150
311
  if (prop === 'continue' || prop === 'fetch') return async (options) => egress(obj, options, prop);
312
+ if (prop === 'fulfill' || prop === 'abort') return async (...args) => { mock(obj); return obj[prop](...args); };
151
313
  const v = Reflect.get(obj, prop, obj);
152
314
  return typeof v === 'function' ? v.bind(obj) : v;
153
315
  },
@@ -156,14 +318,14 @@ function judgedRoute(r, egress) {
156
318
 
157
319
  // Every handler a caller registers gets a judged route; unroute and unrouteAll remove only
158
320
  // the caller's own handlers.
159
- function patchRoutes(target, egress) {
321
+ function patchRoutes(target, egress, mock) {
160
322
  const route = target.route.bind(target);
161
323
  const unroute = target.unroute.bind(target);
162
324
  const tracked = [];
163
325
  const sameUrl = (a, b) => a === b
164
326
  || (a instanceof RegExp && b instanceof RegExp && a.source === b.source && a.flags === b.flags);
165
327
  target.route = (url, handler, options) => {
166
- const wrapped = (r, request) => handler(judgedRoute(r, egress), request);
328
+ const wrapped = (r, request) => handler(judgedRoute(r, egress, mock), request);
167
329
  tracked.push({ url, handler, wrapped });
168
330
  // The returned disposable calls target.unroute(url, wrapped), which lands below.
169
331
  return route(url, wrapped, options);
@@ -180,29 +342,17 @@ function patchRoutes(target, egress) {
180
342
  };
181
343
  }
182
344
 
183
- // The request object stays readable (Playwright's own teardown reads it); only sends throw.
184
- function lockRequest(target) {
185
- const original = target.request;
186
- const locked = new Proxy(original, {
187
- get(obj, prop) {
188
- if (typeof prop === 'string' && SENDS.has(prop)) {
189
- return async () => { throw new Error(`write-guard: ${prop} through APIRequestContext is refused under read-only`); };
190
- }
191
- const v = Reflect.get(obj, prop, obj);
192
- return typeof v === 'function' ? v.bind(obj) : v;
193
- },
194
- });
195
- Object.defineProperty(target, 'request', { get: () => locked, configurable: true });
196
- }
197
-
198
345
  // The page side. A capture-phase listener must decide synchronously, so the decision core
199
346
  // runs in the page, compiled from the same raw config.
200
347
  function pageScript(raw) {
201
348
  return `(() => {
349
+ // Captured before any page script runs, so the page cannot swap them out.
350
+ const URL = window.URL;
202
351
  const compileConfig = ${compileConfig.toString()};
203
352
  const decideControl = ${decideControl.toString()};
353
+ const decideSuspect = ${decideSuspect.toString()};
354
+ const decideRequest = ${decideRequest.toString()};
204
355
  const cfg = compileConfig(${JSON.stringify(raw ?? {})});
205
- // Captured before any page script runs, so the page cannot swap them out.
206
356
  const bind = window.${BINDING};
207
357
  const preventDefault = Function.prototype.call.bind(Event.prototype.preventDefault);
208
358
  const stopImmediatePropagation = Function.prototype.call.bind(Event.prototype.stopImmediatePropagation);
@@ -229,10 +379,32 @@ function pageScript(raw) {
229
379
  }
230
380
  return out;
231
381
  };
382
+ // The URL an a[href] navigates to, or null for a control judged by its label: anything that
383
+ // is not an a[href], and an a[href] whose trimmed href is empty or starts with '#', or that
384
+ // resolves to a javascript: URL.
385
+ const linkTarget = (el) => {
386
+ if (el.localName !== 'a' || !el.hasAttribute('href')) return null;
387
+ const href = el.getAttribute('href').trim();
388
+ if (href === '' || href.startsWith('#')) return null;
389
+ try {
390
+ const u = new URL(href, document.baseURI);
391
+ return u.protocol === 'javascript:' ? null : u.href;
392
+ } catch (_) {
393
+ return href;
394
+ }
395
+ };
232
396
  const judge = (el, e) => {
233
397
  if (!(el instanceof Element)) return;
234
398
  const label = labelOf(el);
235
- const v = decideControl({ label, icons: iconsOf(el) }, cfg);
399
+ const url = linkTarget(el);
400
+ let v;
401
+ if (url === null) {
402
+ v = decideControl({ label, icons: iconsOf(el) }, cfg);
403
+ } else {
404
+ // A link that navigates is judged by its URL only: a suspect one is destructive.
405
+ const s = decideSuspect({ method: 'GET', url }, cfg);
406
+ v = s.suspect ? { action: cfg.policy === 'observe' ? 'observe' : 'block', reason: s.reason } : { action: 'allow' };
407
+ }
236
408
  if (v.action === 'allow') return;
237
409
  if (v.action === 'block') {
238
410
  preventDefault(e);
@@ -252,6 +424,97 @@ function pageScript(raw) {
252
424
  window.addEventListener('keydown', (e) => {
253
425
  if ((e.key === 'Enter' || e.key === ' ') && isControl(document.activeElement)) judge(document.activeElement, e);
254
426
  }, true);
427
+ if (cfg.policy !== 'read-only') return;
428
+
429
+ // Under read-only, a write that a leaving frame sends is judged here, because Playwright 1.64
430
+ // does not route it; nothing is recorded. A frame that is not leaving is untouched.
431
+ const Req = window.Request, origFetch = window.fetch, reject = Promise.reject.bind(Promise);
432
+ const USP = window.URLSearchParams, AB = window.ArrayBuffer, isView = AB.isView, TD = window.TextDecoder;
433
+ const TE = TypeError;
434
+ const visibility = Object.getOwnPropertyDescriptor(Document.prototype, 'visibilityState').get;
435
+ const KEY = Symbol.for('playwright-write-guard.pagehide');
436
+ let hidden = false;
437
+ window.addEventListener('pagehide', () => { hidden = true; }, true);
438
+ // For a page restored from the back/forward cache. Playwright launches Chromium with
439
+ // --disable-back-forward-cache, so this branch is not reached under Playwright.
440
+ window.addEventListener('pageshow', () => { hidden = false; }, true);
441
+ Object.defineProperty(window, KEY, { get: () => hidden });
442
+ // This frame or a same-origin ancestor is hidden or has fired pagehide: when a parent's handler
443
+ // calls into a child frame, the child has seen neither yet. A cross-origin ancestor cannot be
444
+ // read, so the walk goes on past it to the same-origin ones above.
445
+ const leaving = () => {
446
+ for (let w = window; ; w = w.parent) {
447
+ try {
448
+ if (w[KEY] === true || visibility.call(w.document) === 'hidden') return true;
449
+ } catch (_) { /* cross-origin */ }
450
+ if (w === w.parent) return false;
451
+ }
452
+ };
453
+ const isRead = (m) => m === 'GET' || m === 'HEAD' || m === 'OPTIONS';
454
+ const resolveURL = (u) => {
455
+ try { const x = new URL(u, document.baseURI); x.hash = ''; return x.href; } catch (_) { return String(u); }
456
+ };
457
+ // A body the page can read synchronously as a string, else none.
458
+ const bodyText = (b) => typeof b === 'string' ? b
459
+ : b instanceof USP ? String(b)
460
+ : b instanceof AB || isView(b) ? new TD().decode(b)
461
+ : null;
462
+ // True when the send may proceed; false, after the warning, when it is refused.
463
+ const refusal = (m, u) => 'write-guard: ' + m + ' ' + u + ' from a hidden or unloading page is refused under read-only';
464
+ const allowed = (method, url, body, headers) => {
465
+ if (decideRequest({ method, url, body, headers }, cfg).action === 'allow') return true;
466
+ warn(refusal(method, url));
467
+ return false;
468
+ };
469
+
470
+ window.fetch = function fetch(input, init) {
471
+ if (!leaving()) return origFetch.apply(this, arguments);
472
+ let r;
473
+ try { r = new Req(input, init); } catch (e) { return reject(e); }
474
+ const method = r.method.toUpperCase();
475
+ if (isRead(method)) return origFetch(r);
476
+ const url = resolveURL(r.url);
477
+ const headers = { __proto__: null };
478
+ r.headers.forEach((v, k) => { headers[k] = v; });
479
+ const body = init != null && init.body !== undefined ? bodyText(init.body) : null;
480
+ return allowed(method, url, body, headers) ? origFetch(r) : reject(new TE(refusal(method, url)));
481
+ };
482
+
483
+ const Nav = Navigator.prototype, origBeacon = Nav.sendBeacon;
484
+ Nav.sendBeacon = function sendBeacon(url, data) {
485
+ if (leaving() && !allowed('POST', resolveURL(url), bodyText(data), {})) return false;
486
+ return origBeacon.apply(this, arguments);
487
+ };
488
+
489
+ const XHR = XMLHttpRequest.prototype, origOpen = XHR.open, origSet = XHR.setRequestHeader, origSend = XHR.send;
490
+ const opened = new WeakMap(), getOpened = WeakMap.prototype.get.bind(opened), setOpened = WeakMap.prototype.set.bind(opened);
491
+ XHR.open = function open(method, url) {
492
+ const out = origOpen.apply(this, arguments);
493
+ setOpened(this, { method: String(method).toUpperCase(), url: resolveURL(url), headers: { __proto__: null } });
494
+ return out;
495
+ };
496
+ XHR.setRequestHeader = function setRequestHeader(name, value) {
497
+ const out = origSet.apply(this, arguments);
498
+ const s = getOpened(this);
499
+ if (s) {
500
+ const k = String(name).toLowerCase();
501
+ s.headers[k] = k in s.headers ? s.headers[k] + ', ' + String(value) : String(value);
502
+ }
503
+ return out;
504
+ };
505
+ XHR.send = function send(body) {
506
+ const s = getOpened(this);
507
+ if (s && !isRead(s.method) && leaving() && !allowed(s.method, s.url, bodyText(body), s.headers)) {
508
+ throw new TE(refusal(s.method, s.url));
509
+ }
510
+ return origSend.apply(this, arguments);
511
+ };
512
+
513
+ // Neither a shared worker's HTTP nor a fetchLater request is routed, so both are removed (each is
514
+ // an own, configurable property of the window); an app that detects them falls back to a
515
+ // dedicated Worker, fetch or sendBeacon, which are judged.
516
+ delete window.SharedWorker;
517
+ delete window.fetchLater;
255
518
  })();`;
256
519
  }
257
520
 
package/types/core.d.ts CHANGED
@@ -49,9 +49,28 @@ export interface Config {
49
49
  defaultControls?: boolean;
50
50
  /** Default `"allow"`. */
51
51
  websockets?: 'allow' | 'block';
52
+ /**
53
+ * Default `[]`. Regex sources, compiled with no flags (case-sensitive) and tested
54
+ * unanchored against each outbound WebSocket text frame under `websockets: "allow"`.
55
+ * A frame matching any of them is a write. Empty: frames are not judged.
56
+ */
57
+ websocketWrites?: string[];
58
+ /**
59
+ * Default `[]`. Regex sources, compiled case-insensitive and tested unanchored against a
60
+ * GET or HEAD request's URL path plus query, added to the built-in suspect pattern.
61
+ */
62
+ suspectGets?: string[];
63
+ /**
64
+ * Default `true`: include the built-in suspect pattern,
65
+ * `(?:^|[/._-])(?:(?:log|sign)[-_]?out|unsubscribe|confirm|delete|remove)(?:$|[/._?&#-])|[?&]action=`.
66
+ */
67
+ defaultSuspectGets?: boolean;
52
68
  /** JSONL audit log path, relative to the working directory. Default `"write-guard.jsonl"`; `null` turns it off. */
53
69
  log?: string | null;
54
- /** Default `false`: when `true`, request events carry `body`, the first 500 characters of the request body. */
70
+ /**
71
+ * Default `false`: when `true`, request events carry `body`, the first 500 characters of the
72
+ * request body, and WebSocket frame events carry `frame`, the first 500 characters of the frame.
73
+ */
55
74
  logBodies?: boolean;
56
75
  }
57
76
 
@@ -75,6 +94,14 @@ export interface CompiledConfig {
75
94
  /** `controls.icons`; there are no built-in icons. */
76
95
  icons: string[];
77
96
  websockets: 'allow' | 'block';
97
+ /** `websocketWrites`, each compiled with no flags. */
98
+ websocketWrites: RegExp[];
99
+ /**
100
+ * The built-in suspect pattern (unless `defaultSuspectGets` is false) and every
101
+ * `suspectGets` source, each wrapped in `(?:...)` and joined with `|` into one
102
+ * case-insensitive pattern; `null` when there are none.
103
+ */
104
+ suspect: RegExp | null;
78
105
  log: string | null;
79
106
  logBodies: boolean;
80
107
  }
@@ -105,6 +132,23 @@ export interface ControlDecision {
105
132
  reason: string;
106
133
  }
107
134
 
135
+ export interface FrameDecision {
136
+ action: 'allow' | 'block' | 'observe';
137
+ reason: string;
138
+ }
139
+
140
+ export interface SuspectInput {
141
+ /** Compared in upper case: only `GET` and `HEAD` can be suspect. */
142
+ method: string;
143
+ /** An absolute URL is tested by its path plus query; a relative or unparsable one is tested as given. */
144
+ url: string;
145
+ }
146
+
147
+ export interface SuspectDecision {
148
+ suspect: boolean;
149
+ reason: string;
150
+ }
151
+
108
152
  /**
109
153
  * Validate a raw config and apply its defaults. `null` and `undefined` mean `{}`.
110
154
  * Throws `write-guard: <path>: <message>` on anything malformed.
@@ -116,3 +160,17 @@ export function decideRequest(request: RequestInput, config: CompiledConfig): Re
116
160
 
117
161
  /** `block` (read-only) or `observe` (observe) for a destructive control, else `allow`. */
118
162
  export function decideControl(control: ControlInput, config: CompiledConfig): ControlDecision;
163
+
164
+ /**
165
+ * `block` (read-only) or `observe` (observe) for a string frame matching any
166
+ * `websocketWrites` pattern, else `allow`. A frame that is not a string is always `allow`.
167
+ * Runs only in Node.
168
+ */
169
+ export function decideFrame(text: unknown, config: CompiledConfig): FrameDecision;
170
+
171
+ /**
172
+ * `suspect: true` only for a GET or HEAD whose URL matches the compiled `suspect` pattern:
173
+ * an absolute URL is tested by its `pathname` plus `search`, never its origin or hash; a
174
+ * relative or unparsable URL is tested as given. Self-contained, as the installer injects it into pages.
175
+ */
176
+ export function decideSuspect(request: SuspectInput, config: CompiledConfig): SuspectDecision;