playwright-write-guard 0.1.1 → 0.2.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 +156 -39
- package/package.json +1 -1
- package/src/core.mjs +15 -7
- package/src/install.mjs +80 -70
- package/types/core.d.ts +25 -4
- package/types/install.d.ts +34 -6
package/README.md
CHANGED
|
@@ -9,14 +9,17 @@ Pointing an automated browser at a real app is risky when the run should only lo
|
|
|
9
9
|
This package puts a read-only guard in front of the browser:
|
|
10
10
|
|
|
11
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 and so on) is stopped inside the page before the app's own handlers run. Native confirmation dialogs are declined.
|
|
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
13
|
- **Log.** Every write it blocked, or only observed under the `observe` policy, is recorded as one JSON line.
|
|
14
14
|
|
|
15
15
|
What it guarantees, and what it does not:
|
|
16
16
|
|
|
17
|
-
- **Enforced:** every 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.
|
|
18
|
-
- **Heuristic:** which requests count as writes
|
|
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).
|
|
18
|
+
- **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 content type; set `logBodies: true` to add the first 500 characters of each body, which can hold credentials, so it is off by default). GETs with side effects (logout, unsubscribe, `?action=delete`) pass with no event unless a `writes` signature names them.
|
|
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`.
|
|
19
21
|
- **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
|
+
- **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.
|
|
20
23
|
|
|
21
24
|
See it work end to end, with a local page and a local server, in the walkthrough at [demo/README.md](demo/README.md) (`node demo/run.mjs`).
|
|
22
25
|
|
|
@@ -31,7 +34,7 @@ It needs Node 20 or newer and Playwright 1.59 or newer: `@playwright/test` for t
|
|
|
31
34
|
|
|
32
35
|
## Use with Playwright Test
|
|
33
36
|
|
|
34
|
-
Swap one import
|
|
37
|
+
Swap one import:
|
|
35
38
|
|
|
36
39
|
```js
|
|
37
40
|
// before
|
|
@@ -44,18 +47,32 @@ test('lists items without changing them', async ({ page }) => {
|
|
|
44
47
|
});
|
|
45
48
|
```
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
Tests that only read through the page keep working. Under read-only, these change:
|
|
51
|
+
|
|
52
|
+
- The `request` fixture fails the test at setup, and `page.request` and `context.request` refuse every send, GET included.
|
|
53
|
+
- While a test runs, `browser.newContext()`, `browser.newPage()` and the global `fetch` in the test process throw, so tests with several users, or that post results from inside a test, need another approach.
|
|
54
|
+
- 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. 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
|
+
- 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.
|
|
57
|
+
|
|
58
|
+
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:
|
|
48
59
|
|
|
49
60
|
```json
|
|
50
61
|
{
|
|
51
62
|
"policy": "read-only",
|
|
52
63
|
"allow": [
|
|
53
|
-
{
|
|
64
|
+
{
|
|
65
|
+
"method": "POST",
|
|
66
|
+
"url": "^https://app[.]example[.]test/graphql$",
|
|
67
|
+
"body": "\"operationName\":\"(ListItems|GetItem)\""
|
|
68
|
+
}
|
|
54
69
|
],
|
|
55
70
|
"controls": { "icons": ["icon-trash"] }
|
|
56
71
|
}
|
|
57
72
|
```
|
|
58
73
|
|
|
74
|
+
Without the `body` pattern, that signature would allow every request to the URL, mutations included. The pattern trusts your app's operation names, so list only names that read. Reads that differ from writes only in a header, such as Next.js server actions, can be allow-listed with a `headers` entry, for example `{ "method": "POST", "url": "^https://app[.]example[.]test/items$", "headers": { "next-action": "^[0-9a-f]+$" } }`. Header names compare case-insensitively and each value is a regex tested against that header on the request.
|
|
75
|
+
|
|
59
76
|
To set the config in the test file instead, use `test.use`:
|
|
60
77
|
|
|
61
78
|
```js
|
|
@@ -123,20 +140,55 @@ It prints `endpoint: <ws endpoint>`. Attach the agent to it:
|
|
|
123
140
|
- Playwright MCP: start it with `--endpoint <ws endpoint>` and without `--isolated`. `--isolated` makes a new, unguarded context.
|
|
124
141
|
- Playwright CLI: `playwright-cli attach --endpoint=<ws endpoint>`.
|
|
125
142
|
|
|
126
|
-
The guard runs in the launcher's process, not the agent's. The agent's own `unroute` and `unrouteAll` cannot remove it, and a `page.route` handler that calls `continue()
|
|
143
|
+
The guard runs in the launcher's process, not the agent's. The agent's own `unroute` and `unrouteAll` cannot remove it, and a `page.route` handler that calls `continue()`, with or without overrides, is still judged, because page handlers run before the owner's context route and the request reaches the guard with the overrides applied. Playwright MCP's own route tool only sets the headers and status of mocked responses, so this mode survives the MCP route tools the config mode has to deny. It does not survive arbitrary client code: see "What it cannot see" below. Tabs the agent opens in that context are guarded too. Stop it with Ctrl+C.
|
|
144
|
+
|
|
145
|
+
## Logging in
|
|
146
|
+
|
|
147
|
+
Logging in is a write, so under read-only a login form is blocked: the page shows `write-guard blocked POST .../login`. Token refreshes that POST are blocked too. Ways in:
|
|
148
|
+
|
|
149
|
+
- **Playwright Test:** log in from a setup project that imports `@playwright/test`, not this package, save the session with `storageState`, and give it to the guarded project. The fixture wraps Playwright's own context, so `storageState` works as usual.
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
// playwright.config.mjs
|
|
153
|
+
import { defineConfig } from '@playwright/test';
|
|
154
|
+
export default defineConfig({
|
|
155
|
+
projects: [
|
|
156
|
+
{ name: 'login', testMatch: /login\.setup\.mjs/ },
|
|
157
|
+
{ name: 'read-only', dependencies: ['login'], use: { storageState: 'state.json' } },
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
// login.setup.mjs: imports @playwright/test, so it is not guarded
|
|
164
|
+
import { test } from '@playwright/test';
|
|
165
|
+
test('log in', async ({ page }) => {
|
|
166
|
+
await page.goto('https://app.example.test/login');
|
|
167
|
+
// fill in the form with the read-only test account's credentials
|
|
168
|
+
await page.getByRole('button', { name: 'Log in' }).click();
|
|
169
|
+
await page.waitForURL('**/home');
|
|
170
|
+
await page.context().storageState({ path: 'state.json' });
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`state.json` holds a live session: keep it out of version control.
|
|
175
|
+
- **Allow-list the login endpoints**, in any mode: `{ "method": "POST", "url": "^https://app[.]example[.]test/login$" }`, plus the token refresh endpoint if a session must last. Keep these narrow: everything they match is let through.
|
|
176
|
+
- **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).
|
|
177
|
+
- **Launcher mode** has no storage-state option yet: allow-list the login endpoints, or use config mode.
|
|
127
178
|
|
|
128
179
|
## The audit log
|
|
129
180
|
|
|
130
|
-
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
|
|
181
|
+
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, and so is each WebSocket opened under the default `websockets: "allow"`. The file is created when missing and never truncated. Allowed requests and controls are not logged. Set `"log": null` to turn it off.
|
|
131
182
|
|
|
132
|
-
A blocked request
|
|
183
|
+
A blocked request, a refused control and a WebSocket open look like this (one line each in the file):
|
|
133
184
|
|
|
134
185
|
```json
|
|
135
|
-
{"kind":"request","action":"block","reason":"POST is not a read method; policy read-only","method":"POST","url":"https://app.example.test/items","at":"2026-01-15T09:30:00.000Z"}
|
|
186
|
+
{"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"}
|
|
136
187
|
{"kind":"control","action":"block","reason":"\"Delete item\" matches a destructive label; policy read-only","label":"Delete item","at":"2026-01-15T09:30:02.000Z"}
|
|
188
|
+
{"kind":"websocket","action":"open","url":"wss://app.example.test/live","at":"2026-01-15T09:30:03.000Z"}
|
|
137
189
|
```
|
|
138
190
|
|
|
139
|
-
A request event carries `method` and `
|
|
191
|
+
A request event carries `method`, `url` and, when the request has a `content-type` header, `contentType`. 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 event carries only its `url`. `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.
|
|
140
192
|
|
|
141
193
|
## What it cannot see
|
|
142
194
|
|
|
@@ -145,23 +197,35 @@ The guard judges requests the browser sends through its context and clicks the p
|
|
|
145
197
|
- 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.
|
|
146
198
|
- An agent's run-code tool, and any browser the agent launches itself. In config mode, deny those tools as shown above.
|
|
147
199
|
- A Playwright Test run with `-c` from another directory without `WRITE_GUARD_CONFIG`, which falls back to the defaults.
|
|
148
|
-
-
|
|
200
|
+
- WebSocket messages: under the default `websockets: "allow"`, each socket a page opens is recorded as a `websocket` event, but its messages (writes included, as in LiveView, Socket.IO or GraphQL subscriptions) are not judged or logged. `"block"` refuses every socket the guard routes, and apps that need a socket then do not load.
|
|
201
|
+
- WebSockets opened by dedicated or shared workers: they are not routed, so they are neither recorded nor blocked, under either setting.
|
|
202
|
+
- 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`.
|
|
203
|
+
- The page's `WebSocket`: under both settings Playwright replaces it with its routed implementation, which an app that inspects or subclasses it can notice.
|
|
149
204
|
- WebRTC and WebTransport.
|
|
150
205
|
- Redirects after the first URL: only the request the page made is judged, not where the server redirects it.
|
|
151
206
|
- Drag-and-drop, and pointer or mouse handlers that act before `click`.
|
|
152
207
|
- Client-side-only destructive actions that no click or submit reaches.
|
|
153
208
|
- 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.
|
|
209
|
+
- GET requests with side effects (logout, unsubscribe or confirm links, `?action=delete`) pass with no event unless a `writes` signature names them, and `observe` does not list GETs.
|
|
210
|
+
- 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.
|
|
211
|
+
- Request bodies in the log unless `logBodies` is `true`, and request headers other than the content type: events record the method, URL and content type, so an `observe` run shows which endpoints were written to, 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.
|
|
212
|
+
- 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.
|
|
213
|
+
- The HTTP cache: Playwright turns it off while a route is installed, so every load fetches every asset again.
|
|
214
|
+
- Service workers: they are blocked, so `register()` resolves but `navigator.serviceWorker.ready` never settles, and an app that waits for it hangs.
|
|
154
215
|
- 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.
|
|
155
216
|
- 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.
|
|
156
217
|
- 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.
|
|
157
218
|
- Code in the same process that calls Playwright's prototype methods directly (for example `Object.getPrototypeOf(context).route` or `.unrouteAll`, `Object.getPrototypeOf(route).continue`, or `Object.getPrototypeOf(context.request).fetch`, which goes around the read-only lock 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.
|
|
158
219
|
- 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.
|
|
159
220
|
- Under `observe`, sends through `context.request`, `page.request` and the `request` fixture are neither blocked nor recorded (only read-only locks them), so an observe run does not list them.
|
|
160
|
-
- Controls
|
|
221
|
+
- 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).
|
|
161
222
|
- 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.
|
|
162
223
|
- Node's own `http`, `https` and `net` modules in the test process.
|
|
163
224
|
|
|
164
|
-
It also refuses too much
|
|
225
|
+
It also refuses too much, which is the safe direction but can stop a run:
|
|
226
|
+
|
|
227
|
+
- 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").
|
|
228
|
+
- 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).
|
|
165
229
|
|
|
166
230
|
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.
|
|
167
231
|
|
|
@@ -191,7 +255,8 @@ Every key is optional, and unknown keys are ignored:
|
|
|
191
255
|
"controls": { "text": [], "icons": [] },
|
|
192
256
|
"defaultControls": true,
|
|
193
257
|
"websockets": "allow",
|
|
194
|
-
"log": "write-guard.jsonl"
|
|
258
|
+
"log": "write-guard.jsonl",
|
|
259
|
+
"logBodies": false
|
|
195
260
|
}
|
|
196
261
|
```
|
|
197
262
|
|
|
@@ -202,16 +267,18 @@ Every key is optional, and unknown keys are ignored:
|
|
|
202
267
|
| `writes` | list of Sig | `[]` | requests that are writes even when their method is GET, HEAD or OPTIONS |
|
|
203
268
|
| `controls.text` | list of regex sources | `[]` | extra label patterns that make a control destructive |
|
|
204
269
|
| `controls.icons` | list of class name prefixes | `[]` | a control with a class starting with one of these is destructive |
|
|
205
|
-
| `defaultControls` | boolean | `true` | include the built-in verbs in the label pattern |
|
|
206
|
-
| `websockets` | `"allow"` or `"block"` | `"allow"` | `block` refuses every WebSocket |
|
|
270
|
+
| `defaultControls` | boolean | `true` | include the 11 built-in verbs (listed under Writes and controls) in the label pattern; `false` drops them |
|
|
271
|
+
| `websockets` | `"allow"` or `"block"` | `"allow"` | `allow` records each WebSocket open and lets it connect; `block` refuses every WebSocket |
|
|
207
272
|
| `log` | path or `null` | `"write-guard.jsonl"` | the JSONL audit log, relative to the process's working directory; `null` turns it off |
|
|
273
|
+
| `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 |
|
|
208
274
|
|
|
209
|
-
A **Sig** (request signature) is `{ "method": M, "url": U, "body": B }`:
|
|
275
|
+
A **Sig** (request signature) is `{ "method": M, "url": U, "body": B, "headers": H }`:
|
|
210
276
|
|
|
211
|
-
- `method` is
|
|
277
|
+
- `method` is `"*"` or a method name: a string matching `^[A-Za-z][A-Za-z0-9-]*$` (for example `POST`, `delete`, `REPORT` or `M-SEARCH`), compiled to upper case. `*` matches any method; a method name matches that method exactly, compared with the request's method in upper case.
|
|
212
278
|
- `url` is a regex source, compiled with no flags (case-sensitive) and tested, unanchored, against the full request URL. Use `^` and `$` to anchor it, and escape dots: `"^https://api[.]example[.]com/graphql$"`.
|
|
213
279
|
- `body` is optional (absent or `null` means the Sig names no body): a regex source, compiled the same way and tested against the request body. A signature with a `body` never matches a request that has no body (a body that is `null` or absent).
|
|
214
|
-
-
|
|
280
|
+
- `headers` is optional (absent or `null` means the Sig names no headers): an object mapping a header name to a regex source, for example `{ "Next-Action": "^[0-9a-f]+$" }`. Header names compare case-insensitively (they are compiled to lower case). Each value is compiled with no flags and tested, unanchored, against the value of that header on the request. A Sig with `headers` matches a request only when every header it names is present on the request and its value matches.
|
|
281
|
+
- A request matches a Sig when its method, its URL, (when the Sig has one) its body and (when the Sig names any) every named header all match.
|
|
215
282
|
|
|
216
283
|
### Writes and controls
|
|
217
284
|
|
|
@@ -219,11 +286,19 @@ A **Sig** (request signature) is `{ "method": M, "url": U, "body": B }`:
|
|
|
219
286
|
|
|
220
287
|
**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):
|
|
221
288
|
|
|
222
|
-
`save`, `delete`, `remove`, `publish`, `
|
|
289
|
+
`save`, `delete`, `remove`, `publish`, `send`, `archive`, `pay`, `purchase`, `log out`, `sign out`, `logout`
|
|
223
290
|
|
|
224
291
|
There are no built-in icons: icon libraries differ per app, so name yours in `controls.icons` (for example `"icon-trash"`). An empty label never matches the label pattern; with `defaultControls: false` and no `controls.text`, only icons can make a control destructive.
|
|
225
292
|
|
|
226
|
-
In the page,
|
|
293
|
+
In the page, a **control** is an element matching the control selector `button, a[href], [role=button], [role=menuitem], [role=link], [role=tab], input[type=submit], input[type=button], input[type=image]`. The page judges three events, and only when they have a control:
|
|
294
|
+
|
|
295
|
+
- **Click:** the control is the first element of `event.composedPath()` that matches the control selector. The path runs from the click target outward and crosses open shadow roots. When no element on it matches, the click is not judged.
|
|
296
|
+
- **Form submit:** the control is `event.submitter`. When there is none (as with `form.requestSubmit()` called without a submitter), it is the first element of the form's `form.elements` matching `button:not([type]), button[type=submit], input[type=submit]`. When there is none of those either, the submit is not judged.
|
|
297
|
+
- **Enter or Space keydown:** judged only when `document.activeElement` itself matches the control selector, and that element is the control. A keydown in a text field, or with focus on any other element, is never judged.
|
|
298
|
+
|
|
299
|
+
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.
|
|
300
|
+
|
|
301
|
+
Not judged in the page, by design: anchors without `href`, `<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.
|
|
227
302
|
|
|
228
303
|
### The decision core: `playwright-write-guard`
|
|
229
304
|
|
|
@@ -237,22 +312,51 @@ decideControl({ label: 'Delete item', icons: [] }, config);
|
|
|
237
312
|
// { action: 'block', reason: '...' }
|
|
238
313
|
```
|
|
239
314
|
|
|
240
|
-
- `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]`), or `config` for the whole value. Malformed means:
|
|
241
|
-
-
|
|
315
|
+
- `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:
|
|
316
|
+
- a config that is not an object;
|
|
317
|
+
- a `policy` or `websockets` outside its allowed values;
|
|
318
|
+
- 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`);
|
|
319
|
+
- `allow`, `writes`, `controls.text` or `controls.icons` that is not a list;
|
|
320
|
+
- a Sig that is not an object;
|
|
321
|
+
- a `url`, a `body` other than `null`, or a `controls.text` entry that is not a non-empty string or does not compile;
|
|
322
|
+
- a Sig `headers` other than `null` that is not an object, which throws `write-guard: <path>.headers: must be an object` (for example `write-guard: allow[0].headers: must be an object`);
|
|
323
|
+
- a `headers` value that is not a non-empty string or does not compile, which throws the same messages as `url` (`must be a non-empty regex string`, `does not compile: <message>`), with the header name as written in the config as the path (for example `write-guard: allow[0].headers.Next-Action: must be a non-empty regex string`);
|
|
324
|
+
- a `controls.icons` entry that is not a non-empty string;
|
|
325
|
+
- `controls` that is not an object;
|
|
326
|
+
- `defaultControls` that is not a boolean;
|
|
327
|
+
- `logBodies` that is not a boolean, which throws `write-guard: logBodies: must be a boolean`;
|
|
328
|
+
- `log` that is neither a non-empty string nor `null`.
|
|
329
|
+
|
|
330
|
+
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`.
|
|
331
|
+
- `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"`.
|
|
242
332
|
- `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"`.
|
|
243
333
|
- `reason` is a short human-readable explanation; its wording is not part of the API.
|
|
244
334
|
|
|
245
|
-
|
|
335
|
+
`compileConfig` and `decideControl` reference nothing outside their own arguments, because the installer injects their source text into every page.
|
|
246
336
|
|
|
247
337
|
### The installer: `playwright-write-guard/install`
|
|
248
338
|
|
|
249
339
|
`install(context, config?)` guards one Playwright `BrowserContext` and returns `Promise<GuardHandle>`. It compiles `config` (default `{}`) with `compileConfig`, so a malformed config throws before anything is installed. A second call on the same context returns the first call's handle and ignores its own `config`. It:
|
|
250
340
|
|
|
251
|
-
1. Registers one catch-all `context.route
|
|
252
|
-
2. Wraps `context.route` and the `route` of every page in the context, present and future
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
341
|
+
1. Registers one catch-all `context.route`, matching every URL, before anything else. Page handlers run before context handlers, and context handlers run in reverse order of registration, so in the guard's process the catch-all is the last of the handlers registered after `install`.
|
|
342
|
+
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:
|
|
343
|
+
- `continue(options)` and `fetch(options)` are egress points, judged as in step 3;
|
|
344
|
+
- `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;
|
|
345
|
+
- `fulfill` and `abort` are not judged and record nothing, so a test can mock a write by fulfilling it.
|
|
346
|
+
|
|
347
|
+
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).
|
|
348
|
+
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`:
|
|
349
|
+
- 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.
|
|
350
|
+
- 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. The event's `contentType` is the effective `content-type` header.
|
|
351
|
+
- 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.
|
|
352
|
+
- On `allow` or `observe` (`observe` records the event first): (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.
|
|
353
|
+
- Each egress attempt records at most one event (none when the request is allowed). `routed_requests` counts each request once, at its first egress; a request a caller fulfills or aborts without reaching an egress is not counted.
|
|
354
|
+
4. Registers one `context.routeWebSocket`, matching every URL, under both `websockets` settings and either policy:
|
|
355
|
+
- Under `"allow"`, each socket is recorded as `{ kind: "websocket", action: "open", url, at }` and connected with `ws.connectToServer()`. Its messages are not judged or logged.
|
|
356
|
+
- 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`).
|
|
357
|
+
|
|
358
|
+
No socket counts in `routed_requests`, and a `websocket` event counts in neither `blocked` nor `observed`. 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 and `"block"` does not refuse that socket; sockets opened by dedicated or shared workers are not routed, so they are neither recorded nor blocked.
|
|
359
|
+
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.
|
|
256
360
|
6. Under `read-only`, dismisses every dialog (`alert`, `confirm`, `prompt`, `beforeunload`) on every page, so a native confirmation before a delete is declined.
|
|
257
361
|
7. Under `read-only`, replaces `context.request` and the `request` of every page, present and future, with a proxy whose send methods (`fetch`, `get`, `post`, `put`, `patch`, `delete`, `head`) throw `write-guard: <method> through APIRequestContext is refused under read-only` (for example `write-guard: post through APIRequestContext is refused under read-only`). The other properties stay readable. This lock exists only in the process that called `install`.
|
|
258
362
|
|
|
@@ -260,18 +364,23 @@ If the binding `__writeGuardReport` is already registered on the context, anothe
|
|
|
260
364
|
|
|
261
365
|
`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.
|
|
262
366
|
|
|
263
|
-
**Events.** Every blocked or observed write is one event:
|
|
367
|
+
**Events.** Every blocked or observed write is one event, and so is every WebSocket opened under `websockets: "allow"`:
|
|
264
368
|
|
|
265
369
|
```ts
|
|
266
|
-
{ kind: 'request'
|
|
267
|
-
method?: string, url?: string,
|
|
370
|
+
{ kind: 'request', action: 'block' | 'observe', reason: string,
|
|
371
|
+
method?: string, url: string, contentType?: string, body?: string, at: string }
|
|
372
|
+
{ kind: 'control', action: 'block' | 'observe', reason: string, label: string, at: string }
|
|
373
|
+
{ kind: 'websocket', action: 'open', url: string, at: string }
|
|
268
374
|
```
|
|
269
375
|
|
|
270
|
-
A request event carries `method` and `url`; a
|
|
376
|
+
- 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.
|
|
377
|
+
- A control event carries `label` (trimmed, at most 120 characters) and none of the request fields.
|
|
378
|
+
- A websocket event carries `url` and no `reason`.
|
|
379
|
+
- `at` is an ISO 8601 timestamp. Allowed requests and controls produce no event.
|
|
271
380
|
|
|
272
381
|
**The handle.**
|
|
273
382
|
|
|
274
|
-
- `record()` returns `{ installed: true, policy, routed_requests, blocked, observed, events }`: `routed_requests` counts the requests the guard judged, each once; `blocked` and `observed` count request and control events
|
|
383
|
+
- `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 control events by their `action`, and `websocket` events count in neither.
|
|
275
384
|
- `close()` resolves after the last pending log append and returns the final `record()`. The guard stays active until the context closes.
|
|
276
385
|
|
|
277
386
|
**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.
|
|
@@ -335,7 +444,7 @@ Without those entries the agent can remove the guard.
|
|
|
335
444
|
|
|
336
445
|
`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.
|
|
337
446
|
|
|
338
|
-
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()
|
|
447
|
+
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. 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`).
|
|
339
448
|
|
|
340
449
|
The command does the same from a shell:
|
|
341
450
|
|
|
@@ -350,20 +459,28 @@ npx playwright-write-guard-browser [--config <path>] [--headed] [--host <h>] [--
|
|
|
350
459
|
- `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.
|
|
351
460
|
- An agent's run-code tool, and any browser the agent launches itself.
|
|
352
461
|
- 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.
|
|
353
|
-
-
|
|
462
|
+
- WebSocket messages: under the default `websockets: "allow"`, each socket a page opens is recorded as a `websocket` event, but its messages (writes included) are not judged or logged. `"block"` refuses every socket the guard routes.
|
|
463
|
+
- WebSockets opened by dedicated or shared workers: they are not routed, so they are neither recorded nor blocked, under either setting.
|
|
464
|
+
- 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`.
|
|
465
|
+
- The page's `WebSocket`: under both settings Playwright replaces it with its routed implementation.
|
|
354
466
|
- WebRTC and WebTransport.
|
|
355
467
|
- Redirects after the first URL: only the request the page made is judged, not where the server redirects it.
|
|
356
468
|
- Drag-and-drop, and pointer or mouse handlers that act before `click`.
|
|
357
469
|
- Client-side-only destructive actions that no click or submit reaches.
|
|
358
470
|
- 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.
|
|
471
|
+
- GET requests with side effects pass with no event unless a `writes` signature names them, and `observe` does not list GETs.
|
|
472
|
+
- 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.
|
|
473
|
+
- Request bodies unless `logBodies` is `true`, and request headers other than the content type: events record the method, URL and content type. `logBodies` is off by default because bodies can hold credentials and personal data.
|
|
474
|
+
- 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.
|
|
475
|
+
- The HTTP cache: Playwright turns it off while a route is installed.
|
|
476
|
+
- Service workers: `register()` resolves but `navigator.serviceWorker.ready` never settles.
|
|
359
477
|
- 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.
|
|
360
478
|
- 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.
|
|
361
479
|
- 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.
|
|
362
480
|
- Code in the same process that calls Playwright's prototype methods directly (for example `Object.getPrototypeOf(context).route` or `.unrouteAll`, `Object.getPrototypeOf(route).continue`, or `Object.getPrototypeOf(context.request).fetch`, which goes around the read-only lock 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.
|
|
363
481
|
- 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.
|
|
364
482
|
- Under `observe`, sends through `context.request`, `page.request` and the `request` fixture are neither blocked nor recorded (only read-only locks them), so an observe run does not list them.
|
|
365
|
-
-
|
|
366
|
-
- Controls whose label is rendered inside a shadow root: the page sees the shadow host, whose text does not include the shadow content, so the click is not refused in the page. The network layer still judges any request it sends.
|
|
483
|
+
- 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).
|
|
367
484
|
- 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.
|
|
368
485
|
- Node's own `http`, `https` and `net` modules in the test process.
|
|
369
486
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "playwright-write-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A read-only safety net for Playwright tests and AI browser agents: blocks writes at the network layer and refuses destructive clicks in the page.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
package/src/core.mjs
CHANGED
|
@@ -10,10 +10,8 @@
|
|
|
10
10
|
* Throws `write-guard: <path>: <message>` on anything malformed.
|
|
11
11
|
*/
|
|
12
12
|
export function compileConfig(raw) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
'import', 'rename', 'duplicate', 'update', 'confirm', 'pay', 'purchase', 'invite', 'reset',
|
|
16
|
-
'restore', 'approve', 'reject', 'log out', 'sign out'];
|
|
13
|
+
const VERBS = ['save', 'delete', 'remove', 'publish', 'send', 'archive', 'pay', 'purchase',
|
|
14
|
+
'log out', 'sign out', 'logout'];
|
|
17
15
|
const fail = (path, message) => { throw new Error(`write-guard: ${path}: ${message}`); };
|
|
18
16
|
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
19
17
|
const list = (v, path) => {
|
|
@@ -33,11 +31,16 @@ export function compileConfig(raw) {
|
|
|
33
31
|
const sigs = (v, key) => list(v, key).map((s, i) => {
|
|
34
32
|
const at = `${key}[${i}]`;
|
|
35
33
|
if (!isObj(s)) fail(at, 'must be an object');
|
|
36
|
-
if (
|
|
34
|
+
if (s.method !== '*' && !(typeof s.method === 'string' && /^[A-Za-z][A-Za-z0-9-]*$/.test(s.method))) {
|
|
35
|
+
fail(`${at}.method`, 'must be "*" or a method name');
|
|
36
|
+
}
|
|
37
|
+
const hs = s.headers === undefined || s.headers === null ? {} : s.headers;
|
|
38
|
+
if (!isObj(hs)) fail(`${at}.headers`, 'must be an object');
|
|
37
39
|
return {
|
|
38
|
-
method: s.method,
|
|
40
|
+
method: s.method.toUpperCase(),
|
|
39
41
|
url: regex(s.url, `${at}.url`, ''),
|
|
40
42
|
body: s.body === undefined || s.body === null ? null : regex(s.body, `${at}.body`, ''),
|
|
43
|
+
headers: Object.keys(hs).map((k) => ({ name: k.toLowerCase(), value: regex(hs[k], `${at}.headers.${k}`, '') })),
|
|
41
44
|
};
|
|
42
45
|
});
|
|
43
46
|
|
|
@@ -51,6 +54,7 @@ export function compileConfig(raw) {
|
|
|
51
54
|
if (cfg.log !== undefined && cfg.log !== null && (typeof cfg.log !== 'string' || cfg.log === '')) {
|
|
52
55
|
fail('log', 'must be a non-empty string or null');
|
|
53
56
|
}
|
|
57
|
+
if (cfg.logBodies !== undefined && typeof cfg.logBodies !== 'boolean') fail('logBodies', 'must be a boolean');
|
|
54
58
|
|
|
55
59
|
const policy = oneOf(cfg.policy, ['read-only', 'observe'], 'read-only', 'policy');
|
|
56
60
|
const websockets = oneOf(cfg.websockets, ['allow', 'block'], 'allow', 'websockets');
|
|
@@ -73,6 +77,7 @@ export function compileConfig(raw) {
|
|
|
73
77
|
icons: [...icons],
|
|
74
78
|
websockets,
|
|
75
79
|
log: cfg.log === undefined ? 'write-guard.jsonl' : cfg.log,
|
|
80
|
+
logBodies: cfg.logBodies === true,
|
|
76
81
|
};
|
|
77
82
|
}
|
|
78
83
|
|
|
@@ -81,8 +86,11 @@ export function decideRequest(request, config) {
|
|
|
81
86
|
const method = String(request.method ?? '').toUpperCase();
|
|
82
87
|
const url = String(request.url ?? '');
|
|
83
88
|
const body = request.body;
|
|
89
|
+
const headers = {};
|
|
90
|
+
for (const k of Object.keys(request.headers ?? {})) headers[k.toLowerCase()] = request.headers[k];
|
|
84
91
|
const matches = (s) => (s.method === '*' || s.method === method) && s.url.test(url)
|
|
85
|
-
&& (s.body === null || (typeof body === 'string' && s.body.test(body)))
|
|
92
|
+
&& (s.body === null || (typeof body === 'string' && s.body.test(body)))
|
|
93
|
+
&& s.headers.every((h) => typeof headers[h.name] === 'string' && h.value.test(headers[h.name]));
|
|
86
94
|
|
|
87
95
|
const allowed = config.allow.findIndex(matches);
|
|
88
96
|
if (allowed !== -1) return { action: 'allow', reason: `allow[${allowed}] marks it a read` };
|
package/src/install.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// playwright-write-guard/install: guard one Playwright BrowserContext.
|
|
2
2
|
//
|
|
3
|
-
// The network side judges
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// The network side judges each request at egress, where it would leave the browser: a
|
|
4
|
+
// catch-all context route registered first (so it runs last), and the continue and fetch of
|
|
5
|
+
// every route a caller registers later. A caller's handler sees every request and may fulfill
|
|
6
|
+
// or abort it. The page side is an init script built from the decision core's own source,
|
|
7
|
+
// with capture-phase listeners that refuse destructive controls before the app sees them.
|
|
8
|
+
// No playwright import: everything goes through the context it is handed.
|
|
8
9
|
|
|
9
10
|
import { existsSync, promises as fsp, readFileSync } from 'node:fs';
|
|
10
11
|
import { resolve } from 'node:path';
|
|
@@ -40,7 +41,8 @@ async function setup(context, raw, cfg) {
|
|
|
40
41
|
const emit = (event) => {
|
|
41
42
|
const e = { ...event, at: new Date().toISOString() };
|
|
42
43
|
events.push(e);
|
|
43
|
-
|
|
44
|
+
if (e.action === 'block') counts.blocked++;
|
|
45
|
+
else if (e.action === 'observe') counts.observed++;
|
|
44
46
|
if (logPath) {
|
|
45
47
|
const line = `${JSON.stringify(e)}\n`;
|
|
46
48
|
pending = pending.then(() => fsp.appendFile(logPath, line))
|
|
@@ -59,58 +61,59 @@ async function setup(context, raw, cfg) {
|
|
|
59
61
|
throw err;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// Judges the request as it would leave through `kind` ('catch-all', 'continue' or 'fetch'):
|
|
65
|
+
// each of method, URL and body from `options` when given, else as the request reached this
|
|
66
|
+
// point (route.request() reflects earlier fallback overrides). Headers are a whole set,
|
|
67
|
+
// never merged, as Playwright replaces them. Then blocks it or lets it proceed.
|
|
68
|
+
const counted = new WeakSet();
|
|
69
|
+
const egress = async (route, options, kind) => {
|
|
65
70
|
const req = route.request();
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
const url = req.url();
|
|
71
|
+
const o = options ?? {};
|
|
72
|
+
const method = String(o.method || req.method()).toUpperCase();
|
|
73
|
+
const url = o.url || req.url();
|
|
74
|
+
const p = o.postData;
|
|
71
75
|
let body = null;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
if (req.resourceType() === 'document') {
|
|
78
|
-
await route.fulfill({ status: 403, contentType: 'text/html; charset=utf-8', body: blockedPage(method, url) });
|
|
79
|
-
} else {
|
|
80
|
-
await route.abort('blockedbyclient');
|
|
81
|
-
}
|
|
82
|
-
return false;
|
|
83
|
-
};
|
|
76
|
+
if (p != null) body = typeof p === 'string' ? p : Buffer.isBuffer(p) ? p.toString('utf8') : JSON.stringify(p);
|
|
77
|
+
else try { body = req.postData(); } catch { /* an undecodable body is judged as none */ }
|
|
78
|
+
const headers = {};
|
|
79
|
+
for (const [k, val] of Object.entries(o.headers || req.headers())) headers[k.toLowerCase()] = String(val);
|
|
80
|
+
if (!counted.has(req)) { counted.add(req); counts.routed++; }
|
|
84
81
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
82
|
+
const v = decideRequest({ method, url, body, headers }, cfg);
|
|
83
|
+
if (v.action !== 'allow') {
|
|
84
|
+
emit({
|
|
85
|
+
kind: 'request', action: v.action, reason: v.reason, method, url,
|
|
86
|
+
...(headers['content-type'] === undefined ? {} : { contentType: headers['content-type'] }),
|
|
87
|
+
...(cfg.logBodies && body != null ? { body: body.slice(0, 500) } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (v.action === 'block') {
|
|
91
|
+
if (kind === 'fetch') throw new Error(`write-guard: route.fetch of ${method} ${url} is refused under read-only`);
|
|
92
|
+
if (req.resourceType() === 'document') {
|
|
93
|
+
return route.fulfill({ status: 403, contentType: 'text/html; charset=utf-8', body: blockedPage(method, url) });
|
|
94
|
+
}
|
|
95
|
+
return route.abort('blockedbyclient');
|
|
96
|
+
}
|
|
97
|
+
// fallback, never continue, so handlers registered behind the catch-all still run.
|
|
98
|
+
return kind === 'catch-all' ? route.fallback() : route[kind](options);
|
|
99
99
|
};
|
|
100
100
|
|
|
101
101
|
// A predicate, not a glob, so no caller's unroute(url) can name it.
|
|
102
|
-
await context.route(() => true,
|
|
102
|
+
await context.route(() => true, (route) => egress(route, undefined, 'catch-all'));
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
await context.routeWebSocket(() => true, async (ws) => {
|
|
105
|
+
if (cfg.websockets === 'block') {
|
|
106
106
|
emit({ kind: 'request', action: 'block', reason: 'websockets is "block": every WebSocket is refused', url: ws.url() });
|
|
107
107
|
await ws.close({ code: 1008, reason: 'write-guard' }).catch(() => {});
|
|
108
|
-
}
|
|
109
|
-
|
|
108
|
+
} else {
|
|
109
|
+
emit({ kind: 'websocket', action: 'open', url: ws.url() });
|
|
110
|
+
ws.connectToServer();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
110
113
|
|
|
111
114
|
await context.addInitScript({ content: pageScript(raw) });
|
|
112
115
|
|
|
113
|
-
patchRoutes(context,
|
|
116
|
+
patchRoutes(context, egress);
|
|
114
117
|
if (cfg.policy === 'read-only') {
|
|
115
118
|
lockRequest(context);
|
|
116
119
|
// The context's dialog event is emitted before any page's, so this runs ahead of every
|
|
@@ -118,7 +121,7 @@ async function setup(context, raw, cfg) {
|
|
|
118
121
|
context.on('dialog', (d) => d.dismiss().catch(() => {}));
|
|
119
122
|
}
|
|
120
123
|
const onPage = (page) => {
|
|
121
|
-
patchRoutes(page,
|
|
124
|
+
patchRoutes(page, egress);
|
|
122
125
|
if (cfg.policy === 'read-only') lockRequest(page);
|
|
123
126
|
};
|
|
124
127
|
for (const p of context.pages()) onPage(p);
|
|
@@ -139,37 +142,28 @@ const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
|
139
142
|
const blockedPage = (method, url) => `<!doctype html><html><head><meta charset="utf-8"><title>write-guard</title></head>`
|
|
140
143
|
+ `<body><p>write-guard blocked ${escapeHtml(method)} ${escapeHtml(url)}</p></body></html>`;
|
|
141
144
|
|
|
142
|
-
// The route a caller's handler gets: continue
|
|
143
|
-
//
|
|
144
|
-
function judgedRoute(r,
|
|
145
|
+
// The route a caller's handler gets: continue and fetch are egress points, judged first, and
|
|
146
|
+
// everything else (fulfill, abort, fallback) is the original Route's.
|
|
147
|
+
function judgedRoute(r, egress) {
|
|
145
148
|
return new Proxy(r, {
|
|
146
149
|
get(obj, prop) {
|
|
147
|
-
if (prop === 'continue' || prop === '
|
|
148
|
-
return async (options) => (judgeOverride(obj, options).pass ? obj[prop](options) : obj.abort('blockedbyclient'));
|
|
149
|
-
}
|
|
150
|
-
if (prop === 'fetch') {
|
|
151
|
-
return async (options) => {
|
|
152
|
-
const v = judgeOverride(obj, options);
|
|
153
|
-
if (!v.pass) throw new Error(`write-guard: route.fetch of ${v.method} ${v.url} is refused under read-only`);
|
|
154
|
-
return obj.fetch(options);
|
|
155
|
-
};
|
|
156
|
-
}
|
|
150
|
+
if (prop === 'continue' || prop === 'fetch') return async (options) => egress(obj, options, prop);
|
|
157
151
|
const v = Reflect.get(obj, prop, obj);
|
|
158
152
|
return typeof v === 'function' ? v.bind(obj) : v;
|
|
159
153
|
},
|
|
160
154
|
});
|
|
161
155
|
}
|
|
162
156
|
|
|
163
|
-
// Every handler a caller registers
|
|
157
|
+
// Every handler a caller registers gets a judged route; unroute and unrouteAll remove only
|
|
164
158
|
// the caller's own handlers.
|
|
165
|
-
function patchRoutes(target,
|
|
159
|
+
function patchRoutes(target, egress) {
|
|
166
160
|
const route = target.route.bind(target);
|
|
167
161
|
const unroute = target.unroute.bind(target);
|
|
168
162
|
const tracked = [];
|
|
169
163
|
const sameUrl = (a, b) => a === b
|
|
170
164
|
|| (a instanceof RegExp && b instanceof RegExp && a.source === b.source && a.flags === b.flags);
|
|
171
165
|
target.route = (url, handler, options) => {
|
|
172
|
-
const wrapped =
|
|
166
|
+
const wrapped = (r, request) => handler(judgedRoute(r, egress), request);
|
|
173
167
|
tracked.push({ url, handler, wrapped });
|
|
174
168
|
// The returned disposable calls target.unroute(url, wrapped), which lands below.
|
|
175
169
|
return route(url, wrapped, options);
|
|
@@ -212,14 +206,22 @@ function pageScript(raw) {
|
|
|
212
206
|
const bind = window.${BINDING};
|
|
213
207
|
const preventDefault = Function.prototype.call.bind(Event.prototype.preventDefault);
|
|
214
208
|
const stopImmediatePropagation = Function.prototype.call.bind(Event.prototype.stopImmediatePropagation);
|
|
209
|
+
const warn = console.warn.bind(console);
|
|
215
210
|
const report = (e) => { try { Promise.resolve(bind(e)).catch(() => {}); } catch (_) {} };
|
|
216
|
-
const CONTROL = 'button, a, [role=button], [role=menuitem], [role=link], [role=tab], input[type=submit], input[type=button], input[type=image]
|
|
217
|
-
const
|
|
211
|
+
const CONTROL = 'button, a[href], [role=button], [role=menuitem], [role=link], [role=tab], input[type=submit], input[type=button], input[type=image]';
|
|
212
|
+
const SUBMIT = 'button:not([type]), button[type=submit], input[type=submit]';
|
|
213
|
+
const ownLabel = (el) => {
|
|
218
214
|
for (const v of [el.getAttribute('aria-label'), el.innerText, el.getAttribute('title'), el.value]) {
|
|
219
215
|
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
220
216
|
}
|
|
221
217
|
return '';
|
|
222
218
|
};
|
|
219
|
+
// One level up: a control in a shadow root whose own label is empty takes its host's.
|
|
220
|
+
const labelOf = (el) => {
|
|
221
|
+
const own = ownLabel(el);
|
|
222
|
+
const root = el.getRootNode();
|
|
223
|
+
return own || !(root instanceof ShadowRoot) ? own : ownLabel(root.host);
|
|
224
|
+
};
|
|
223
225
|
const iconsOf = (el) => {
|
|
224
226
|
const out = [];
|
|
225
227
|
for (const n of [el, ...el.querySelectorAll('[class]')]) {
|
|
@@ -232,15 +234,23 @@ function pageScript(raw) {
|
|
|
232
234
|
const label = labelOf(el);
|
|
233
235
|
const v = decideControl({ label, icons: iconsOf(el) }, cfg);
|
|
234
236
|
if (v.action === 'allow') return;
|
|
235
|
-
if (v.action === 'block') {
|
|
237
|
+
if (v.action === 'block') {
|
|
238
|
+
preventDefault(e);
|
|
239
|
+
stopImmediatePropagation(e);
|
|
240
|
+
warn('write-guard refused "' + label.slice(0, 120) + '": ' + v.reason);
|
|
241
|
+
}
|
|
236
242
|
report({ kind: 'control', action: v.action, reason: v.reason, label: label.slice(0, 120) });
|
|
237
243
|
};
|
|
238
|
-
|
|
239
|
-
|
|
244
|
+
const isControl = (n) => n instanceof Element && n.matches(CONTROL);
|
|
245
|
+
// The path crosses open shadow roots and stops at a closed one's host.
|
|
246
|
+
window.addEventListener('click', (e) => judge(e.composedPath().find(isControl), e), true);
|
|
247
|
+
window.addEventListener('submit', (e) => {
|
|
248
|
+
const form = e.target;
|
|
249
|
+
const fallback = form instanceof HTMLFormElement ? Array.prototype.find.call(form.elements, (n) => n.matches(SUBMIT)) : null;
|
|
250
|
+
judge(e.submitter || fallback, e);
|
|
240
251
|
}, true);
|
|
241
|
-
window.addEventListener('submit', (e) => judge(e.submitter || e.target, e), true);
|
|
242
252
|
window.addEventListener('keydown', (e) => {
|
|
243
|
-
if (e.key === 'Enter' || e.key === ' ') judge(document.activeElement, e);
|
|
253
|
+
if ((e.key === 'Enter' || e.key === ' ') && isControl(document.activeElement)) judge(document.activeElement, e);
|
|
244
254
|
}, true);
|
|
245
255
|
})();`;
|
|
246
256
|
}
|
package/types/core.d.ts
CHANGED
|
@@ -3,19 +3,29 @@
|
|
|
3
3
|
/** `read-only` blocks writes; `observe` records them and lets them through. */
|
|
4
4
|
export type Policy = 'read-only' | 'observe';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A request method a signature can name: `"*"` (any method) or a method name matching
|
|
8
|
+
* `^[A-Za-z][A-Za-z0-9-]*$` (for example `POST`, `REPORT` or `M-SEARCH`), compiled to upper
|
|
9
|
+
* case and compared with the request's method in upper case.
|
|
10
|
+
*/
|
|
11
|
+
export type SigMethod = string;
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* A request signature. `url` and `body` are regex sources, compiled with no flags
|
|
11
15
|
* (case-sensitive) and tested unanchored against the full URL and the request body.
|
|
12
|
-
* A signature with `body` never matches a request that has no body.
|
|
16
|
+
* A signature with `body` never matches a request that has no body. A signature with
|
|
17
|
+
* `headers` matches only when every named header is present and its value matches.
|
|
13
18
|
*/
|
|
14
19
|
export interface Sig {
|
|
15
20
|
method: SigMethod;
|
|
16
21
|
url: string;
|
|
17
22
|
/** Absent or `null`: the signature names no body. */
|
|
18
23
|
body?: string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Header name to a regex source. Names compare case-insensitively; values are compiled
|
|
26
|
+
* with no flags and tested unanchored. Absent or `null`: the signature names no headers.
|
|
27
|
+
*/
|
|
28
|
+
headers?: Record<string, string> | null;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
/** The raw config. Every key is optional; unknown keys are ignored. */
|
|
@@ -32,19 +42,27 @@ export interface Config {
|
|
|
32
42
|
/** Class name prefixes; a control with a class starting with one is destructive. */
|
|
33
43
|
icons?: string[];
|
|
34
44
|
};
|
|
35
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Default `true`: include the 11 built-in verbs in the text pattern: `save`, `delete`,
|
|
47
|
+
* `remove`, `publish`, `send`, `archive`, `pay`, `purchase`, `log out`, `sign out`, `logout`.
|
|
48
|
+
*/
|
|
36
49
|
defaultControls?: boolean;
|
|
37
50
|
/** Default `"allow"`. */
|
|
38
51
|
websockets?: 'allow' | 'block';
|
|
39
52
|
/** JSONL audit log path, relative to the working directory. Default `"write-guard.jsonl"`; `null` turns it off. */
|
|
40
53
|
log?: string | null;
|
|
54
|
+
/** Default `false`: when `true`, request events carry `body`, the first 500 characters of the request body. */
|
|
55
|
+
logBodies?: boolean;
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
export interface CompiledSig {
|
|
59
|
+
/** `"*"` or the method name in upper case. */
|
|
44
60
|
method: SigMethod;
|
|
45
61
|
url: RegExp;
|
|
46
62
|
/** `null` when the signature names no body. */
|
|
47
63
|
body: RegExp | null;
|
|
64
|
+
/** Header names in lower case; empty when the signature names no headers. */
|
|
65
|
+
headers: Array<{ name: string; value: RegExp }>;
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
/** What `compileConfig` returns: every default applied, every pattern compiled. */
|
|
@@ -58,6 +76,7 @@ export interface CompiledConfig {
|
|
|
58
76
|
icons: string[];
|
|
59
77
|
websockets: 'allow' | 'block';
|
|
60
78
|
log: string | null;
|
|
79
|
+
logBodies: boolean;
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
export interface RequestInput {
|
|
@@ -65,6 +84,8 @@ export interface RequestInput {
|
|
|
65
84
|
url: string;
|
|
66
85
|
/** The request body; `null` or absent when it has none. */
|
|
67
86
|
body?: string | null;
|
|
87
|
+
/** The request headers, names in any case (`decideRequest` lower-cases them); absent when it has none. */
|
|
88
|
+
headers?: Record<string, string>;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
export interface RequestDecision {
|
package/types/install.d.ts
CHANGED
|
@@ -3,22 +3,50 @@
|
|
|
3
3
|
import type { BrowserContext } from 'playwright-core';
|
|
4
4
|
import type { Config, Policy } from './core.js';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
export interface
|
|
8
|
-
kind: 'request'
|
|
6
|
+
/** A write the guard blocked or observed at egress, or a WebSocket refused under `websockets: "block"`. */
|
|
7
|
+
export interface RequestEvent {
|
|
8
|
+
kind: 'request';
|
|
9
9
|
action: 'block' | 'observe';
|
|
10
10
|
reason: string;
|
|
11
|
+
/** The effective method, upper case. Absent for a refused WebSocket. */
|
|
11
12
|
method?: string;
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
/** The effective URL, or the refused WebSocket's URL. */
|
|
14
|
+
url: string;
|
|
15
|
+
/** The effective `content-type` header, when the request has one. */
|
|
16
|
+
contentType?: string;
|
|
17
|
+
/** Only with `logBodies: true`: the first 500 characters of the effective body, when the request has one. */
|
|
18
|
+
body?: string;
|
|
14
19
|
/** ISO 8601 timestamp. */
|
|
15
20
|
at: string;
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
/** A destructive control the page blocked or observed. */
|
|
24
|
+
export interface ControlEvent {
|
|
25
|
+
kind: 'control';
|
|
26
|
+
action: 'block' | 'observe';
|
|
27
|
+
reason: string;
|
|
28
|
+
/** Trimmed, at most 120 characters. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** ISO 8601 timestamp. */
|
|
31
|
+
at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A WebSocket opened under `websockets: "allow"`. Counts in neither `blocked` nor `observed`. */
|
|
35
|
+
export interface WebSocketEvent {
|
|
36
|
+
kind: 'websocket';
|
|
37
|
+
action: 'open';
|
|
38
|
+
url: string;
|
|
39
|
+
/** ISO 8601 timestamp. */
|
|
40
|
+
at: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One event the guard recorded. */
|
|
44
|
+
export type GuardEvent = RequestEvent | ControlEvent | WebSocketEvent;
|
|
45
|
+
|
|
18
46
|
export interface GuardRecord {
|
|
19
47
|
installed: true;
|
|
20
48
|
policy: Policy;
|
|
21
|
-
/** Requests the guard judged, each counted once. */
|
|
49
|
+
/** Requests the guard judged at egress, each counted once, at its first egress. WebSockets are not counted. */
|
|
22
50
|
routed_requests: number;
|
|
23
51
|
/** Request and control events with `action: "block"`. */
|
|
24
52
|
blocked: number;
|