playwright-write-guard 0.2.1 → 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 +244 -64
- 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/README.md
CHANGED
|
@@ -8,16 +8,17 @@ Pointing an automated browser at a real app is risky when the run should only lo
|
|
|
8
8
|
|
|
9
9
|
This package puts a read-only guard in front of the browser:
|
|
10
10
|
|
|
11
|
-
- **Requests.** Any request whose method is not GET, HEAD or OPTIONS is blocked, unless a signature in your `allow` list says it is really a read (a read-only GraphQL endpoint, for example). Requests that are really writes but look like reads can be named in `writes`.
|
|
12
|
-
- **Clicks.** A click, form submit, or Enter or Space keypress on a control whose label is a destructive verb (Save, Delete, Publish, Send, Archive, Pay, Purchase, Log out and so on) is stopped inside the page before the app's own handlers run, and the page's console gets a `write-guard refused "<label>": <reason>` warning, so an agent that reads the console learns why nothing happened. Native confirmation dialogs are declined.
|
|
13
|
-
- **Log.** Every write it blocked, or only observed under the `observe` policy, is recorded as one JSON line.
|
|
11
|
+
- **Requests.** Any request whose method is not GET, HEAD or OPTIONS is blocked, unless a signature in your `allow` list says it is really a read (a read-only GraphQL endpoint, for example). Requests that are really writes but look like reads can be named in `writes`. The same rules judge the sends of `context.request` and `page.request`, and, in the Playwright Test fixture, the `request` fixture and Node's `fetch` inside a test: a GET goes through and a write is refused.
|
|
12
|
+
- **Clicks.** A click, form submit, or Enter or Space keypress on a control whose label is a destructive verb (Save, Delete, Publish, Send, Archive, Pay, Purchase, Log out and so on) is stopped inside the page before the app's own handlers run, and the page's console gets a `write-guard refused "<label>": <reason>` warning, so an agent that reads the console learns why nothing happened. A link that navigates is judged by its URL, not its text (see [Suspect GETs](#websocket-frames-and-suspect-gets)). Native confirmation dialogs are declined.
|
|
13
|
+
- **Log.** Every write it blocked, or only observed under the `observe` policy, is recorded as one JSON line, and so is every write one of your own routes mocked and every side-effecting GET it noticed (`suspect`).
|
|
14
14
|
|
|
15
15
|
What it guarantees, and what it does not:
|
|
16
16
|
|
|
17
|
-
- **Enforced:** every HTTP request the pages of a guarded context send is judged at the network layer, whatever triggered it. This is the main layer: a "Continue" button that submits a payment still sends a request, and that request is judged. WebSockets are the exception: by default a socket opens and each open is recorded as a `websocket` event, but its messages, writes included, are not judged or logged (`websockets: "block"` refuses the sockets the guard routes, and apps that need a socket then do not load).
|
|
17
|
+
- **Enforced:** every HTTP request the pages of a guarded context send is judged at the network layer, whatever triggered it, with the exceptions below. This is the main layer: a "Continue" button that submits a payment still sends a request, and that request is judged. WebSockets are the first exception: by default a socket opens and each open is recorded as a `websocket` event, but its messages, writes included, are not judged or logged. List patterns in `websocketWrites` to judge the text frames the page sends (see [WebSocket frames](#websocket-frames-and-suspect-gets)); `websockets: "block"` refuses the sockets the guard routes, and apps that need a socket then do not load. The others are sends from a page that is being left, HTTP from a `SharedWorker` and requests made with `fetchLater`, which Playwright does not route (next item).
|
|
18
|
+
- **Leaving pages, shared workers and `fetchLater`:** Playwright does not route a request a page starts while it is being left (a `navigator.sendBeacon` or a `keepalive` fetch from a `pagehide` or `unload` handler, or an ordinary `fetch` or XHR then), nor the HTTP of a `SharedWorker`, nor a request made with `fetchLater`, even from a page that stays open. Under `read-only`, the page script judges the writes of a leaving page itself, with the same `allow` list and rules as the network layer: an allowed write goes out, and any other is refused (the beacon returns `false`, the `fetch` rejects, the XHR throws) with a console warning. A frame counts as leaving when it, or a same-origin frame above it, is hidden or has fired `pagehide`, also with a cross-origin frame in between. A refusal is recorded nowhere, because a page that is being left cannot report reliably, and reads from a leaving page are not judged. Under `read-only` the page script also removes `window.SharedWorker` and `window.fetchLater` in every frame, so an app that checks for them falls back to a dedicated `Worker` (routed) or to `fetch` or `sendBeacon` (judged), and an app that calls them without checking does not load that part. Under `observe`, nothing is replaced or removed: unload-time sends, the HTTP of a `SharedWorker` and `fetchLater` requests are not judged and not recorded.
|
|
18
19
|
- **Heuristic:** which requests count as writes, and which controls count as destructive.
|
|
19
|
-
- Requests are judged by method, unless your `allow` and `writes` signatures say otherwise. Apps that read over POST (GraphQL, search APIs, server actions, long polling) stop working until those endpoints are allow-listed. A run under `observe` lists them (method, URL and
|
|
20
|
-
- Controls are judged by label, and only real controls: buttons, links with an `href`, elements with a button, menuitem, link or tab role, and submit, button or image inputs. The click check is a second layer that stops the app before it starts a write. Its built-in verb list is short (save, delete, remove, publish, send, archive, pay, purchase, log out, sign out, logout): it still refuses some harmless controls such as "Remove filter" or "Save search", and misses writing controls such as "Place order" or "Add to cart". If it gets in the way, set `defaultControls: false` and name your app's own verbs in `controls.text`.
|
|
20
|
+
- Requests are judged by method, unless your `allow` and `writes` signatures say otherwise. Apps that read over POST (GraphQL, search APIs, server actions, long polling) stop working until those endpoints are allow-listed. A run under `observe` lists them (method, URL, content type and header names; set `logBodies: true` to add the first 500 characters of each body, which can hold credentials, so it is off by default), and [a command drafts the allow entries from that log](#writing-an-allow-list). Events carry header names, never values, so reads told apart only by a header value have to be found in the app's own code or network panel. GETs with side effects (logout, unsubscribe, `?action=delete`) pass, but a GET whose path or query matches a short built-in pattern is recorded as a `suspect` event; it is never blocked. Other side-effecting GETs pass with no event unless a `writes` signature names them.
|
|
21
|
+
- Controls are judged by label, and only real controls: buttons, links with an `href`, elements with a button, menuitem, link or tab role, and submit, button or image inputs. A link that navigates is judged by its URL instead of its label. The click check is a second layer that stops the app before it starts a write. Its built-in verb list is short (save, delete, remove, publish, send, archive, pay, purchase, log out, sign out, logout): it still refuses some harmless controls such as "Remove filter" or "Save search", and misses writing controls such as "Place order" or "Add to cart". If it gets in the way, set `defaultControls: false` and name your app's own verbs in `controls.text`.
|
|
21
22
|
- **Not covered:** anything outside the guarded context, such as other contexts, request clients the guard cannot reach, and code that removes the guard. See [What it cannot see](#what-it-cannot-see). The boundary is a restricted account: give the run credentials that cannot write, and use the guard to catch mistakes before they reach it.
|
|
22
23
|
- **Cost:** Playwright turns off the HTTP cache while a route is installed, so every load fetches every asset again. Service workers are blocked, so an app that waits for `navigator.serviceWorker.ready` hangs. Every WebSocket a page opens goes through Playwright's routing, which makes socket traffic noticeably slower.
|
|
23
24
|
|
|
@@ -30,7 +31,7 @@ npm i -D playwright-write-guard
|
|
|
30
31
|
npx playwright install chromium
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
It needs Node 20 or newer and Playwright 1.59 or newer: `@playwright/test` for the fixture, and `playwright-core` for the launcher and its command
|
|
34
|
+
It needs Node 20 or newer and Playwright 1.59 or newer: `@playwright/test` for the fixture, and `playwright-core` for the launcher (`playwright-write-guard/browser`) and its command `playwright-write-guard-browser`. Using the `initPage` entry with an agent needs a Node that can `require` an ES module (see below).
|
|
34
35
|
|
|
35
36
|
## Use with Playwright Test
|
|
36
37
|
|
|
@@ -47,13 +48,22 @@ test('lists items without changing them', async ({ page }) => {
|
|
|
47
48
|
});
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
Tests that only read through the page keep working. Under
|
|
51
|
+
Tests that only read through the page keep working. Under both policies, these change:
|
|
51
52
|
|
|
52
|
-
- The `request` fixture
|
|
53
|
-
-
|
|
54
|
-
-
|
|
53
|
+
- The `request` fixture, `page.request` and `context.request` work: a GET goes through, and a write is judged like a browser request. Under `read-only` it rejects without being sent, with `write-guard: POST <url> through APIRequestContext is refused under read-only`, and is recorded as a blocked `request` event (`via: "request-client"`); under `observe` it is recorded and sent. In a `beforeAll` or `afterAll` hook there is no test guard: a `request` fixture send there is judged (and refused under `read-only`) but not recorded.
|
|
54
|
+
- Node's global `fetch` is judged the same way while the test's own function runs (events carry `via: "node-fetch"`). In hooks and in fixture setup and teardown it still throws.
|
|
55
|
+
- `browser.newContext()` and `browser.newPage()` inside a test return a guarded context, created with `serviceWorkers: 'block'`, so tests with several users can create more contexts. Each one's record is attached as `write-guard-<n>.json`. In hooks and in fixture setup and teardown they still throw (a multi-user fixture that calls `browser.newContext()` fails at setup). `playwright.request.newContext()` and the browser types' `launch` and `connect` family still throw everywhere, because they make clients the guard cannot see.
|
|
56
|
+
- A test can mock a write: a `page.route` handler that `fulfill`s or `abort`s a POST runs, and the request never leaves the browser. The guard records it as a `mocked` event, which counts in `mocked`, not in `blocked`. A handler that passes the request on with `continue()` or `route.fetch()` is judged at that point, overrides included, so the write is still blocked. A mock is not the server: count writes on a real local server, not through a route that fulfills them.
|
|
55
57
|
- Blocked requests other than page navigations are aborted, which the page's console reports as `net::ERR_BLOCKED_BY_CLIENT` errors, so a test that asserts a clean console needs to filter those out.
|
|
56
|
-
- Blocked writes do not fail a test. Check the `blocked` count in the `write-guard.json` attachment if you want them to. A write that one of your own routes fulfills or aborts is
|
|
58
|
+
- Blocked writes do not fail a test. Check the `blocked` count in the `write-guard.json` attachment if you want them to. A write that one of your own routes fulfills or aborts is counted in `mocked`, not in `blocked`, so check `mocked` as well when the test must prove no write was attempted.
|
|
59
|
+
|
|
60
|
+
**Dialogs.** Under `read-only` the guard declines every dialog (`alert`, `confirm`, `prompt`, `beforeunload`) first, so a suite's own `accept` on the same dialog throws `Cannot accept dialog which is already handled!`, and its own `dismiss` throws `Cannot dismiss dialog which is already handled!`. The dialog is declined either way; catch the error so the test goes on:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
page.on('dialog', (dialog) => dialog.accept().catch(() => {}));
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A dismissed `beforeunload` keeps the page, so a test or an agent that leaves a page with unsaved changes (a `goto` elsewhere, a click on a link) stays on that page. A `page.goto` then throws `net::ERR_ABORTED`; a click on a link does not throw.
|
|
57
67
|
|
|
58
68
|
The fixture guards each test's context, created with `serviceWorkers: 'block'`. By default it reads `write-guard.config.json` from the directory you run Playwright in. Every key is optional; a small example that lets two named GraphQL queries through and blocks everything else, mutations included:
|
|
59
69
|
|
|
@@ -178,19 +188,47 @@ Logging in is a write, so under read-only a login form is blocked: the page show
|
|
|
178
188
|
- **Playwright MCP in config mode:** start it with `--isolated --storage-state <file>` to begin already logged in (Playwright MCP reads a storage state only for isolated sessions).
|
|
179
189
|
- **Launcher mode** takes no context or launch options yet (no storage state, proxy, `ignoreHTTPSErrors` or HTTP credentials): allow-list the login endpoints, or use config mode.
|
|
180
190
|
|
|
191
|
+
## WebSocket frames and suspect GETs
|
|
192
|
+
|
|
193
|
+
Two more things can be judged beyond methods and clicks. The first is off until you list patterns; the second is on with a small built-in pattern.
|
|
194
|
+
|
|
195
|
+
**WebSocket frames.** By default the guard records each socket a page opens and judges none of its messages. Set `websocketWrites` to a list of regex sources, for example `{ "websocketWrites": ["\"event\":\"delete_item\""] }`, and under `websockets: "allow"` each text frame the page sends is tested against them (case-sensitive, unanchored). Under `read-only` a matching frame is dropped, so the server never receives it, and recorded as a `websocket` event with `"action":"block"`; the socket stays open. Under `observe` it is recorded with `"action":"observe"` and forwarded. A frame that matches nothing is forwarded with no event. What stays out: binary frames always pass, messages the server sends to the page are never judged, and a write frame that none of your patterns matches passes, so the list is only as good as the patterns. A frame is JSON text, so a newline inside it is the two characters `\n`, not a line break, and a pattern like `"query":"\s*mutation` misses a formatted mutation (`"query":"\nmutation {`). To match both, write `{ "websocketWrites": ["\"query\":\"(?:\\\\n|\\s)*mutation"] }`.
|
|
196
|
+
|
|
197
|
+
**Suspect GETs.** A GET or HEAD is never blocked for its path alone, but one whose path plus query matches the built-in pattern is recorded as a `suspect` event and goes through. The pattern matches the words logout, sign-out (and `sign_out`), unsubscribe, confirm, delete and remove as a whole path segment or word, and any `action=` query parameter, case-insensitively: `/logout`, `/users/sign_out`, `/unsubscribe?t=1` and `/items?action=delete` are suspect, `/catalog` and `/deleted-items` are not. The pattern reads words in a URL, not what the URL does, so it also marks static assets such as `/static/icons/delete-icon.svg` (a harmless extra `suspect` event). It is tested against the path and query only, never the origin. `suspectGets` adds your own regex sources (case-insensitive, unanchored), and `"defaultSuspectGets": false` drops the built-in pattern. Browser requests, request client sends and Node `fetch` calls are all checked. To block a GET outright, name it in `writes`.
|
|
198
|
+
|
|
199
|
+
**Links.** A link that navigates is judged by its URL, not its text: an `a` with a real `href` is refused in the page under `read-only` (and recorded under `observe`) only when that URL is suspect, so a link to `/logout` or `/items?action=delete` is refused. The URL is what counts, not the text: a help link whose text says "Delete a repository" is not refused when its URL holds no built-in word (`/help/repositories`), and is refused when the URL does (`/help/delete-a-repository`, because a hyphen-separated word counts). Anchors whose `href` is empty, starts with `#` or is a `javascript:` URL, and elements with `role=link`, are still judged by their label.
|
|
200
|
+
|
|
201
|
+
What stays out of all of this: toggles (checkboxes and switches) and `change` events, binary frames, server-to-page messages, blocking suspect GETs, and custom-method paths such as `/items/7:delete`.
|
|
202
|
+
|
|
181
203
|
## The audit log
|
|
182
204
|
|
|
183
|
-
When `log` is a path (default `write-guard.jsonl`, relative to the process's working directory), each blocked or observed write is appended as one JSON object per line, as it happens,
|
|
205
|
+
When `log` is a path (default `write-guard.jsonl`, relative to the process's working directory), each blocked or observed write is appended as one JSON object per line, as it happens. So is each write one of your own routes mocked (`"action":"mocked"`), each GET or HEAD that looks side effecting (`"action":"suspect"`), each blocked or observed WebSocket frame, and each WebSocket opened under the default `websockets: "allow"`. The file is created when missing and never truncated. Allowed requests that are not suspect, allowed frames and allowed controls are not logged. Set `"log": null` to turn it off.
|
|
184
206
|
|
|
185
|
-
A blocked request, a refused control
|
|
207
|
+
A blocked request, a refused control, a WebSocket open and a suspect GET look like this (one line each in the file):
|
|
186
208
|
|
|
187
209
|
```json
|
|
188
|
-
{"kind":"request","action":"block","reason":"POST is not a read method; policy read-only","method":"POST","url":"https://app.example.test/items","contentType":"application/json","at":"2026-01-15T09:30:00.000Z"}
|
|
210
|
+
{"kind":"request","action":"block","reason":"POST is not a read method; policy read-only","method":"POST","url":"https://app.example.test/items","contentType":"application/json","headerNames":["accept","content-type"],"at":"2026-01-15T09:30:00.000Z"}
|
|
189
211
|
{"kind":"control","action":"block","reason":"\"Delete item\" matches a destructive label; policy read-only","label":"Delete item","at":"2026-01-15T09:30:02.000Z"}
|
|
190
212
|
{"kind":"websocket","action":"open","url":"wss://app.example.test/live","at":"2026-01-15T09:30:03.000Z"}
|
|
213
|
+
{"kind":"request","action":"suspect","reason":"GET /logout matches the suspect pattern","method":"GET","url":"https://app.example.test/logout","headerNames":["accept"],"at":"2026-01-15T09:30:04.000Z"}
|
|
191
214
|
```
|
|
192
215
|
|
|
193
|
-
A request event carries `method`, `url
|
|
216
|
+
A request event carries `method`, `url`, when the request has a `content-type` header `contentType`, and `headerNames`: the names of the request's headers, lower-cased and sorted, never their values. A send through a request client (`context.request`, `page.request`, the fixture's `request`) or Node's `fetch` also carries `via` (`"request-client"` or `"node-fetch"`); for a send made with an `APIRequestContext` the header names are those of the `headers` option (or the `Request`) you passed, not ones the client adds itself. With `logBodies: true` it also carries `body`, the first 500 characters of the request body; that is off by default because bodies can hold credentials and personal data, so treat a log that has them as sensitive. A control event carries `label` and none of the request fields. A websocket `open` event carries only its `url`; a frame event adds `reason` and, with `logBodies: true`, `frame`, the first 500 characters of the frame. `reason` is a human-readable note whose wording may change. Under the `observe` policy the same events are recorded with `"action":"observe"` and the write goes through, which is a way to find out what a run would have blocked. The full event shape is in the API below.
|
|
217
|
+
|
|
218
|
+
## Writing an allow list
|
|
219
|
+
|
|
220
|
+
Apps that read over POST stop working until those endpoints are in `allow`. To find them, observe a run and draft the entries from its log:
|
|
221
|
+
|
|
222
|
+
1. **Observe a run.** Run the same test, agent session or script with `"policy": "observe"` and a `log` path, with the guard in a mode that writes the log (the `initPage` entry or the launcher; `install` called directly works too). Nothing is blocked, and every write is recorded. The Playwright Test fixture never writes a log file: its events are in the `write-guard.json` attachment, and one JSON line per element of its `events` array makes a log the next step can read.
|
|
223
|
+
2. **Look at the header names.** Each request event carries `headerNames`, the request's header names, lower-cased and sorted; never their values. A read that differs from a write only by a header shows up there (a `next-action` name means a Next.js server action). The values you need for a `headers` signature have to come from the app's own code or network panel.
|
|
224
|
+
3. **Draft the entries.**
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
npx playwright-write-guard-suggest write-guard.jsonl > allow.json
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
It reads the log and never writes a file. It groups the `observe`, `block` and `mocked` request events by method and by origin plus path, prints `{ "allow": [...] }` on stdout, one entry per group, and one hint line per entry on stderr: how many events it saw, and a note when the group carries `next-action` or its path ends in `/graphql`. A group of three blocked `POST https://app.example.test/graphql` events gives the entry `{ "method": "POST", "url": "^https://app\\.example\\.test/graphql(?:[?#]|$)" }` and the hint `3 x POST https://app.example.test/graphql: GraphQL, add an anchored body pattern`. Allowed GETs are never logged, and `suspect` events and events without a method are skipped.
|
|
231
|
+
4. **Review before use.** The command cannot tell a read from a write: it lists every write the run attempted, the real ones included, and each entry allows every request with that method to that path, whatever its body or headers. Delete the entries that are real writes. Narrow the rest with a `body` pattern anchored at its start (GraphQL operation names) or a `headers` pattern (exact action IDs), as shown under Use with Playwright Test. Then put them in `allow`, run again under `read-only`, and check that the reads work and the writes are still blocked.
|
|
194
232
|
|
|
195
233
|
## What it cannot see
|
|
196
234
|
|
|
@@ -199,37 +237,52 @@ The guard judges requests the browser sends through its context and clicks the p
|
|
|
199
237
|
- Request clients it does not own: an attached client's own `page.request` and `context.request` in launcher mode, and `playwright.request.newContext()` called from an agent's run-code tool.
|
|
200
238
|
- An agent's run-code tool, and any browser the agent launches itself. In config mode, deny those tools as shown above.
|
|
201
239
|
- A Playwright Test run with `-c` from another directory without `WRITE_GUARD_CONFIG`, which falls back to the defaults.
|
|
202
|
-
- WebSocket messages: under the default `websockets: "allow"`, each socket a page opens is recorded as a `websocket` event,
|
|
240
|
+
- WebSocket messages, except the text frames `websocketWrites` names: under the default `websockets: "allow"`, each socket a page opens is recorded as a `websocket` event, and its messages (writes included, as in LiveView, Socket.IO or GraphQL subscriptions) are not judged or logged unless `websocketWrites` is set, and then only outbound text frames that match one of its patterns. Binary frames and messages the server sends to the page are never judged. `"block"` refuses every socket the guard routes, and apps that need a socket then do not load.
|
|
203
241
|
- WebSockets opened by dedicated or shared workers: they are not routed, so they are neither recorded nor blocked, under either setting.
|
|
204
242
|
- A caller's own `routeWebSocket` registered after the guard in the same process takes precedence: no open is recorded, and `websockets: "block"` does not refuse that socket. In launcher mode an attached client cannot register its own `routeWebSocket`: Playwright throws `Another client is already routing WebSockets`.
|
|
205
243
|
- The page's `WebSocket`: under both settings Playwright replaces it with its routed implementation, which an app that inspects or subclasses it can notice, and which makes socket traffic noticeably slower.
|
|
206
244
|
- WebRTC and WebTransport.
|
|
207
|
-
- Redirects after the first URL: only the request the page made is judged, not where the server redirects it.
|
|
208
|
-
- Drag-and-drop, and pointer or mouse handlers that act before `
|
|
245
|
+
- Redirects after the first URL: only the request the page, a request client or Node's `fetch` made is judged, not where the server redirects it.
|
|
246
|
+
- Drag-and-drop, and handlers that act without a `click`: pointer or mouse handlers that act before it, `dblclick` and `auxclick` handlers, and key handlers other than the Enter or Space keydown the guard judges (for example a `keyup` handler for Space on a `role=button`). The network layer still judges what they send.
|
|
209
247
|
- Client-side-only destructive actions that no click or submit reaches.
|
|
248
|
+
- Toggles: `role=switch` and `role=checkbox` elements and checkbox inputs, and `change` events, are not judged (a `<button role=switch>` is still a button and is judged by its label).
|
|
210
249
|
- Mistakes in the config: requests are classified only as well as the config, so a write sent as GET, or a read sent as POST, needs a `writes` or `allow` signature.
|
|
211
|
-
- GET requests with side effects
|
|
250
|
+
- GET requests with side effects: only a GET or HEAD whose path plus query matches the built-in suspect pattern or a `suspectGets` pattern is recorded, as `suspect`, and it is never blocked. Any other pass with no event unless a `writes` signature names them, and `observe` does not list ordinary GETs. The built-in pattern is short: it misses paths such as `/items/7:delete`, and it also marks harmless GETs such as the static asset `/static/icons/delete-icon.svg`.
|
|
212
251
|
- Reads that send no query text (persisted GraphQL queries without an operation name): when nothing in the method, URL, headers or body tells such a read from a write, a signature that allows the endpoint allows its mutations too.
|
|
213
|
-
- Request bodies in the log unless `logBodies` is `true`, and
|
|
252
|
+
- Request bodies in the log unless `logBodies` is `true`, and header values: events record the method, URL, content type and header names, so an `observe` run shows which endpoints were written to and which headers they carried, not with what. `logBodies: true` adds the first 500 characters of each body, and is off by default because bodies can hold credentials and personal data.
|
|
253
|
+
- Bodies sent as `form` or `multipart` through an `APIRequestContext`, and Node `fetch` bodies that are not strings (streams, `FormData`, `URLSearchParams`, Blob, buffers) or that belong to a `Request` given to `fetch`: they are not judged by body, so a `body` signature cannot see them (the method, URL and headers still are).
|
|
254
|
+
- A request client's own `extraHTTPHeaders`: they are not part of the request the guard judges (nor is a `content-type` the client sets itself for `data`), although browser egress sees a context's `extraHTTPHeaders`. A `headers` signature set up for a header the client adds on its own therefore does not match a request client's send.
|
|
255
|
+
- Values the guard reads once and Playwright or `fetch` reads again (an options getter, a `Request`'s `url()`, an object with its own `toString`): they can return something else the second time, so the request sent can differ from the one judged.
|
|
256
|
+
- A `fetch` function or `APIRequestContext` captured before the guard wrapped it: calls through the captured reference are not judged. Only `context.request`, `page.request`, the fixture's `request`, Node's `fetch` inside a test and what you pass to `guardRequestClient` or `guardFetch` are wrapped.
|
|
214
257
|
- In launcher mode, mocking a blocked write from a client's `context.route`: that handler runs after the guard's catch-all, so it never receives a blocked write. A client's `page.route` can mock one.
|
|
215
|
-
-
|
|
258
|
+
- Mocks outside the guard's process: a write that one of your own routes fulfills or aborts in the guard's process is recorded as `mocked`, but in launcher mode a client's routes run in the client's process, so their mocks are not recorded. A `fulfill` or `abort` of a request that already went through `route.fetch()` records nothing (the fetch was judged), and a GET that a route fulfills or aborts is never recorded as `suspect`. A mock is not counted in `blocked` or `observed`, so a broad stub such as `page.route('**/api/**', ...)` keeps its writes out of those counts; look at `mocked`.
|
|
259
|
+
- A `request` fixture send in a `beforeAll` or `afterAll` hook: it is judged but not recorded, because no test guard exists there, so a run's record and log do not list it.
|
|
216
260
|
- The launcher's browser and context options: it takes no storage state, proxy, `ignoreHTTPSErrors` or HTTP credentials.
|
|
217
261
|
- The HTTP cache: Playwright turns it off while a route is installed, so every load fetches every asset again.
|
|
218
262
|
- Service workers: they are blocked, so `register()` resolves but `navigator.serviceWorker.ready` never settles, and an app that waits for it hangs.
|
|
219
263
|
- In launcher mode, any context other than `contexts()[0]`: `--isolated`, or `browser.newContext()` from an agent's run-code tool, makes a new, unguarded context.
|
|
220
264
|
- In launcher mode, a client's `context.route` overrides are not judged again: that handler runs after the guard, in the client's process, so a `continue()` or `fallback()` that rewrites the method, URL or body (a GET rewritten to DELETE) reaches the server.
|
|
221
265
|
- In launcher mode, a client's `route.fetch()` from any route handler (`page.route` or `context.route`) is not seen: it is sent through the client's own `APIRequestContext`, which the guard cannot reach.
|
|
222
|
-
- Code in the same process that calls Playwright's prototype methods directly (for example `Object.getPrototypeOf(context).route` or `.unrouteAll`, `Object.getPrototypeOf(route).continue`,
|
|
266
|
+
- Code in the same process that calls Playwright's prototype methods directly (for example `Object.getPrototypeOf(context).route` or `.unrouteAll`, `Object.getPrototypeOf(route).continue`, `Object.getPrototypeOf(context.request).fetch`, or Playwright's private `_innerFetch`, which go around the guard's wrapper on `APIRequestContext`) or removes the guard's own `page` or `dialog` listeners (`context.removeAllListeners('page')` leaves new pages' routes unwrapped; `context.removeAllListeners('dialog')` lets a caller accept a dialog) goes around the guard's wrappers: the route it registers is not wrapped (its `continue()` can send a POST), `unrouteAll` removes the guard, and a `continue` with overrides skips the override judging.
|
|
223
267
|
- Routes and dialog listeners registered before `install`: a `page.route` whose `continue()` sends a write, a `context.route` whose overrides are not judged, and a page `dialog` listener that accepts all run outside the guard's wrappers.
|
|
224
|
-
-
|
|
268
|
+
- Navigating links are judged by their URL, not by their label or icons: an `a` with a real `href` whose text or icon class says Delete is not refused in the page unless its URL is suspect (the network layer still judges the request its navigation sends). Anchors whose `href` is empty, starts with `#` or is a `javascript:` URL, and elements with `role=link`, are still judged by label and icons.
|
|
225
269
|
- Controls the page does not judge: anchors without `href`, `<input type=reset>`, `summary`, plain elements that have only an `onclick` attribute or a `tabindex` (rows, cards), controls inside closed shadow roots, and plain text outside any control. The network layer still judges any request they send. Open shadow roots are judged through the event's composed path, using the shadow host's label when the control has none. An image input is never found by the submit fallback, so a `requestSubmit()` with no submitter on a form whose only submit control is an image input is not judged in the page (the network layer still judges the request).
|
|
226
270
|
- A document loaded with `page.setContent()`: the guard's in-page listeners do not carry over into it, so a destructive click there is not refused in the page. The network layer still judges any request it sends.
|
|
227
271
|
- Node's own `http`, `https` and `net` modules in the test process.
|
|
272
|
+
- Under `observe`, writes a page sends while it is being left (a `navigator.sendBeacon` or a `keepalive` fetch from a `pagehide` or `unload` handler, or an ordinary `fetch` or XHR then), the HTTP of a `SharedWorker`, and requests made with `fetchLater`, even from a page that stays open: Playwright does not route them, so they are neither judged nor recorded.
|
|
273
|
+
- A write that a caller's slow route is still holding when `page.close()` runs, and a write that a live page or its dedicated worker starts at the instant its page or context closes: Playwright no longer tracks the frame, so the request goes out unjudged, under `read-only` too (without the guard, a route sees no such write either). Apart from that instant, a dedicated worker's requests are routed.
|
|
274
|
+
- A cross-origin frame that sends when its parent tells it to (a `postMessage` from the parent's `pagehide` handler, say): it has not fired `pagehide` itself and cannot read its parent, so it is not leaving, and on `page.close()` its write goes out unjudged, under `read-only` too.
|
|
275
|
+
- Reads from a leaving frame: under `read-only` only its writes are judged in the page. A GET, HEAD or OPTIONS goes through unjudged, a suspect GET included.
|
|
276
|
+
- A leaving frame's write whose body the page cannot read (a `Blob` or a `FormData`, or the body of a `Request` given to `fetch`): it is judged with no body, so a `body` signature never matches it, and a beacon carries no headers in the page's view, so a `headers` signature (a content type, say) does not match one either. Such a write is refused unless an `allow` signature without a `body` or `headers` matches it.
|
|
277
|
+
- Everything a leaving frame sends under `read-only`: a refusal is a console warning and an allowed write goes out, but neither is recorded or counted, so the record and the log do not list them. After a `page.goto`, the warning may not reach `page.on('console')`.
|
|
278
|
+
- In a headed browser, a background tab under `read-only`: it is `hidden`, so it counts as leaving, and its writes are judged in the page as above instead of at the network layer (not recorded, and a `Blob` or `FormData` body fails a `body` signature).
|
|
228
279
|
|
|
229
280
|
It also refuses too much, which is the safe direction but can stop a run:
|
|
230
281
|
|
|
231
282
|
- The built-in verbs match harmless controls too, because a label matches as a whole word anywhere in it ("Remove filter", "Save search", "Send feedback", "Pay attention").
|
|
232
|
-
-
|
|
283
|
+
- A link that navigates is refused in the page when its URL matches the suspect pattern, although following it may only read: a link to `/logout`, or to any URL with an `action=` query parameter, is refused, and so is a slugged URL that holds a built-in word, such as a help link to `/help/delete-a-repository`. Links judged by label (an empty, `#` or `javascript:` href, and `role=link`) are refused when their text holds a built-in verb ("Delete a repository" as a `#` link).
|
|
284
|
+
- Under `read-only`, `window.SharedWorker` and `window.fetchLater` are removed in every frame: an app that checks for them falls back to a dedicated `Worker`, `fetch` or `sendBeacon`, and an app that calls them without checking does not load that part.
|
|
285
|
+
- Under `read-only`, a dismissed `beforeunload` keeps the page: a test or an agent that leaves a page with unsaved changes stays on it, and a `page.goto` away from it throws `net::ERR_ABORTED` (a click on a link does not throw).
|
|
233
286
|
- A real control whose label contains a verb is refused even when it only reads. Text fields are never refused (a typed space or Enter goes through; Enter still submits a form implicitly, and that submit is judged by its submit button).
|
|
234
287
|
|
|
235
288
|
It is a safety net, not a security boundary. A restricted test account is the boundary: give the run credentials that cannot write, and use the guard to catch the mistakes that slip past.
|
|
@@ -240,13 +293,13 @@ The package has five entry points, each with its own type declarations:
|
|
|
240
293
|
|
|
241
294
|
| Import | Exports |
|
|
242
295
|
|---|---|
|
|
243
|
-
| `playwright-write-guard` | `compileConfig`, `decideRequest`, `decideControl` |
|
|
296
|
+
| `playwright-write-guard` | `compileConfig`, `decideRequest`, `decideControl`, `decideFrame`, `decideSuspect` |
|
|
244
297
|
| `playwright-write-guard/install` | `install`, `loadConfig` |
|
|
245
298
|
| `playwright-write-guard/test` | `test`, `expect` |
|
|
246
299
|
| `playwright-write-guard/init-page` | the default export, an `initPage` function |
|
|
247
300
|
| `playwright-write-guard/browser` | `startGuardedBrowser` |
|
|
248
301
|
|
|
249
|
-
It also installs
|
|
302
|
+
It also installs two commands: `playwright-write-guard-browser`, which starts a guarded browser (see the launcher below), and `playwright-write-guard-suggest`, which drafts `allow` entries from an audit log (see the suggest command below). It needs Node 20 or newer and Playwright 1.59 or newer (`@playwright/test` for the fixture; `playwright-core` is needed at runtime only by `playwright-write-guard/browser` and `playwright-write-guard-browser`, and the other entry points use the context Playwright gives them).
|
|
250
303
|
|
|
251
304
|
### Config
|
|
252
305
|
|
|
@@ -260,6 +313,9 @@ Every key is optional, and unknown keys are ignored:
|
|
|
260
313
|
"controls": { "text": [], "icons": [] },
|
|
261
314
|
"defaultControls": true,
|
|
262
315
|
"websockets": "allow",
|
|
316
|
+
"websocketWrites": [],
|
|
317
|
+
"suspectGets": [],
|
|
318
|
+
"defaultSuspectGets": true,
|
|
263
319
|
"log": "write-guard.jsonl",
|
|
264
320
|
"logBodies": false
|
|
265
321
|
}
|
|
@@ -274,8 +330,11 @@ Every key is optional, and unknown keys are ignored:
|
|
|
274
330
|
| `controls.icons` | list of class name prefixes | `[]` | a control with a class starting with one of these is destructive |
|
|
275
331
|
| `defaultControls` | boolean | `true` | include the 11 built-in verbs (listed under Writes and controls) in the label pattern; `false` drops them |
|
|
276
332
|
| `websockets` | `"allow"` or `"block"` | `"allow"` | `allow` records each WebSocket open and lets it connect; `block` refuses every WebSocket |
|
|
333
|
+
| `websocketWrites` | list of regex sources | `[]` | under `websockets: "allow"`, an outbound WebSocket text frame matching any of these is a write: dropped under `read-only`, recorded and forwarded under `observe`. Compiled with no flags (case-sensitive) and tested unanchored against the frame's text. Empty: frames are not judged |
|
|
334
|
+
| `suspectGets` | list of regex sources | `[]` | extra patterns that mark a GET or HEAD as suspect (recorded, never blocked), added to the built-in suspect pattern. Compiled case-insensitive and tested unanchored against the URL's path plus query |
|
|
335
|
+
| `defaultSuspectGets` | boolean | `true` | include the built-in suspect pattern (shown under Writes and controls); `false` drops it |
|
|
277
336
|
| `log` | path or `null` | `"write-guard.jsonl"` | the JSONL audit log, relative to the process's working directory; `null` turns it off |
|
|
278
|
-
| `logBodies` | boolean | `false` | add `body`, the first 500 characters of the request body, to request events (in `record()` and the log); off by default because bodies can hold credentials and personal data |
|
|
337
|
+
| `logBodies` | boolean | `false` | add `body`, the first 500 characters of the request body, to request events, and `frame`, the first 500 characters of the frame, to WebSocket frame events (in `record()` and the log); off by default because bodies and frames can hold credentials and personal data |
|
|
279
338
|
|
|
280
339
|
A **Sig** (request signature) is `{ "method": M, "url": U, "body": B, "headers": H }`:
|
|
281
340
|
|
|
@@ -289,6 +348,12 @@ A **Sig** (request signature) is `{ "method": M, "url": U, "body": B, "headers":
|
|
|
289
348
|
|
|
290
349
|
**A request is a write** when its method, upper-cased, is not GET, HEAD or OPTIONS, or it matches a `writes` signature, **and** it matches no `allow` signature. The method is upper-cased before the GET, HEAD or OPTIONS check. `allow` always wins: `{ "method": "POST", "url": "^https://app[.]example[.]com/graphql" }` in `allow` lets a read-only GraphQL endpoint through.
|
|
291
350
|
|
|
351
|
+
**A request is suspect** when its method, upper-cased, is GET or HEAD and its URL matches the suspect pattern. The suspect pattern is the built-in pattern (unless `defaultSuspectGets` is `false`) plus every `suspectGets` source, joined into one case-insensitive regex. It is tested, unanchored, against the URL's path plus query (`pathname` followed by `search`, never the origin or the hash) when the URL parses as an absolute URL, and against the URL as given when it is relative or does not parse. The built-in pattern is:
|
|
352
|
+
|
|
353
|
+
`(?:^|[/._-])(?:(?:log|sign)[-_]?out|unsubscribe|confirm|delete|remove)(?:$|[/._?&#-])|[?&]action=`
|
|
354
|
+
|
|
355
|
+
So `/logout`, `/users/sign_out`, `/unsubscribe?t=1` and `/items?action=delete` are suspect, and `/catalog` and `/deleted-items` are not. A suspect request is never blocked at the network: wherever a GET or HEAD is allowed (browser egress, request clients, Node's `fetch`), a suspect one is recorded as a `suspect` event and proceeds. With no built-in pattern and no `suspectGets`, nothing is suspect. A `writes` signature still makes a GET a write.
|
|
356
|
+
|
|
292
357
|
**A control is destructive** when its label matches the label pattern, or it has a class starting with one of the `controls.icons` prefixes. The label pattern is the built-in verbs (unless `defaultControls` is `false`) plus every `controls.text` source, joined into one case-insensitive regex. The built-in verbs match as whole words, case-insensitive (`Save` and `Delete item` match, `Saved` does not):
|
|
293
358
|
|
|
294
359
|
`save`, `delete`, `remove`, `publish`, `send`, `archive`, `pay`, `purchase`, `log out`, `sign out`, `logout`
|
|
@@ -303,23 +368,40 @@ In the page, a **control** is an element matching the control selector `button,
|
|
|
303
368
|
|
|
304
369
|
A control's **label** is the first non-empty, trimmed value of its `aria-label` attribute, its `innerText`, its `title` attribute and its `value`. When that is empty and the control is inside a shadow root, the label is the same rule applied to that shadow root's host (so `<x-btn>Delete</x-btn>`, whose shadow root holds a `<button>` with a `<slot>`, is labelled `Delete`). Its **icons** are every class name on the control and on each of its descendants.
|
|
305
370
|
|
|
306
|
-
|
|
371
|
+
Every control is judged by its label and icons with `decideControl`, except a **link that navigates**. When the control is an `a` element with an `href` attribute (whatever its `role`), the page reads that attribute and trims it:
|
|
372
|
+
|
|
373
|
+
- An `a[href]` is non-navigating when its `href`, trimmed, is empty or starts with `#`, or when, with the `href` resolved against `document.baseURI` (`new URL(href, document.baseURI)`), the resolved URL's protocol is `javascript:` (so tabs or newlines inside the scheme cannot escape the rule). A non-navigating link is judged by its label and icons like any other control.
|
|
374
|
+
- Otherwise the link navigates, and it is judged by its URL, never by its label or icons. The URL is the trimmed `href` resolved against the document's base URL (`new URL(href, document.baseURI).href`; the base URL is the page's URL unless a `<base>` element sets another), or the trimmed `href` as written when that throws. It is checked with `decideSuspect({ method: 'GET', url })`. A suspect link is treated as a destructive control with its label: under `read-only` its event is stopped and the console warning is written, as below, and under either policy it is reported as a control event whose `action` is `"block"` under `read-only` and `"observe"` under `observe`, and whose `reason` is the one `decideSuspect` returned. Any other navigating link is not judged in the page; the request its navigation sends is judged at the network layer.
|
|
375
|
+
|
|
376
|
+
Elements matching `[role=link]` that are not `a` elements with an `href` keep the label rule.
|
|
377
|
+
|
|
378
|
+
Not judged in the page, by design: anchors without `href`, navigating links whose URL is not suspect, `<input type=reset>`, `summary`, plain elements that have only an `onclick` attribute or a `tabindex` (rows, cards), and controls inside closed shadow roots (the path stops at the shadow host). The network layer still judges every request any of them sends.
|
|
307
379
|
|
|
308
380
|
### The decision core: `playwright-write-guard`
|
|
309
381
|
|
|
310
382
|
```js
|
|
311
|
-
import { compileConfig, decideRequest, decideControl } from 'playwright-write-guard';
|
|
383
|
+
import { compileConfig, decideRequest, decideControl, decideFrame, decideSuspect } from 'playwright-write-guard';
|
|
312
384
|
|
|
313
|
-
const config = compileConfig({
|
|
385
|
+
const config = compileConfig({
|
|
386
|
+
allow: [{ method: 'POST', url: '^https://app[.]example[.]com/graphql' }],
|
|
387
|
+
websocketWrites: ['"event":"delete_item"'],
|
|
388
|
+
});
|
|
314
389
|
decideRequest({ method: 'POST', url: 'https://app.example.com/items', body: '{}' }, config);
|
|
315
390
|
// { action: 'block', reason: '...' }
|
|
316
391
|
decideControl({ label: 'Delete item', icons: [] }, config);
|
|
317
392
|
// { action: 'block', reason: '...' }
|
|
393
|
+
decideFrame('{"event":"delete_item","id":7}', config);
|
|
394
|
+
// { action: 'block', reason: '...' }
|
|
395
|
+
decideSuspect({ method: 'GET', url: 'https://app.example.com/logout' }, config);
|
|
396
|
+
// { suspect: true, reason: '...' }
|
|
318
397
|
```
|
|
319
398
|
|
|
320
399
|
- `compileConfig(raw)` validates a raw config (`null` and `undefined` mean `{}`), applies every default and compiles every pattern. On anything malformed it throws an `Error` whose message is `write-guard: <path>: <message>`, where `<path>` names the offending key the way the config spells it (`policy`, `allow[0].method`, `controls.text[2]`, `allow[0].headers.Next-Action`), or `config` for the whole value. Malformed means:
|
|
321
400
|
- a config that is not an object;
|
|
322
401
|
- a `policy` or `websockets` outside its allowed values;
|
|
402
|
+
- `websocketWrites` or `suspectGets` that is not a list, which throws `write-guard: <key>: must be a list` (for example `write-guard: websocketWrites: must be a list`);
|
|
403
|
+
- a `websocketWrites` or `suspectGets` entry that is not a non-empty string, which throws `write-guard: <key>[<i>]: must be a non-empty regex string` (for example `write-guard: websocketWrites[0]: must be a non-empty regex string`), or that does not compile (a `websocketWrites` entry with no flags, a `suspectGets` entry case-insensitive), which throws `write-guard: <key>[<i>]: does not compile: <message>`;
|
|
404
|
+
- `defaultSuspectGets` that is not a boolean, which throws `write-guard: defaultSuspectGets: must be a boolean`;
|
|
323
405
|
- a Sig `method` that is neither `"*"` nor a string matching `^[A-Za-z][A-Za-z0-9-]*$`, which throws `write-guard: <path>.method: must be "*" or a method name` (for example `write-guard: allow[0].method: must be "*" or a method name`);
|
|
324
406
|
- `allow`, `writes`, `controls.text` or `controls.icons` that is not a list;
|
|
325
407
|
- a Sig that is not an object;
|
|
@@ -332,12 +414,14 @@ decideControl({ label: 'Delete item', icons: [] }, config);
|
|
|
332
414
|
- `logBodies` that is not a boolean, which throws `write-guard: logBodies: must be a boolean`;
|
|
333
415
|
- `log` that is neither a non-empty string nor `null`.
|
|
334
416
|
|
|
335
|
-
The result is `{ policy, allow, writes, text, icons, websockets, log, logBodies }`: `allow` and `writes` hold `{ method, url: RegExp, body: RegExp | null, headers: Array<{ name: string, value: RegExp }> }`, where `method` is `"*"` or the method name in upper case and `headers` holds one entry per named header, its `name` in lower case (empty when the Sig names none); `text` is the label pattern (`null` when there is none); `icons` is `controls.icons`; `logBodies` is `false` unless the config sets it to `true`.
|
|
417
|
+
The result is `{ policy, allow, writes, text, icons, websockets, websocketWrites, suspect, log, logBodies }`: `allow` and `writes` hold `{ method, url: RegExp, body: RegExp | null, headers: Array<{ name: string, value: RegExp }> }`, where `method` is `"*"` or the method name in upper case and `headers` holds one entry per named header, its `name` in lower case (empty when the Sig names none); `text` is the label pattern (`null` when there is none); `icons` is `controls.icons`; `websocketWrites` is a `RegExp[]`, one per source, each compiled with no flags (empty by default); `suspect` is the suspect pattern, a single case-insensitive `RegExp` made of the built-in pattern (unless `defaultSuspectGets` is `false`) and every `suspectGets` source, each wrapped in `(?:...)` and joined with `|`, or `null` when there are none; `logBodies` is `false` unless the config sets it to `true`.
|
|
336
418
|
- `decideRequest({ method, url, body, headers }, compiled)` returns `{ action, reason }`. `body` is `null` or absent when the request has none. `headers` is an object of header name to value, names in any case (`decideRequest` lower-cases them itself), absent when the request has none, in which case no Sig that names headers matches. For a write, `action` is `"block"` under `read-only` and `"observe"` under `observe`; for anything else it is `"allow"`.
|
|
337
419
|
- `decideControl({ label, icons }, compiled)` returns `{ action, reason }`. For a destructive control, `action` is `"block"` under `read-only` and `"observe"` under `observe`; for anything else it is `"allow"`.
|
|
420
|
+
- `decideFrame(text, compiled)` returns `{ action, reason }` for one outbound WebSocket frame. When `text` is a string that matches any `compiled.websocketWrites` pattern (tested unanchored), `action` is `"block"` under `read-only` and `"observe"` under `observe`. A string that matches none, and a frame that is not a string (a binary frame), are always `"allow"`. With an empty `websocketWrites`, every frame is `"allow"`.
|
|
421
|
+
- `decideSuspect({ method, url }, compiled)` returns `{ suspect, reason }`, where `suspect` is a boolean. It is `true` only when `method`, upper-cased, is `GET` or `HEAD`, `compiled.suspect` is not `null`, and `compiled.suspect` matches the tested string: the URL's `pathname` followed by its `search` when `new URL(url)` parses it, else `url` as given (a relative or unparsable URL). It never depends on the policy: a suspect request is recorded, never blocked, by the callers described under the installer.
|
|
338
422
|
- `reason` is a short human-readable explanation; its wording is not part of the API.
|
|
339
423
|
|
|
340
|
-
`compileConfig` and `
|
|
424
|
+
`compileConfig`, `decideControl`, `decideSuspect` and `decideRequest` reference nothing outside their own arguments, because the installer injects their source text into every page (`decideRequest` for the leaving-page sends of installer step 8). `decideFrame` runs only in Node, in the process that called `install`.
|
|
341
425
|
|
|
342
426
|
### The installer: `playwright-write-guard/install`
|
|
343
427
|
|
|
@@ -347,46 +431,91 @@ decideControl({ label: 'Delete item', icons: [] }, config);
|
|
|
347
431
|
2. Wraps `context.route` and the `route` of every page in the context, present and future. A handler registered through them receives every request, blocked writes included, and gets a route on which:
|
|
348
432
|
- `continue(options)` and `fetch(options)` are egress points, judged as in step 3;
|
|
349
433
|
- `fallback(options)` is not judged: it passes the request to the next handler with its overrides applied, so the next handler's `route.request()` reflects them, and they are judged at the egress where the request finally leaves;
|
|
350
|
-
- `fulfill` and `abort` are
|
|
434
|
+
- `fulfill(options)` and `abort(errorCode)` are mock points. Before calling Playwright's own `fulfill` or `abort` with the same arguments, the guard judges the request as that handler received it, with `decideRequest`: the method (upper-cased), URL, body and headers of `route.request()`, which reflects earlier `fallback` overrides. The body is `route.request().postData()`, judged as none when it cannot be decoded; the headers are `route.request().headers()`, names lower-cased. The arguments of `fulfill` and `abort` play no part. When the action is not `allow` (`block` or `observe`, under either policy), the guard records a `request` event with `action: "mocked"` and the usual request fields; either way the call then proceeds unchanged, so a test can still mock a write by fulfilling it. One exception: a `fulfill` or `abort` of a request that already reached egress through `fetch()`, by any handler, records nothing, because that request was judged at that egress. A request a caller fulfills or aborts is never recorded as `suspect`. Mocked events count in `mocked`, never in `blocked`, `observed` or `routed_requests`.
|
|
351
435
|
|
|
352
|
-
The wrapped `unroute` and `unrouteAll` remove only the caller's own handlers, never the guard's. This applies in the fixture, the `initPage` entry and a direct `install`; in launcher mode it covers only the launcher's own process (see below).
|
|
436
|
+
The wrapped `unroute` and `unrouteAll` remove only the caller's own handlers, never the guard's. This applies in the fixture, the `initPage` entry and a direct `install`; in launcher mode it covers only the launcher's own process (see below), so a launcher client's own route that fulfills or aborts a write runs in the client's process and records nothing.
|
|
353
437
|
3. Judges each request at **egress**, the point where it would leave the browser. There are three egress points: (a) the guard's catch-all, reached when every handler before it fell back; (b) a caller's `route.continue(options)`; (c) a caller's `route.fetch(options)`. Each egress judges the effective request with `decideRequest`:
|
|
354
|
-
-
|
|
355
|
-
-
|
|
438
|
+
- At (a) and (b), for each of method, URL and body, the effective value is the one in `options` when given, else the request's as it reached that point (`route.request()`, which reflects earlier `fallback` overrides). A `postData` Buffer is read as UTF-8 and any other non-string `postData` as its JSON encoding; a body that cannot be decoded is judged as none. The effective method is upper-cased.
|
|
439
|
+
- At (a) and (b), headers are a whole set, never merged: the effective headers are `options.headers` when given (Playwright replaces the full set), else `route.request().headers()`, with names lower-cased.
|
|
440
|
+
- At (c), the guard judges what Playwright 1.64's `route.fetch(options)` sends: it passes the merged options `m = { request: route.request(), data: options.postData, ...options }` to the context's request client, so the effective request (method, URL, headers and body) is that of an `APIRequestContext` send with those merged options `m`, by the same rule as under Request clients below, with the context client's `baseURL` (the `baseURL` option the context was created with). `route.request()` reflects earlier `fallback` overrides; because `...options` comes last, `options.url`, `options.method`, `options.headers`, `options.data` and `options.request` override it.
|
|
441
|
+
- At every egress, the event's `contentType` is the effective `content-type` header (at (c), the judged one, defined under Request clients: the last entry whose name lower-cases to `content-type`), and its `headerNames` are the effective header names.
|
|
356
442
|
- On `block`: (a) and (b) fulfill a request whose resource type is `document` with status 403, content type `text/html; charset=utf-8`, and a short HTML page reading `write-guard blocked <METHOD> <URL>` (the effective method and URL, HTML-escaped), so a blocked navigation shows why instead of a browser error page, and abort any other request with `'blockedbyclient'`, which the page sees as a network error; (c) rejects with `write-guard: route.fetch of <METHOD> <URL> is refused under read-only`, with the effective method and URL.
|
|
357
|
-
- On `allow
|
|
358
|
-
-
|
|
443
|
+
- On `allow`, when the effective method is GET or HEAD and `decideSuspect({ method, url }, compiled)` returns `suspect: true` for the effective method and URL, the guard records a `request` event with `action: "suspect"` and the reason `decideSuspect` returned. A suspect request is never blocked.
|
|
444
|
+
- On `allow` or `observe` (`observe` records the event first, and so does a suspect `allow`): (a) proceeds with `route.fallback()`, never `continue()`, so handlers registered behind it (a launcher client's `context.route`) still run; (b) and (c) call Playwright's own `continue` or `fetch` with the same options.
|
|
445
|
+
- Each egress attempt records at most one event (none when the request is allowed and not suspect). `routed_requests` counts each request once, at its first egress; a request a caller fulfills or aborts without reaching an egress is not counted.
|
|
359
446
|
4. Registers one `context.routeWebSocket`, matching every URL, under both `websockets` settings and either policy:
|
|
360
|
-
- Under `"allow"`, each socket is recorded as `{ kind: "websocket", action: "open", url, at }` and connected with `ws.connectToServer()`.
|
|
447
|
+
- Under `"allow"`, each socket is recorded as `{ kind: "websocket", action: "open", url, at }` and connected with `ws.connectToServer()`. When `websocketWrites` is empty, that is all the route does (as in 0.2.x), and no message is judged or logged.
|
|
448
|
+
- Under `"allow"` with a non-empty `websocketWrites`, the route keeps the server side that `ws.connectToServer()` returns and judges each message the page sends with `decideFrame(message, compiled)`, in order: on `"block"` the message is dropped (never forwarded to the server) and recorded as `{ kind: "websocket", action: "block", reason, url, at }`; on `"observe"` it is recorded the same way with `action: "observe"` and then forwarded; on `"allow"` it is forwarded with no event. A binary message is always forwarded. With `logBodies: true`, a frame event also carries `frame`, the first 500 characters of the message. Messages the server sends to the page are never judged and always arrive. Dropping a message does not close the socket.
|
|
361
449
|
- Under `"block"`, each socket is closed with code 1008 without connecting to the server, and recorded as a `request` event with `action: "block"` and the socket's `url` (no `method`).
|
|
362
450
|
|
|
363
|
-
No socket counts in `routed_requests
|
|
364
|
-
5. Adds an init script that runs before any page script, with capture-phase `click`, `submit` and `keydown` (Enter or Space) listeners on `window` that find the control as described under Writes and controls and judge it with `decideControl`. Under `read-only` a destructive control's event is stopped (`preventDefault` and `stopImmediatePropagation`), so the app never sees it, and the page calls its original `console.warn`, which the script captures before any page script runs, with exactly `write-guard refused "<label>": <reason>`, where `<label>` is the control's label cut to 120 characters and `<reason>` is the reason `decideControl` returned. Under `observe` the event goes through and nothing is written to the console. Either way the page reports the event through the binding `__writeGuardReport`, which the script captures before any page script can replace it.
|
|
451
|
+
No socket or frame counts in `routed_requests`. An `open` event counts in neither `blocked` nor `observed`; a frame event counts in `blocked` or `observed` by its `action`. Because the guard always routes sockets: under both settings Playwright replaces the page's `WebSocket` with its routed implementation; in launcher mode an attached client cannot register its own `routeWebSocket` (Playwright throws `Another client is already routing WebSockets`); in the guard's process a caller's own `routeWebSocket` registered later takes precedence, and then no open is recorded, no frame is judged, and `"block"` does not refuse that socket; sockets opened by dedicated or shared workers are not routed, so they are neither recorded nor blocked, and their frames are not judged (under `read-only` no shared worker starts, see step 8).
|
|
452
|
+
5. Adds an init script that runs before any page script, with capture-phase `click`, `submit` and `keydown` (Enter or Space) listeners on `window` that find the control as described under Writes and controls and judge it: with `decideSuspect` when it is a link that navigates, else with `decideControl`. The script carries the source of `compileConfig`, `decideControl`, `decideSuspect` and `decideRequest` (used by step 8), compiled from the same raw config. Under `read-only` a destructive control's event (a suspect link's included) is stopped (`preventDefault` and `stopImmediatePropagation`), so the app never sees it and a suspect link does not navigate, and the page calls its original `console.warn`, which the script captures before any page script runs, with exactly `write-guard refused "<label>": <reason>`, where `<label>` is the control's label cut to 120 characters and `<reason>` is the reason `decideControl` (or, for a link, `decideSuspect`) returned. Under `observe` the event goes through and nothing is written to the console. Either way the page reports the event through the binding `__writeGuardReport`, which the script captures before any page script can replace it.
|
|
365
453
|
6. Under `read-only`, dismisses every dialog (`alert`, `confirm`, `prompt`, `beforeunload`) on every page, so a native confirmation before a delete is declined.
|
|
366
|
-
7. Under `
|
|
454
|
+
7. Under both policies, judges every send through the context's request client. It wraps `context.request` once, as `guardRequestClient(context.request, { baseURL })` does (below), with `baseURL` the `baseURL` option the context was created with, and makes `context.request` and the `request` of every page in the context, present and future, return that one wrapper. A page's `request` is the same object as its context's (Playwright 1.64), so each send is judged once and records at most one event. Every property that is not a send method stays readable, with methods bound to the raw client. This wrapper exists only in the process that called `install`.
|
|
455
|
+
8. Under `read-only` only, the init script of step 5 also guards, in the page, three kinds of send that Playwright does not route. It does so in every frame the script runs in: every frame of every page, cross-origin frames and `about:blank` frames a script creates included. Under `observe` it replaces or removes none of the globals below, so `fetch`, `sendBeacon`, `XMLHttpRequest`, `SharedWorker` and `fetchLater` stay the browser's own, and a send from a leaving page, HTTP from a shared worker and a `fetchLater` request are neither judged nor recorded.
|
|
456
|
+
- **Sends from a leaving page.** A request a document starts while it is being left (from a `visibilitychange`, `pagehide` or `unload` handler when the page navigates with `page.goto`, follows a link or closes) is not routed by Playwright 1.64 and reaches the server unjudged, whether it is a `navigator.sendBeacon`, a `fetch` with `keepalive: true`, or an ordinary `fetch` or `XMLHttpRequest`. A frame is **leaving** when its own document, or the document of any same-origin ancestor, has `visibilityState` `hidden` or has fired `pagehide` with no `pageshow` since. The script walks `window.parent` up to the top: an ancestor whose document it cannot read (a cross-origin one) is skipped, and the walk goes on past it, so a same-origin frame behind a cross-origin frame counts its same-origin ancestors above that frame. The script reads `visibilityState` with the `Document.prototype` getter it captures before any page script runs, tracks `pagehide` and `pageshow` with capture-phase listeners on `window` added before any page script, and exposes that flag to same-origin descendant frames as a non-configurable getter on its `window` keyed by `Symbol.for('playwright-write-guard.pagehide')` (when a parent's handler calls into a child frame, the child has seen neither event yet, so ancestors count).
|
|
457
|
+
- **Judged in the page.** While a frame is leaving, each send below whose method, upper-cased, is not GET, HEAD or OPTIONS is judged in the page with `decideRequest({ method, url, body, headers }, cfg)`, where `cfg` is the config the page script already compiles for controls and suspect links: the script embeds the raw config as JSON and calls `compileConfig` on it in the page, so no `RegExp` is serialised, and it now also carries the source text of `decideRequest` (which, like the others, references nothing outside its own arguments). So `allow` is honoured, and a non-read method that matches no `allow` signature is `block`. The judged request:
|
|
458
|
+
- `method`: the method, upper-cased (`POST` for a beacon).
|
|
459
|
+
- `url`: the URL resolved against `document.baseURI` with its fragment removed (`u.hash = ''` before reading `href`), as a server receives it; for `fetch`, the built `Request`'s `url` treated the same way; the URL as given when it does not parse.
|
|
460
|
+
- `body`: a string when the page can read the data synchronously as one, else none (`null`). A string is used as is; a `URLSearchParams` as `String(data)`, which is what is sent; an `ArrayBuffer` or a view of one (`ArrayBuffer.isView`) decoded as UTF-8 with `new TextDecoder().decode(data)`. Each kind is tested with `typeof` or `instanceof` against the frame's own constructors captured before any page script runs. A `Blob`, a `FormData`, a stream, any other value, no data or `null`, and the body of a `Request` given to `fetch` (it can only be read asynchronously), are none, so a Sig with a `body` never matches them. For `fetch` the data is `init.body`, when `init` is given and its `body` is not `undefined`; for an XHR it is the argument of `send`; for a beacon it is `data`.
|
|
461
|
+
- `headers`: for `fetch`, every entry of the built `Request`'s `headers` (names in lower case, repeated names joined with `, `, and including the `content-type` the `Request` derives from its body, such as `text/plain;charset=UTF-8` for a string); for an XHR, the headers set with `setRequestHeader` since the last `open`, names lower-cased and repeated names joined with `, ` (the `content-type` the browser adds itself is not among them); for a beacon, none (`{}`).
|
|
462
|
+
- On `allow`, the send proceeds unchanged: the original `fetch` with the built `Request`, the original `send` with the same arguments, or the original `sendBeacon` with the same `this` and arguments. A beacon or a `keepalive` fetch allowed this way reaches the server; an ordinary `fetch` or XHR may still be cancelled by the browser with the document. On `block`, nothing is sent: `fetch` returns a promise rejected with a `TypeError`, an XHR `send` throws a `TypeError`, and `sendBeacon` returns `false` (what the browser returns for a beacon it cannot queue); each carries or writes the refusal below. Neither outcome is recorded.
|
|
463
|
+
- The script assigns these replacements over captured originals, so each property keeps its attributes:
|
|
464
|
+
- `window.fetch(input, init)`: when the frame is not leaving, it calls the original `fetch` with the same arguments, untouched. When it is leaving, it builds `new Request(input, init)` with the original `Request` (a throw is returned as a rejected promise, as `fetch` does). A request whose method, upper-cased, is GET, HEAD or OPTIONS is passed to the original `fetch` unjudged; any other is judged as above. `keepalive` plays no part, so a `Request` built with `keepalive: true` and then passed to `fetch` is treated the same way.
|
|
465
|
+
- `Navigator.prototype.sendBeacon(url, data)`: when the frame is not leaving, it calls the original with the same `this` and arguments, so the beacon is validated, sent and answered exactly as the browser does it. When it is leaving, it is judged as above before anything else (a beacon whose URL does not parse is judged with the URL as given, and is sent to the original only on `allow`, which then throws as the browser does).
|
|
466
|
+
- `XMLHttpRequest.prototype.open`, `setRequestHeader` and `send`: `open` calls the original and then remembers the method, upper-cased, and the resolved URL, and forgets earlier headers; `setRequestHeader` calls the original and then remembers the header; `send` is judged as above when the remembered method is not GET, HEAD or OPTIONS and the frame is leaving, and otherwise calls the original with the same arguments.
|
|
467
|
+
- The refusal is the message `write-guard: <METHOD> <URL> from a hidden or unloading page is refused under read-only`, with the judged method and URL. The page calls its captured `console.warn` with exactly that message. It is not reported through `__writeGuardReport`: a page that is being left cannot report reliably, so a judged send records no event and counts nowhere, whatever the decision.
|
|
468
|
+
- A leaving frame's reads (GET, HEAD, OPTIONS) go through unchanged and, like every other send from a page being left, are not judged.
|
|
469
|
+
- A page that is not leaving is untouched: its beacons, keepalive fetches and XHRs are routed and judged at egress like any other request (step 3), and so are the sends of a `beforeunload` handler, which runs while the page is still live. A beacon is a POST with credentials included, and its body and `content-type` are what `fetch` sends for the same data: a string (or any value that is none of the kinds below) as text with `text/plain;charset=UTF-8`; a `Blob` with its own type (no `content-type` when the type is empty); a `FormData` as `multipart/form-data; boundary=...`; a `URLSearchParams` with `application/x-www-form-urlencoded;charset=UTF-8`; an `ArrayBuffer` or a view of one with no `content-type`; no data, or `null`, as an empty body with no `content-type`.
|
|
470
|
+
- Headless Chromium keeps every page `visible`, so there only a page being left is leaving. In a headed browser a background tab is `hidden`, so its writes are judged in the page this way too.
|
|
471
|
+
- **SharedWorker and fetchLater.** HTTP from a shared worker is not routed, and neither is a request made with `fetchLater`, even from a page that is not leaving, so the script deletes `window.SharedWorker` and `window.fetchLater` (in Chromium each is an own, configurable property of the window), and `'SharedWorker' in window` and `'fetchLater' in window` are then `false`. An app that checks for them can fall back to a dedicated `Worker`, `fetch` or `sendBeacon`; one that calls them without checking gets the browser's own error for a missing global. Nothing is recorded or written to the console. Dedicated workers are unchanged: their requests are routed, except a request started at the instant its page or context closes (see What it cannot see).
|
|
472
|
+
|
|
473
|
+
**Request clients.** A request client is an `APIRequestContext` (wrapped by step 7, by `guardRequestClient`, and by the fixture's `request`) or Node's `fetch` (wrapped by `guardFetch`, and by the fixture while a test runs). Each send is judged with `decideRequest` on its effective request, under both policies:
|
|
474
|
+
|
|
475
|
+
- **An `APIRequestContext` send** is a call to `fetch`, `get`, `post`, `put`, `patch`, `delete` or `head` on the wrapper; every property that is not one of these send methods reads through to the client, with methods bound to the raw client. The guard builds the merged options `m` that Playwright 1.64 builds before it sends, judges what Playwright would send, and reads its effective request from `m`:
|
|
476
|
+
- Merged options: for `fetch(a, options)`, `m = { url: a when a is a string, request: a otherwise, ...options }`. For `get`, `post`, `put`, `patch`, `delete` and `head`: the same, followed by `method: NAME`, the method's name in upper case (`m = { url: a when a is a string, request: a otherwise, ...options, method: NAME }`). Because `...options` comes after the first argument, `options.url`, `options.request` and `options.headers` override what the first argument gave, and so does `options.method` for `fetch`; a `request` here is a Playwright `Request`, given as the first argument or as `options.request`.
|
|
477
|
+
- A string in these rules is a value Playwright's `isString` accepts, `typeof v === 'string' || v instanceof String`: the first argument `a` and `m.data` are tested that way, so a String object as `data` is judged as its string value. Two tests are Playwright's narrower ones instead: `m.params` is a string only when `typeof m.params === 'string'` (a String object appends its `Object.entries`, as any other object does), and the JSON re-encoding check under Body counts only a primitive string as parsing (Playwright's `isJsonParsable`), so under that check a String object is judged as its `JSON.stringify` encoding.
|
|
478
|
+
- Method: `m.method || m.request?.method() || 'GET'`, upper-cased.
|
|
479
|
+
- URL: `m.url !== undefined ? m.url : m.request.url()` (an `APIRequestContext` rejects URL objects). It is resolved against the client's `baseURL` when one is set (`new URL(url, baseURL)`, so an absolute URL stays as it is), else parsed alone (`new URL(url)`); when resolving against `baseURL` throws (an invalid `baseURL`), it is parsed alone. Then `m.params`, when given, is applied as Playwright 1.64 applies it: a string or `URLSearchParams` whose string form is non-empty replaces the query (`u.search = String(params)`); an empty one leaves the URL unchanged; any other non-null object appends (an array included) its `Object.entries`, skipping `undefined` (`u.searchParams.append(name, String(value))`, in `Object.entries` order); any other value leaves the URL unchanged (a number, or `null`). The judged URL is the resulting URL with its fragment removed (`u.hash = ''` before reading `href`), the URL a server receives, and the event's `url` has that form. Only when parsing alone throws too (a relative URL with no `baseURL` or with an invalid one, or an unparsable URL) is the URL judged as the raw string, with no params applied.
|
|
480
|
+
- Headers: `m.headers || m.request?.headers()`, else none. Its entries are the names a `for...in` loop enumerates on that headers object (own and inherited enumerable keys, as Playwright reads them), in that order; entries whose value is `undefined` are skipped (Playwright does not send them), and names are lower-cased. When two names are equal after lower-casing, the last one is judged and recorded, because Playwright sends the last; this holds for `content-type` too, so the effective `content-type` header is the last entry whose name lower-cases to `content-type`. Only the JSON re-encoding check of `m.data` (under Body) uses the first entry whose name lower-cases to `content-type`, as Playwright reads it there. Headers the client adds itself (its `extraHTTPHeaders`, a `content-type` it sets for `data`) are not part of the effective request.
|
|
481
|
+
- Body: `m.data` when it is not `undefined`: a string as is, except that when the first entry whose name lower-cases to `content-type` has the value exactly `application/json` (no parameters) and the string does not parse as JSON (`JSON.parse` throws), it is judged as its `JSON.stringify` encoding, which is what Playwright sends; a Buffer as UTF-8; any other value as its `JSON.stringify` encoding. Else, when neither `m.form` nor `m.multipart` is given (Playwright tests each for a truthy value), `m.request?.postDataBuffer()` read as UTF-8, so the body of a given `Request` is judged; `null` when that returns `null` or there is no `m.request`. Else `null`: a `form` or `multipart` body is not judged by body.
|
|
482
|
+
- **A Node `fetch(input, init)` call.** Its effective request:
|
|
483
|
+
- Method: `init.method`, else the `method` property of `input` when it is a Request, else `GET`, upper-cased. A given input is a Request only when `input instanceof globalThis.Request`.
|
|
484
|
+
- URL: `input` when it is a string, its `url` property when it is a Request (`input instanceof globalThis.Request`), and `String(input)` for any other input (for an ordinary `URL` that equals its `href`); when that parses as an absolute URL, the judged URL is its `href` with its fragment removed (`u.hash = ''` before reading `href`), the URL a server receives, and the event's `url` has that form; else the raw string.
|
|
485
|
+
- Body: `init.body` when it is a string, else `null` (a stream, `FormData`, `URLSearchParams`, Blob or buffer body, and the body of a given `Request`, are not judged by body).
|
|
486
|
+
- Headers: `init.headers`, else the `headers` of `input` when it is a Request (`input instanceof globalThis.Request`), else none; each (a plain object, an array of name and value pairs, or a `Headers`) is normalised through `new Headers(h)`, so repeated names are joined with `, ` as fetch sends them, and then passed with lower-cased names.
|
|
487
|
+
- **The decision.** On `block`, the send rejects without sending, with `write-guard: <METHOD> <URL> through <via> is refused under read-only`, where `<METHOD>` and `<URL>` are the effective method and URL and `<via>` is `APIRequestContext` or `fetch` (for example `write-guard: POST https://app.example.test/items through APIRequestContext is refused under read-only`). On `observe`, it records the event and sends. On `allow`, it sends, and first records a `suspect` event when `decideSuspect` says the effective method and URL are suspect. A send is passed on with its original arguments, untouched.
|
|
488
|
+
- **The event** is a `request` event with the effective `method`, `url`, `contentType` (for an `APIRequestContext` send, the judged `content-type` header, the last of equal names), `headerNames` and (with `logBodies: true`) `body`, plus `via`: `"request-client"` for an `APIRequestContext`, `"node-fetch"` for Node's `fetch`. These events count in `blocked`, `observed` or `suspect` by their `action`, never in `routed_requests`.
|
|
367
489
|
|
|
368
490
|
If the binding `__writeGuardReport` is already registered on the context, another process guards it (as when an agent attached to the launcher also loads the `initPage` entry), and `install` throws `write-guard: this context is already guarded by another process; use one mode, not both`. That check happens before anything is installed, so a refused `install` leaves nothing behind.
|
|
369
491
|
|
|
370
492
|
`install` cannot change how the context was created: create it with `serviceWorkers: 'block'`, or a service worker can send requests the guard never sees. The fixture, the launcher and the `initPage` config below all do.
|
|
371
493
|
|
|
372
|
-
**Events.** Every blocked or observed write is one event, and so is every WebSocket opened under `websockets: "allow"`:
|
|
494
|
+
**Events.** Every blocked or observed write is one event, and so is every write a caller's route mocks, every suspect GET or HEAD, every blocked or observed WebSocket frame, and every WebSocket opened under `websockets: "allow"`:
|
|
373
495
|
|
|
374
496
|
```ts
|
|
375
|
-
{ kind: 'request', action: 'block' | 'observe', reason: string,
|
|
376
|
-
method?: string, url: string,
|
|
497
|
+
{ kind: 'request', action: 'block' | 'observe' | 'mocked' | 'suspect', reason: string,
|
|
498
|
+
method?: string, url: string, via?: 'request-client' | 'node-fetch',
|
|
499
|
+
contentType?: string, headerNames?: string[], body?: string, at: string }
|
|
377
500
|
{ kind: 'control', action: 'block' | 'observe', reason: string, label: string, at: string }
|
|
378
501
|
{ kind: 'websocket', action: 'open', url: string, at: string }
|
|
502
|
+
{ kind: 'websocket', action: 'block' | 'observe', reason: string, url: string, frame?: string, at: string }
|
|
379
503
|
```
|
|
380
504
|
|
|
505
|
+
- A request event's `action` is `"block"` or `"observe"` for a write judged at an egress point or a request-client send (the `decideRequest` action), `"mocked"` for a write a caller's route fulfilled or aborted (installer step 2), and `"suspect"` for an allowed GET or HEAD that `decideSuspect` marks suspect (step 3 and the request clients).
|
|
381
506
|
- A request event carries the effective `method` (upper case) and `url`; a socket refused under `websockets: "block"` carries its `url` and no `method`. It carries `contentType` when the effective request has a `content-type` header, and, only with `logBodies: true`, `body`: the first 500 characters of the effective request body, when the request has one.
|
|
382
|
-
-
|
|
383
|
-
- A
|
|
384
|
-
- `
|
|
507
|
+
- Every request event that has a `method` carries `headerNames`: the effective request's header names, lower-cased, without duplicates, sorted with `Array.prototype.sort` (code unit order); `[]` when it has none. Header values are never recorded. A socket refused under `websockets: "block"` has no `headerNames`.
|
|
508
|
+
- A request event carries `via` only when it comes from a request client: `"request-client"` for an `APIRequestContext` send, `"node-fetch"` for a Node `fetch` call. Events of requests the browser sent have no `via`.
|
|
509
|
+
- A control event carries `label` (trimmed, at most 120 characters) and none of the request fields. A suspect link (see Writes and controls) is a control event.
|
|
510
|
+
- A websocket `open` event carries `url` and no `reason`. A websocket frame event (`"block"` or `"observe"`) carries `reason` and the socket's `url`, and, only with `logBodies: true`, `frame`: the first 500 characters of the frame's text.
|
|
511
|
+
- `at` is an ISO 8601 timestamp. Allowed requests that are not suspect, allowed frames and allowed controls produce no event, and neither do the page-side refusals of installer step 8.
|
|
385
512
|
|
|
386
513
|
**The handle.**
|
|
387
514
|
|
|
388
|
-
- `record()` returns `{ installed: true, policy, routed_requests, blocked, observed, events }`: `routed_requests` counts the requests the guard judged at egress, each once, at its first egress, and no WebSockets; `blocked` and `observed` count request and
|
|
515
|
+
- `record()` returns `{ installed: true, policy, routed_requests, blocked, observed, mocked, suspect, events }`: `routed_requests` counts the requests the guard judged at browser egress, each once, at its first egress, and no WebSockets, mocks or request-client sends; `blocked` and `observed` count request, control and websocket events by their `action` (`"block"` or `"observe"`), and websocket `open` events count in neither; `mocked` counts request events with `action: "mocked"`, and `suspect` request events with `action: "suspect"`. Each event counts in at most one of `blocked`, `observed`, `mocked` and `suspect`.
|
|
389
516
|
- `close()` resolves after the last pending log append and returns the final `record()`. The guard stays active until the context closes.
|
|
517
|
+
- `guardRequestClient(client, { baseURL })` returns a wrapper over the `APIRequestContext` `client` whose sends are judged as described under Request clients, with this guard's config, and recorded in this guard's record and log with `via: "request-client"`. `baseURL` is optional; without it, a relative URL is judged as the raw string. Every property that is not a send method reads through to `client`, with methods bound to the raw client. The fixture judges and records the `request` fixture's sends this way in a test and its `beforeEach` and `afterEach` hooks.
|
|
518
|
+
- `guardFetch(fetchFn)` returns a function with `fetchFn`'s signature that judges each call as described under Request clients, with this guard's config, records it in this guard's record and log with `via: "node-fetch"`, and then either rejects or returns `fetchFn(input, init)` called with the same arguments. The fixture uses it for Node's global `fetch` while a test runs.
|
|
390
519
|
|
|
391
520
|
**The audit log.** When `config.log` is a path, each event is appended to that file as one JSON line, as it happens. The path is relative to the process's working directory; the file is created when missing and never truncated.
|
|
392
521
|
|
|
@@ -406,10 +535,17 @@ test('lists items without changing them', async ({ page }) => {
|
|
|
406
535
|
```
|
|
407
536
|
|
|
408
537
|
- `test` and `expect` are Playwright Test's, extended with one option fixture, `writeGuardConfig`, settable with `test.use`. It defaults to `loadConfig()`, which resolves relative to the working directory: running Playwright with `-c` from another directory needs `WRITE_GUARD_CONFIG` to find the config.
|
|
409
|
-
- The `context` fixture is created with `serviceWorkers: 'block'` and guarded with `install`, with `log` forced to `null` (the fixture never writes a log file).
|
|
410
|
-
- The `request` fixture
|
|
411
|
-
-
|
|
412
|
-
-
|
|
538
|
+
- The `context` fixture is created with `serviceWorkers: 'block'` and guarded with `install`, with `log` forced to `null` (the fixture never writes a log file). That also judges every send through `context.request` and `page.request` (installer step 7). A test whose effective `serviceWorkers` option is not `'block'` fails at fixture setup with an error starting `write-guard: serviceWorkers is`, because a service worker could send requests the guard never sees. This is the test's guard: its record is `write-guard.json`.
|
|
539
|
+
- The `request` fixture works under both policies, and does not depend on the `context` fixture (Playwright 1.64 refuses `context` in a `beforeAll` or `afterAll` hook). Playwright creates it with `playwright.request.newContext()` (the one call of that method the fixture lets through). Each send through it is judged as described under Request clients, on its effective request with `baseURL` the `baseURL` fixture, so a GET goes through and, under `read-only`, a write rejects without sending. Where the send is recorded depends on where it is made:
|
|
540
|
+
- In a test and its `beforeEach` and `afterEach` hooks, each send is judged and recorded by the test's guard (the `context` fixture's), looked up when the send is made, as that guard's `guardRequestClient(request, { baseURL })` judges and records it; its events (`via: "request-client"`) are in `write-guard.json`.
|
|
541
|
+
- In a `beforeAll` or `afterAll` hook there is no test guard, and each send is judged but not recorded: it is judged with `decideRequest` on the same effective request, with the config from `writeGuardConfig`, with the same block message and the same `allow` and `observe` behaviour, and no event is recorded and no log line is written.
|
|
542
|
+
- While a guarded test's own function runs (not a `beforeAll`, `afterAll`, `beforeEach` or `afterEach` hook), Node's global `fetch` is replaced by the test guard's `guardFetch(fetch)`, so each call is judged as described under Request clients and its events (`via: "node-fetch"`) are in `write-guard.json`.
|
|
543
|
+
- While a guarded test's own function runs, `browser.newContext(options)` returns a guarded context: it creates the context with `serviceWorkers` set to `'block'` (when `options.serviceWorkers` is given with any other value, it throws the same `write-guard: serviceWorkers is ...` error as above, before creating anything), then guards it with `install`, using the test's config with `log` forced to `null`, before returning it. At the end of the test, each such context's `record()` is attached as `write-guard-<n>.json` (`application/json`), numbered from 2 in the order the contexts were created. `browser.newPage(options)` goes through that same `newContext` (Playwright 1.64 calls it internally), so its page's context is guarded and numbered the same way; `newPage` attaches nothing of its own. Playwright's own creation of the test's context is outside this wrapper, and is not numbered.
|
|
544
|
+
- Under both policies, in a guarded worker, these throw an error starting `write-guard:` because they would make a browser context, request context or connection the guard cannot see:
|
|
545
|
+
- outside a guarded test's own function (in any hook, `beforeAll`, `afterAll`, `beforeEach` or `afterEach`, and in fixture setup and teardown), `browser.newContext`, `browser.newPage` and Node's global `fetch`, as in 0.2.x, except Playwright's own creation of the test's context;
|
|
546
|
+
- everywhere, `playwright.request.newContext`, except Playwright's own creation of the `request` fixture;
|
|
547
|
+
- everywhere, the browser types' `launch`, `launchPersistentContext`, `launchServer`, `connect` and `connectOverCDP`.
|
|
548
|
+
- Each test attaches its guard's `record()` as `write-guard.json` (`application/json`), and each context it created with `browser.newContext` or `browser.newPage` as `write-guard-<n>.json`.
|
|
413
549
|
|
|
414
550
|
### The `initPage` entry: `playwright-write-guard/init-page`
|
|
415
551
|
|
|
@@ -449,7 +585,7 @@ Without those entries the agent can remove the guard.
|
|
|
449
585
|
|
|
450
586
|
`startGuardedBrowser({ config, headless = true, host = '127.0.0.1', port = 0, title = 'write-guard' })` returns `Promise<{ wsEndpoint, browser, context, guard, close() }>`. It launches Chromium with `playwright-core`, creates one context with `serviceWorkers: 'block'`, guards it with `install(context, config)` (`config` defaults to `loadConfig()`), and calls `browser.bind(title, { host, port })`; `wsEndpoint` is the `endpoint` that returns, `ws://<host>:<port>/<id>` (`port: 0` lets the OS pick a free port). `guard` is the install handle. `close()` waits for `guard.close()`, then closes the context and the browser.
|
|
451
587
|
|
|
452
|
-
A client attaches with `chromium.connect(wsEndpoint)` and sees the guarded context as `contexts()[0]`. The guard runs in the launcher's process, so the client's own `unroute` and `unrouteAll()` cannot remove it, and a `page.route` handler that calls `continue()`, with or without overrides, is still judged (page handlers run before the owner's context route, and the request reaches the guard's catch-all with the overrides applied). New tabs the client opens in that context are guarded. A client's `page.route` handler can `fulfill` or `abort` a write, so it can mock one
|
|
588
|
+
A client attaches with `chromium.connect(wsEndpoint)` and sees the guarded context as `contexts()[0]`. The guard runs in the launcher's process, so the client's own `unroute` and `unrouteAll()` cannot remove it, and a `page.route` handler that calls `continue()`, with or without overrides, is still judged (page handlers run before the owner's context route, and the request reaches the guard's catch-all with the overrides applied). New tabs the client opens in that context are guarded. A client's `page.route` handler can `fulfill` or `abort` a write, so it can mock one; that handler runs in the client's process, so the mock is not recorded as `mocked`. A client's `context.route` handler runs after the guard's catch-all, in the client's process: it receives only requests the catch-all already judged and let through, so it cannot mock a blocked write, its `continue()` or `fallback()` overrides are not judged again, and its `route.fetch()` is not seen; see "What it cannot see" below. The client cannot register its own `routeWebSocket`, because the guard already routes every WebSocket (Playwright throws `Another client is already routing WebSockets`).
|
|
453
589
|
|
|
454
590
|
The command does the same from a shell:
|
|
455
591
|
|
|
@@ -459,38 +595,82 @@ npx playwright-write-guard-browser [--config <path>] [--headed] [--host <h>] [--
|
|
|
459
595
|
|
|
460
596
|
`--config` is passed to `loadConfig` (so without it, `WRITE_GUARD_CONFIG` and `write-guard.config.json` apply), `--headed` sets `headless` to false, and `--host` and `--port` default to `127.0.0.1` and `0`. It prints `endpoint: <ws endpoint>` on a line of its own, then how to attach: Playwright MCP with `--endpoint <ws endpoint>` and without `--isolated` (which uses `contexts()[0]`), or the Playwright CLI with `playwright-cli attach --endpoint=<ws endpoint>`. It stays up until SIGINT or SIGTERM, then closes the browser and exits 0. An unknown flag or a flag missing its value prints the usage line and exits 1.
|
|
461
597
|
|
|
598
|
+
### The suggest command: `playwright-write-guard-suggest`
|
|
599
|
+
|
|
600
|
+
```
|
|
601
|
+
npx playwright-write-guard-suggest <log.jsonl>
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
It reads an audit log and drafts `allow` entries for the writes it lists. It reads only that file and never writes one.
|
|
605
|
+
|
|
606
|
+
- **Input.** It reads the file as UTF-8 and splits it into lines. A line that does not parse with `JSON.parse` (an empty line included) is skipped, and so is a parsed line that is not an object with a non-empty string `method` (control events, websocket events, refused sockets). Of the rest it keeps `request` events whose `action` is `"observe"`, `"block"` or `"mocked"`; `suspect` events are skipped. An event whose `url` does not parse as an absolute URL (`new URL(url)` throws) is skipped too.
|
|
607
|
+
- **Grouping.** Kept events are grouped by `method` and by origin plus path: `u.origin + u.pathname` for `u = new URL(url)`, so the query and the hash are dropped.
|
|
608
|
+
- **Output.** It prints to stdout one JSON document, `JSON.stringify({ allow }, null, 2)` followed by a newline. `allow` holds one entry per group, `{ "method": M, "url": "^<origin and path, escaped>(?:[?#]|$)" }`, where escaped means each of the characters `\ ^ $ . * + ? ( ) [ ] { } |` is preceded by a backslash. Entries are sorted by `url`, then by `method`, both in code unit order. With nothing kept, `allow` is `[]`.
|
|
609
|
+
- **Hints.** It prints to stderr one line per entry, in the same order: `<count> x <METHOD> <origin and path>`, where `<count>` is the number of events in the group and the origin and path are not escaped. When any event in the group has `next-action` in its `headerNames`, the line goes on with `: carries next-action, pin its action IDs with a headers entry`; otherwise, when the path ends with `/graphql`, with `: GraphQL, add an anchored body pattern`. It then exits 0.
|
|
610
|
+
- **Errors.** With no argument, more than one argument, or a file that is missing or cannot be read, it prints `usage: playwright-write-guard-suggest <log.jsonl>` to stderr, nothing to stdout, and exits 1.
|
|
611
|
+
|
|
612
|
+
For a log holding three blocked `POST https://app.example.test/graphql?op=1` events, stdout is:
|
|
613
|
+
|
|
614
|
+
```json
|
|
615
|
+
{
|
|
616
|
+
"allow": [
|
|
617
|
+
{
|
|
618
|
+
"method": "POST",
|
|
619
|
+
"url": "^https://app\\.example\\.test/graphql(?:[?#]|$)"
|
|
620
|
+
}
|
|
621
|
+
]
|
|
622
|
+
}
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
and stderr is `3 x POST https://app.example.test/graphql: GraphQL, add an anchored body pattern`. Each entry allows every request with its method to its path, whatever the body or headers, so the output is a draft to review: narrow each entry with a `body` or `headers` pattern, and drop any that is a real write, before putting it in `allow`.
|
|
626
|
+
|
|
462
627
|
### What it cannot see
|
|
463
628
|
|
|
464
629
|
- `APIRequestContext` objects the guard cannot reach: an attached client's own `page.request` and `context.request` in launcher mode, and `playwright.request.newContext()` called from an agent's run-code tool.
|
|
465
630
|
- An agent's run-code tool, and any browser the agent launches itself.
|
|
466
631
|
- A Playwright Test run with `-c` from another directory without `WRITE_GUARD_CONFIG`: the fixture finds no config file and falls back to the defaults.
|
|
467
|
-
- WebSocket messages: under the default `websockets: "allow"`, each socket a page opens is recorded as a `websocket` event,
|
|
632
|
+
- WebSocket messages, except the text frames `websocketWrites` names: under the default `websockets: "allow"`, each socket a page opens is recorded as a `websocket` event, and its messages (writes included) are not judged or logged unless `websocketWrites` is set, and then only outbound text frames that match one of its patterns. Binary frames and messages the server sends to the page are never judged. `"block"` refuses every socket the guard routes.
|
|
468
633
|
- WebSockets opened by dedicated or shared workers: they are not routed, so they are neither recorded nor blocked, under either setting.
|
|
469
634
|
- A caller's own `routeWebSocket` registered after the guard in the same process takes precedence: no open is recorded, and `websockets: "block"` does not refuse that socket. In launcher mode an attached client cannot register its own `routeWebSocket`: Playwright throws `Another client is already routing WebSockets`.
|
|
470
635
|
- The page's `WebSocket`: under both settings Playwright replaces it with its routed implementation, which is noticeably slower.
|
|
471
636
|
- WebRTC and WebTransport.
|
|
472
|
-
- Redirects after the first URL: only the request the page made is judged, not where the server redirects it.
|
|
473
|
-
- Drag-and-drop, and pointer or mouse handlers that act before `
|
|
637
|
+
- Redirects after the first URL: only the request the page, a request client or Node's `fetch` made is judged, not where the server redirects it.
|
|
638
|
+
- Drag-and-drop, and handlers that act without a `click`: pointer or mouse handlers that act before it, `dblclick` and `auxclick` handlers, and key handlers other than the Enter or Space keydown the guard judges (for example a `keyup` handler for Space on a `role=button`). The network layer still judges what they send.
|
|
474
639
|
- Client-side-only destructive actions that no click or submit reaches.
|
|
640
|
+
- Toggles: `role=switch` and `role=checkbox` elements and checkbox inputs, and `change` events, are not judged (a `<button role=switch>` is still a button and is judged by its label).
|
|
475
641
|
- Requests are classified only as well as the config: a write sent as GET, or a read sent as POST, needs a `writes` or `allow` signature.
|
|
476
|
-
- GET requests with side effects pass with no event unless a `writes` signature names them, and `observe` does not list GETs.
|
|
642
|
+
- GET requests with side effects: only a GET or HEAD whose path plus query matches the built-in suspect pattern or a `suspectGets` pattern is recorded, as `suspect`, and it is never blocked. Any other pass with no event unless a `writes` signature names them, and `observe` does not list ordinary GETs. The built-in pattern is short: it misses paths such as `/items/7:delete`, and it also marks harmless GETs such as the static asset `/static/icons/delete-icon.svg`.
|
|
477
643
|
- Reads that send no query text (persisted GraphQL queries without an operation name): when nothing in the method, URL, headers or body tells such a read from a write, a signature that allows the endpoint allows its mutations too.
|
|
478
|
-
- Request bodies unless `logBodies` is `true`, and
|
|
644
|
+
- Request bodies unless `logBodies` is `true`, and header values: events record the method, URL, content type and header names. `logBodies` is off by default because bodies can hold credentials and personal data.
|
|
645
|
+
- Bodies sent as `form` or `multipart` through an `APIRequestContext`, and Node `fetch` bodies that are not strings (streams, `FormData`, `URLSearchParams`, Blob, buffers) or that belong to a `Request` given to `fetch`: they are not judged by body (the method, URL and headers still are).
|
|
646
|
+
- A request client's own `extraHTTPHeaders`: they are not part of the request the guard judges (nor is a `content-type` the client sets itself for `data`), although browser egress sees a context's `extraHTTPHeaders`. A `headers` signature set up for a header the client adds on its own therefore does not match a request client's send.
|
|
647
|
+
- Values the guard reads once and Playwright or `fetch` reads again (an options getter, a `Request`'s `url()`, an object with its own `toString`): they can return something else the second time, so the request sent can differ from the one judged.
|
|
648
|
+
- A `fetch` function or `APIRequestContext` captured before the guard wrapped it: calls through the captured reference are not judged. Only `context.request`, `page.request`, the fixture's `request`, Node's `fetch` inside a test and what you pass to `guardRequestClient` or `guardFetch` are wrapped.
|
|
479
649
|
- In launcher mode, mocking a blocked write from a client's `context.route`: that handler runs after the guard's catch-all, so it never receives a blocked write. A client's `page.route` can mock one.
|
|
480
|
-
-
|
|
650
|
+
- Mocks outside the guard's process: a write that one of your own routes fulfills or aborts in the guard's process is recorded as `mocked`, but in launcher mode a client's routes run in the client's process, so their mocks are not recorded. A `fulfill` or `abort` of a request that already went through `route.fetch()` records nothing (the fetch was judged), and a GET that a route fulfills or aborts is never recorded as `suspect`. A mock is not counted in `blocked` or `observed`, so a broad stub such as `page.route('**/api/**', ...)` keeps its writes out of those counts; look at `mocked`.
|
|
651
|
+
- A `request` fixture send in a `beforeAll` or `afterAll` hook: it is judged but not recorded, because no test guard exists there, so a run's record and log do not list it.
|
|
481
652
|
- The launcher's browser and context options: it takes no storage state, proxy, `ignoreHTTPSErrors` or HTTP credentials.
|
|
482
653
|
- The HTTP cache: Playwright turns it off while a route is installed.
|
|
483
654
|
- Service workers: `register()` resolves but `navigator.serviceWorker.ready` never settles.
|
|
484
655
|
- Any context other than `contexts()[0]` in launcher mode: `--isolated`, or `browser.newContext()` from an agent's run-code tool, makes a new, unguarded context.
|
|
485
656
|
- A client's `context.route` overrides in launcher mode are not judged again: that handler runs after the guard, in the client's process, so a `continue()` or `fallback()` that rewrites the method, URL or body (a GET rewritten to DELETE) reaches the server.
|
|
486
657
|
- A client's `route.fetch()` from any route handler (`page.route` or `context.route`) in launcher mode is not seen: it is sent through the client's own `APIRequestContext`, which the guard cannot reach.
|
|
487
|
-
- Code in the same process that calls Playwright's prototype methods directly (for example `Object.getPrototypeOf(context).route` or `.unrouteAll`, `Object.getPrototypeOf(route).continue`,
|
|
658
|
+
- Code in the same process that calls Playwright's prototype methods directly (for example `Object.getPrototypeOf(context).route` or `.unrouteAll`, `Object.getPrototypeOf(route).continue`, `Object.getPrototypeOf(context.request).fetch`, or Playwright's private `_innerFetch`, which go around the guard's wrapper on `APIRequestContext`) or removes the guard's own `page` or `dialog` listeners (`context.removeAllListeners('page')` leaves new pages' routes unwrapped; `context.removeAllListeners('dialog')` lets a caller accept a dialog) goes around the guard's wrappers: the route it registers is not wrapped (its `continue()` can send a POST), `unrouteAll` removes the guard, and a `continue` with overrides skips the override judging.
|
|
488
659
|
- Routes and dialog listeners registered before `install`: a `page.route` whose `continue()` sends a write, a `context.route` whose overrides are not judged, and a page `dialog` listener that accepts all run outside the guard's wrappers.
|
|
489
|
-
-
|
|
660
|
+
- Navigating links are judged by their URL, not by their label or icons: an `a` with a real `href` whose text or icon class says Delete is not refused in the page unless its URL is suspect (the network layer still judges the request its navigation sends). Anchors whose `href` is empty, starts with `#` or is a `javascript:` URL, and elements with `role=link`, are still judged by label and icons.
|
|
490
661
|
- Controls the page does not judge: anchors without `href`, `<input type=reset>`, `summary`, plain elements that have only an `onclick` attribute or a `tabindex` (rows, cards), controls inside closed shadow roots, and plain text outside any control. The network layer still judges any request they send. Open shadow roots are judged through the event's composed path, using the shadow host's label when the control has none. An image input is never found by the submit fallback, so a `requestSubmit()` with no submitter on a form whose only submit control is an image input is not judged in the page (the network layer still judges the request).
|
|
491
662
|
- A document loaded with `page.setContent()`: the guard's in-page listeners do not carry over into it, so a destructive click there is not refused in the page. The network layer still judges any request it sends.
|
|
492
663
|
- Node's own `http`, `https` and `net` modules in the test process.
|
|
493
|
-
-
|
|
664
|
+
- Under `observe`, writes a page sends while it is being left (a `navigator.sendBeacon` or a `keepalive` fetch from a `pagehide` or `unload` handler, or an ordinary `fetch` or XHR then), the HTTP of a `SharedWorker`, and requests made with `fetchLater`, even from a page that stays open: Playwright does not route them, so they are neither judged nor recorded.
|
|
665
|
+
- A write that a caller's slow route is still holding when `page.close()` runs, and a write that a live page or its dedicated worker starts at the instant its page or context closes: Playwright no longer tracks the frame, so the request goes out unjudged, under `read-only` too (without the guard, a route sees no such write either). Apart from that instant, a dedicated worker's requests are routed.
|
|
666
|
+
- A cross-origin frame that sends when its parent tells it to (a `postMessage` from the parent's `pagehide` handler, say): it has not fired `pagehide` itself and cannot read its parent, so it is not leaving, and on `page.close()` its write goes out unjudged, under `read-only` too.
|
|
667
|
+
- Reads from a leaving frame: under `read-only` only its writes are judged in the page. A GET, HEAD or OPTIONS goes through unjudged, a suspect GET included.
|
|
668
|
+
- A leaving frame's write whose body the page cannot read (a `Blob` or a `FormData`, or the body of a `Request` given to `fetch`): it is judged with no body, so a `body` signature never matches it, and a beacon carries no headers in the page's view, so a `headers` signature (a content type, say) does not match one either. Such a write is refused unless an `allow` signature without a `body` or `headers` matches it.
|
|
669
|
+
- Everything a leaving frame sends under `read-only`: a refusal is a console warning and an allowed write goes out, but neither is recorded or counted, so the record and the log do not list them. After a `page.goto`, the warning may not reach `page.on('console')`.
|
|
670
|
+
- In a headed browser, a background tab under `read-only`: it is `hidden`, so it counts as leaving, and its writes are judged in the page as above instead of at the network layer (not recorded, and a `Blob` or `FormData` body fails a `body` signature).
|
|
671
|
+
- A link that navigates is refused in the page when its URL matches the suspect pattern, although following it may only read: a link to `/logout`, or to any URL with an `action=` query parameter, is refused, and so is a slugged URL that holds a built-in word, such as a help link to `/help/delete-a-repository`. Links judged by label (an empty, `#` or `javascript:` href, and `role=link`) are refused when their text holds a built-in verb ("Delete a repository" as a `#` link).
|
|
672
|
+
- Under `read-only`, `window.SharedWorker` and `window.fetchLater` are removed in every frame: an app that checks for them falls back to a dedicated `Worker`, `fetch` or `sendBeacon`, and an app that calls them without checking does not load that part.
|
|
673
|
+
- Under `read-only`, a dismissed `beforeunload` keeps the page: a test or an agent that leaves a page with unsaved changes stays on it, and a `page.goto` away from it throws `net::ERR_ABORTED` (a click on a link does not throw).
|
|
494
674
|
|
|
495
675
|
It is a safety net, not a security boundary. A restricted test account is the boundary.
|
|
496
676
|
|