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/README.md +256 -68
- package/bin/suggest.mjs +56 -0
- package/package.json +3 -2
- package/src/core.mjs +40 -0
- package/src/fixture.mjs +68 -29
- package/src/install.mjs +327 -64
- package/types/core.d.ts +59 -1
- package/types/install.d.ts +59 -8
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
|
|
7
|
-
//
|
|
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
|
-
|
|
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:
|
|
37
|
-
blocked: counts.
|
|
38
|
-
observed: counts.
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 (
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
//
|
|
147
|
-
|
|
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
|
|
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
|
-
/**
|
|
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;
|