@ai-ecoverse/slicc-browsers 1.10.3 → 1.12.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 +20 -6
- package/catalog.json +5 -0
- package/extension-world.mjs +28 -4
- package/guard-driver.mjs +5 -1
- package/guard-runner.mjs +92 -17
- package/guard-vectors.json +1199 -1
- package/package.json +6 -5
- package/protected-error-page.mjs +259 -0
- package/protected.json +37 -0
- package/protected.mjs +16 -1
- package/schema/catalog.schema.json +10 -0
- package/schema/guard-vectors.schema.json +11 -0
- package/schema/protected.schema.json +13 -0
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ Every feature builds for `wasm32-wasip1`.
|
|
|
65
65
|
|
|
66
66
|
`guard-driver.mjs` is described under [Guard vectors](#guard-vectors).
|
|
67
67
|
|
|
68
|
-
`protected.mjs` is the reference matcher for [protected origins](#protected-origins): `import { protectedOrigins } from '@ai-ecoverse/slicc-browsers/protected.mjs'`, then `protectedOrigins(catalog.protectedOrigins, extraOrigins)` returns `{ url(text), target({ url, previous, opener }), cookieDomain(domain), storageKey(key), unparseable(text) }`, each a boolean. `url(text)` uses the load/loaded-URL fail-closed rule; `url(text, { readOnly: true })` preserves the old parse-failure result for read-only keys and value redaction, and `storageKey(key)` uses that mode internally. `unparseable(text)` identifies parse or unwrap failures without changing whether a parsed origin is protected. It needs only the WHATWG `URL`, so it runs in Node and in a browser. `protected.mjs` and `seven.mjs` import nothing and re-export nothing, so an extension or service worker can ship either file alone; `npm run validate` (through `scripts/self-contained.mjs`) evaluates each as a module in a fresh `node:vm` context that has only `URL`, with no linker resolution allowed.
|
|
68
|
+
`protected.mjs` is the reference matcher for [protected origins](#protected-origins): `import { protectedOrigins } from '@ai-ecoverse/slicc-browsers/protected.mjs'`, then `protectedOrigins(catalog.protectedOrigins, extraOrigins)` returns `{ url(text), target({ url, previous, opener, type, documentURL, unreachableURL, navigating }), cookieDomain(domain), storageKey(key), unparseable(text) }`, each a boolean. `url(text)` uses the load/loaded-URL fail-closed rule; `url(text, { readOnly: true })` preserves the old parse-failure result for read-only keys and value redaction, and `storageKey(key)` uses that mode internally. `unparseable(text)` identifies parse or unwrap failures without changing whether a parsed origin is protected. It needs only the WHATWG `URL`, so it runs in Node and in a browser. `protected.mjs` and `seven.mjs` import nothing and re-export nothing, so an extension or service worker can ship either file alone; `npm run validate` (through `scripts/self-contained.mjs`) evaluates each as a module in a fresh `node:vm` context that has only `URL`, with no linker resolution allowed.
|
|
69
69
|
|
|
70
70
|
## `catalog.json`
|
|
71
71
|
|
|
@@ -94,7 +94,7 @@ Every feature builds for `wasm32-wasip1`.
|
|
|
94
94
|
- `discardExceptions`: where the site pattern and the extra origins' hosts go, as a `list` of strings or as a `chrome-time-map`, a map from site to the time it was added (microseconds since 1601-01-01 UTC, as a decimal string);
|
|
95
95
|
- `cleanExit`: values that mark the last exit clean;
|
|
96
96
|
- `form` says where a key comes from: `six` (SLICC six's keys) or `chrome-154` (what Chrome 154 reads).
|
|
97
|
-
- `protectedOrigins`: what browser control must never reach, see [Protected origins](#protected-origins). `domains` (`sliccy.ai`), `hosts` (`vault.sliccy.ai`), `schemes` (`https`, `http`), `innerSchemes` (`blob`, `filesystem`, `view-source`), `anySchemes` (`devtools`), `protectedSchemes` (`chrome`, `chrome-untrusted`, `chrome-search`, `chrome-native`, `chrome-distiller`, `chrome-experimental-site-token-provider`, `isolated-app`, `externalfile`, `content`, `chrome-extension`, `devtools`, `edge`, `brave`, `opera`, `vivaldi`, `arc`, `file`) with `schemeExceptions` (`chrome://newtab`, `chrome://new-tab-page`, `chrome-untrusted://new-tab-page`, `edge://newtab`, `brave://newtab`, `opera://startpage`), `aboutScheme` (`chrome`), `targetTypes` (`browser_ui`), `inheriting` (`about:blank`, `about:srcdoc`), `unparseable` (`protected`) with `unparseableRefusal`, and `refusal`, the text of a parsed protected-origin refusal.
|
|
97
|
+
- `protectedOrigins`: what browser control must never reach, see [Protected origins](#protected-origins). `domains` (`sliccy.ai`), `hosts` (`vault.sliccy.ai`), `schemes` (`https`, `http`), `innerSchemes` (`blob`, `filesystem`, `view-source`), `anySchemes` (`devtools`), `protectedSchemes` (`chrome`, `chrome-untrusted`, `chrome-search`, `chrome-native`, `chrome-distiller`, `chrome-experimental-site-token-provider`, `isolated-app`, `externalfile`, `content`, `chrome-extension`, `devtools`, `edge`, `brave`, `opera`, `vivaldi`, `arc`, `file`) with `schemeExceptions` (`chrome://newtab`, `chrome://new-tab-page`, `chrome-untrusted://new-tab-page`, `edge://newtab`, `brave://newtab`, `opera://startpage`), `aboutScheme` (`chrome`), `targetTypes` (`browser_ui`), `inheriting` (`about:blank`, `about:srcdoc`), `errorPage` (`url: "chrome-error://chromewebdata/"`, `target: "previous"`, `redact: "origin"`), `unparseable` (`protected`) with `unparseableRefusal`, and `refusal`, the text of a parsed protected-origin refusal.
|
|
98
98
|
- `profiles`: SLICC profiles live in `<config dir>/<root>/<name>`, and a snap's in `~/snap/<snap>/common/<snapRoot>/<name>`. `legacyRoot` is the old root, never scanned; on macOS's case-insensitive volume it is SLICC six's root too. `sixProfilePattern` matches six's profile names, case-insensitively. `namePattern` is a valid profile name. `six` describes SLICC six's Chrome profiles (see [SLICC six's profiles](#slicc-sixs-profiles)):
|
|
99
99
|
- `roots`: six's profile root per OS (`macos`, `linux`, `windows`), a `base` location followed by the `path` segments. The base is its `env` when that is set and not empty (any value, as six takes it), else `home` under the home dir.
|
|
100
100
|
- `defaultName` (`browser-coding-agent-chrome`), `defaultPort` (5710) and `portSuffix` (`-{port}`): six's profile for its serve port `<port>` is `<root>/<defaultName>`, with `portSuffix` added for any port other than `defaultPort`.
|
|
@@ -147,6 +147,10 @@ A guard must refuse navigation, creation and attach on protected schemes, includ
|
|
|
147
147
|
|
|
148
148
|
**New-tab checks (1.8.2).** In fresh default profiles on macOS, Chrome for Testing 156.0.8078.4 (Playwright build 1248) and Chrome 155.0.8059.82 commit `chrome://newtab/` to `chrome://new-tab-page/` and embed `chrome-untrusted://new-tab-page/one-google-bar?paramsencoded=` as an out-of-process frame. Direct `chrome://new-tab-page/` also commits as itself. Edge 155.0.4283.60 commits `edge://newtab/` to `https://ntp.msn.com/edge/ntp` with an `about:blank` child and no internal-scheme child observed; directly opening `edge://ntp/` returns `ERR_INVALID_URL`. These observations are for fresh default profiles, not every browser configuration. Brave, Opera and Vivaldi were not installed locally, and this repository's CI installs only Chromium, so their redirects and internal frames were not verified and no additional exceptions are inferred. Incognito Chrome and Edge new tabs omit the ordinary new tab's frame tree; the reachability vector therefore uses the default context.
|
|
149
149
|
|
|
150
|
+
**Protected-URL error pages.** `errorPage` in both the catalog and `protected.json` retains the previous target decision; it never locks an allowed target or unlocks an already-protected target. The exemption requires the last main-frame different-document commit to be `chrome-error://chromewebdata/`, with opaque `securityOrigin` (`://`), and `unreachableUrl` exactly equal to the reported `TargetInfo.url`, with no main-frame navigation in flight. A URL mismatch, unavailable commit evidence or an in-flight navigation does not qualify; guards fail closed while their state is unknown. `protected.mjs.target` takes validated `documentURL`, `unreachableURL` and `navigating` evidence in addition to `previous`; callers must track commits and readiness, rather than trust `TargetInfo.url` alone. Browser UI target types remain protected. Read-only discovery still redacts the URL **and title** to the protected origin in `getTargets`, `getTargetInfo`, `targetCreated`, `targetInfoChanged` and `attachedToTarget`, including held sessions released later. Path, query and fragment secrets must never survive in those fields. Navigation destinations remain protected: the exception permits recovery, not a protected `Page.navigate` destination.
|
|
151
|
+
|
|
152
|
+
Main-frame loaders start at `Page.frameStartedNavigating`, excluding `sameDocument` and `historySameDocument`, and end on the corresponding commit, `Network.loadingFailed` with matching requestId, or main-frame `Page.frameStoppedLoading`. A different-document commit clears older loaders; `BackForwardCacheRestore` clears the entire set, even though it reuses the restored document’s old loaderId. A guard that is not ready, cannot parse parameters, or has more than 64 loaders in flight must fail closed. BFCache can report the protected TargetInfo before its restore commit, so waiting for `frameNavigated` alone is not sufficient.
|
|
153
|
+
|
|
150
154
|
**Privileged-scheme follow-up checks.** With the expanded catalog, fresh default-profile Chrome for Testing 156.0.8078.4 on macOS again committed both NTP entry points to `chrome://new-tab-page/`, with an `about:blank` background child and only `chrome-untrusted://new-tab-page/one-google-bar?paramsencoded=` as its internal-scheme OOPIF. `C8.newtab` passed both attach and ordinary-navigation legs; no `chrome-search` exceptions are added or inferred for other profile/search-provider configurations. Direct `Page.navigate` to `chrome-search://most-visited/` returned `net::ERR_INVALID_URL` and committed `chrome-error://chromewebdata/` with that exact `unreachableUrl`, confirming why `C8.chrome-search` distinguishes a browser response from a guard refusal rather than requiring a successful search-document commit. The catalog-adopting emulator passed every C8 protection row; plain clients failed them, and removing only `chrome-search` from the adopted catalog failed the new row. C1.href, C2.302, C3.popup-self-nav, all tested C4 iframe process layouts and C5.document passed their guarded and control legs with the expanded schemes; plain clients passed controls and failed protection expectations. Those HTTP fixtures deliberately protect only the seven host, leaving their same-site control host ordinary, while adopting every internal-scheme rule unchanged.
|
|
151
155
|
|
|
152
156
|
**Unparseable URL boundaries.** Fail closed on command destinations: navigation and target creation, the URL and `userTypedURL` of a selected history entry, `Fetch.continueRequest` and legacy interception overrides, `Network.loadNetworkResource`, PWA install/launch, and cookie-command URLs when no domain is supplied. Also fail closed on security-relevant Chrome-reported URLs: targets, main-frame `securityOrigin`, execution-context origins, requests/redirect responses, web sockets and web transports, and service-worker scopes/scripts. A protected default execution context still cuts its session; a protected foreign isolated world stays hidden. This is not a rule to reject arbitrary strings a guard merely reads: storage-command origin keys, storage keys/cache ids, script/stack URLs, generic value redaction and storage events retain the old not-protected result for a parse failure. Cookie domains remain suffix matches, so `exa%20mple.sliccy.ai` is already covered.
|
|
@@ -167,7 +171,7 @@ A same-site frame shares its parent's process and has no target of its own. Ever
|
|
|
167
171
|
|
|
168
172
|
**Refusals** carry the text in `refusal`, `slicc: refused: target is on seven's origin`.
|
|
169
173
|
|
|
170
|
-
**`protected.json`** (schema `schema/protected.schema.json`): `unparseable` (`protected`) and `unparseableRefusal`, in step with the catalog; `origins`, the extra origins every vector runs with; `urls` (`url`, `protected`); `targets` (`url`, `type?`, the target's `TargetInfo.type`; `previous?`, whether the target was protected before; `opener?`, `protected`, `unprotected` or `unknown`, and no `opener` for a target without one; `protected`); `cookieDomains` (`domain`, `protected`); `storageKeys` (`key`, `protected`). `npm run validate` runs every vector through `protected.mjs`, and no vector uses a port from 9222 to 9229.
|
|
174
|
+
**`protected.json`** (schema `schema/protected.schema.json`): `errorPage` (the committed-error previous-decision and origin-redaction rule), `unparseable` (`protected`) and `unparseableRefusal`, in step with the catalog; `origins`, the extra origins every vector runs with; `urls` (`url`, `protected`); `targets` (`url`, `documentURL?` and `unreachableURL?` for validated main-frame error-commit evidence, `navigating?` for a main-frame navigation in flight; `type?`, the target's `TargetInfo.type`; `previous?`, whether the target was protected before; `opener?`, `protected`, `unprotected` or `unknown`, and no `opener` for a target without one; `protected`); `cookieDomains` (`domain`, `protected`); `storageKeys` (`key`, `protected`). `npm run validate` runs every vector through `protected.mjs`, and no vector uses a port from 9222 to 9229.
|
|
171
175
|
|
|
172
176
|
## Guard vectors
|
|
173
177
|
|
|
@@ -178,7 +182,7 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
|
|
|
178
182
|
- `C5.continue-request` and `C5.continue-response`, added next to `C5.document`;
|
|
179
183
|
- the `C8` rows for privileged pages: `C8.navigate` (a page navigates to `chrome://settings`, `chrome://password-manager`, an extension page or `file:///`; refused), `C8.view-source` (the same through `view-source:`; refused), `C8.create` (`Target.createTarget` straight on those pages; refused), `C8.browser-ui-listed` (`Target.getTargets` lists no `browser_ui` target) and `C8.browser-ui-attach` (`Target.attachToTarget` by a `browser_ui` target's id; refused). All five have an unguarded control, since the guard refuses them only on the privileged target. Two cases aren't rows, because Chrome itself blocks them, so an unguarded client can't tell a guard from none: a page's `window.open('chrome://settings')`, and an `about:` alias such as `about:settings`, which `Page.navigate` and `Target.createTarget` both turn into `about:blank#blocked`. The `about:` aliases are in `protected.json` instead.
|
|
180
184
|
|
|
181
|
-
The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each command selecting the hidden world must fail with the `slicc: refused:` prefix, never an ordinary browser error. Numeric guesses also sweep ids 1–32: an id not exposed to this client must be refused, while every exposed numeric context must remain usable; none of those guarded probes may reach the fixture content-script marker. The marker combines the content script’s global lexical `root` binding with its extension-id DOM marker and `chrome.runtime.id`, and is independently required to be absent from the page default world. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
|
|
185
|
+
The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each command selecting the hidden world must fail with the `slicc: refused:` prefix, never an ordinary browser error. Numeric guesses also sweep ids 1–32: an id not exposed to this client must be refused, while every exposed numeric context must remain usable; none of those guarded probes may reach the fixture content-script marker. The marker combines the content script’s global lexical `root` binding with its extension-id DOM marker and `chrome.runtime.id`, and is independently required to be absent from the page default world. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. The control waits up to 5000 ms (scaled by `settleScale`) for `Debugger.paused`; when it never pauses, `reachable` records `"incomplete"`, not `false`, and the row fails with `extensionWorld debugger: no Debugger.paused within <ms>ms`. The guarded leg waits up to 1500 ms, scaled by `refusalScale`. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
|
|
182
186
|
|
|
183
187
|
The integration launcher loads shared-web 1.25.2’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.2 also loads it by default in its own harness. A transport unable to host a foreign content-script extension omits `extensionWorld` and reports `not-supported:extensionWorld`; an advertised capability with no observed world fails, rather than silently skipping.
|
|
184
188
|
|
|
@@ -188,6 +192,8 @@ The integration launcher loads shared-web 1.25.2’s harness extension exactly o
|
|
|
188
192
|
|
|
189
193
|
The `C8.newtab` reachability control attaches through the guard to a settled ordinary new tab, including its internal frames, then navigates that same tab to an ordinary document. Both legs must succeed, including without a guard. It requires `chromeUrlsAttach`, so an extension transport reports it as `skip` with `not-supported:chromeUrlsAttach`. The C8 protection rows expect a refusal on the guarded leg and run their controls unguarded, so they don't require it and still run on extension transports. It uses the default browser context, since an incognito new tab omits the ordinary new tab's internal frames. It covers `chrome://newtab/` and directly opened `chrome://new-tab-page/`.
|
|
190
194
|
|
|
195
|
+
`C8.protected-error-page` touches an allowed tab, scripts a protected navigation, and requires the guard to fail its Document request. The independent observer verifies the committed error document and the exact unreachable/reported URL pair. The session stays attached; reload, navigation to `about:blank` and close succeed, while `Target.getTargetInfo`, `Target.getTargets`, the `Target.attachedToTarget` of a fresh attach and the `targetCreated`/`targetInfoChanged` events redact URL and title to the origin (`infoRedacted`, `listRedacted`, `attachRedacted`, `eventsRedacted`), and none of them shows the full failed URL (`fullURL`). The failed URL carries a secret path, `?query` and `#fragment`; the `.query` and `.fragment` variants of this row and of `C8.protected-error-page-sticky` fail `/doc?query-secret=…` and `/doc#fragment-secret-…` instead, so no path, query or fragment secret may survive alone either. The unguarded control uses independent Fetch failure injection and keeps raw discovery values: its `fullURL` must be `true`. `C8.protected-document-restore` seeds a real protected document before attachment, navigates away, then restores it from BFCache in the same tab after the error/recovery sequence; evaluate, attach and close must be refused. A real `BackForwardCacheRestore` commit is required, so a second failed network request cannot masquerade as this control. `C8.protected-error-page-sticky` additionally fails a request after that real protected restore and requires the already-protected tab to remain locked. They run on browser-session and page-session-only transports alike, have unguarded controls and clean up their own targets. The regression tests include URL-only locking, unconditional error-page unlocking, and a plain client with only request-failure injection.
|
|
196
|
+
|
|
191
197
|
`C8.network-error` and `C8.iframe-error` are ordinary-error reachability controls: request the configured `network-error` URL (default `http://nonexistent.invalid/`) directly or in an ordinary iframe, observe a failed Document request, reattach to the settled tab, evaluate and snapshot its document, then navigate the same tab to an ordinary document. Both control and guarded legs must pass. The failing URL is unchanged on the control, rather than replaced with a healthy origin, and the rows require `browserSession` but not `chromeUrlsAttach`. With Chrome for Testing 156.0.8078.4, scheme-wide `chrome-error` protection made the direct-error row fail both legs because reattachment was refused; removing it passed both rows, with readable DOM and successful recovery. The old emulator still allowed the cross-process iframe parent because the iframe TargetInfo kept the failing HTTP URL, but that layout-specific behavior is not grounds for protecting the error scheme. Plain-client controls and reachability legs also passed both rows.
|
|
192
198
|
|
|
193
199
|
A client may register `Fetch.enable` patterns naming a protected origin in either of two conformant ways: the guard allows registration but never pauses or forwards protected requests to the client and refuses continue, fulfil or fail calls for request ids it did not hand out; or the guard refuses registration with its declared refusal text. Setup calls explicitly naming a protected placeholder are refusable with `refusal.onlyProtected: true`, and interception-dependent observations depend on that refusal. Wildcard and ordinary-only patterns do not receive this exemption. The ordinary-origin control still runs through the guard and must succeed.
|
|
@@ -247,6 +253,8 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
247
253
|
|
|
248
254
|
`{ "extensionWorld": "hidden" | "select" | "debugger" | "drivable", "as": "world", "requires": ["extensionWorld"] }` runs the C9 compound probe on the current ordinary page. The runner attaches an independent observer to discover actual context ids, unique ids and names, and sends probes through the client session. Discovery matches the expected fixture extension’s exact origin, never the first extension world: `run()`’s optional `extensionWorldId` defaults to shared-web’s harness id `ankmbodmgimjemnojmeddfcdlbocolob`. Multiple unrelated content-script worlds are ignored for fixture selection, while guards must still protect foreign worlds. A custom fixture must provide the same content-script marker contract. Object handles are minted and validated on the unguarded control session, then replayed as guesses on the guarded leg; Chrome object handles and DOM node ids are session-local, so observer handles must never be mistaken for client handles. Numeric ids from the control are also replayed alongside fresh observer-discovered selectors, but numeric ids are local to a tab: a control id may name the next tab’s exposed default context, which must work and return no extension marker. The same-tab hidden world’s actual numeric id and every extension unique-id guess still require a matched refusal. Unknown numeric ids and stale object handles (`not exposed`) need explicit refusals rather than Chrome’s invalid-id errors. `guessesRefused` aggregates only required refusals; `guessesReached` must be false on the guarded leg, and `exposedContextsWork` must remain true. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
|
|
249
255
|
|
|
256
|
+
`{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no capability. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
|
|
257
|
+
|
|
250
258
|
`{ "unparseable": true, "as": "unparseable" }` runs the C8 malformed-URL compound probe on the current ordinary page: independent browser ground truth, raw/canonical destination commands, discovery/attach and cookie checks. Boolean evidence is consumed by ordinary `result` checks, with native Chrome outcomes retained in its diagnostics.
|
|
251
259
|
|
|
252
260
|
**The control rule.** Every vector has a control on an ordinary origin: the same setup and trigger against an unprotected origin must succeed. A missing or failing control fails the id. With `via: "guard"`, the control runs through the guard. With `via: "unguarded"`, the runner runs setup and trigger through its own CDP against the ordinary origin and against the protected stand-in, and both must show the effect. The runner reports the control's transport as `via`.
|
|
@@ -358,7 +366,7 @@ every alternative of an `any`, or a required (`all`, `not` or plain) check, need
|
|
|
358
366
|
selects one variant or an array; omission selects all. `parallel` defaults to 1.
|
|
359
367
|
With the `browserContexts` capability each leg runs in its own browser context. A `Target.createBrowserContext` that fails with a timeout is retried once, and the row's `notes` records it; any other failure fails the row.
|
|
360
368
|
|
|
361
|
-
`settleScale` (default 1) multiplies the bounds of waits for events the leg expects to happen: settles (beacon, served, title and load) on the control leg and `required` settles on either leg, `paused` on the control leg, page and child loads, the wait for a background step's `as` result (`<as> did not complete within <ms>ms`), and the wait for an observation the leg expects to be `true`. Adopters on slow hosts can set 3 or 4. Every wait polls and returns as soon as its event arrives, so a larger bound only costs time when something is really missing, and each timeout error names the bound it used. An observation read from the fixture (`observer: "fixture"`, such as a beacon or a served request) that the leg expects to be `true` is polled for up to 5000 ms (scaled) instead of read once. One that the leg expects to be `false` is still read once.
|
|
369
|
+
`settleScale` (default 1) multiplies the bounds of waits for events the leg expects to happen: settles (beacon, served, title and load) on the control leg and `required` settles on either leg, `paused` on the control leg, page and child loads, the wait for a background step's `as` result (`<as> did not complete within <ms>ms`), the wait for the pre-action beacons, the C9 control's `Debugger.paused`, and the wait for an observation the leg expects to be `true`. Adopters on slow hosts can set 3 or 4. Every wait polls and returns as soon as its event arrives, so a larger bound only costs time when something is really missing, and each timeout error names the bound it used. An observation read from the fixture (`observer: "fixture"`, such as a beacon or a served request) that the leg expects to be `true` is polled for up to 5000 ms (scaled) instead of read once. One that the leg expects to be `false` is still read once.
|
|
362
370
|
|
|
363
371
|
`refusalScale` (default 1) multiplies the bounds of waits for what the guarded leg expects to be absent, the beacon, request, title or navigation that the guard should block: settles that are not `required`, and `paused`, on the guarded leg. Such a wait runs to its full bound whenever the guard works, so `settleScale` leaves it alone. The trade-off: a larger `refusalScale` gives more sensitivity to a leak that arrives late on a slow host, because the observations after the wait see it, at the cost of that much more time per guarded row. Adopters should keep `refusalScale` at 1 or higher, never below 1; the runner refuses a `refusalScale` below 1 with `refusalScale must be a number of at least 1`.
|
|
364
372
|
|
|
@@ -393,7 +401,13 @@ observations: a missing or failing effect fails the control. Guarded liveness
|
|
|
393
401
|
requires the exact target URL in the fixture log or a target-origin `/live`
|
|
394
402
|
heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request and
|
|
395
403
|
an exact-URL block/error from the client or unguarded frame tree. An error for a
|
|
396
|
-
different URL is not evidence.
|
|
404
|
+
different URL is not evidence. Before judging, the runner waits, bounded at 5 s (scaled by `settleScale`), for the
|
|
405
|
+
pre-action beacon of every trigger step it ran, since a keepalive beacon has no ordering
|
|
406
|
+
guarantee. When one never arrives, an `eitherWhen` that needs it is `"incomplete"` and
|
|
407
|
+
excluded from that field's expectations, and the guarded leg is not live with
|
|
408
|
+
`not live: pre beacon not observed for <url>` in the row's `why`, never a false
|
|
409
|
+
mismatch. Once the beacon is in, a failing `eitherWhen` whose field mismatches, and a
|
|
410
|
+
failing `liveCheck`, are rechecked for up to 1.5 s (scaled) before they count. `blocked` accepts either `{ url }` for one exact URL
|
|
397
411
|
or `{ anyOf: [url, ...] }` for a nonempty list of exact URLs; each URL can be a
|
|
398
412
|
string or `{ hops, to, index? }`. Both forms allow `session` and `frame`. With `frame`,
|
|
399
413
|
the unguarded observer also reads the page's out-of-process iframe targets (matched by
|
package/catalog.json
CHANGED
|
@@ -738,6 +738,11 @@
|
|
|
738
738
|
"aboutScheme": "chrome",
|
|
739
739
|
"targetTypes": ["browser_ui"],
|
|
740
740
|
"inheriting": ["about:blank", "about:srcdoc"],
|
|
741
|
+
"errorPage": {
|
|
742
|
+
"url": "chrome-error://chromewebdata/",
|
|
743
|
+
"target": "previous",
|
|
744
|
+
"redact": "origin"
|
|
745
|
+
},
|
|
741
746
|
"unparseable": "protected",
|
|
742
747
|
"unparseableRefusal": "slicc: refused: slicc can't parse this URL, so it could be a protected page",
|
|
743
748
|
"refusal": "slicc: refused: target is on seven's origin"
|
package/extension-world.mjs
CHANGED
|
@@ -73,7 +73,17 @@ async function drivable(client, session, live) {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
async function debuggerWorld({
|
|
76
|
+
async function debuggerWorld({
|
|
77
|
+
client,
|
|
78
|
+
reader,
|
|
79
|
+
session,
|
|
80
|
+
observer,
|
|
81
|
+
world,
|
|
82
|
+
events,
|
|
83
|
+
live,
|
|
84
|
+
side,
|
|
85
|
+
pauseMs,
|
|
86
|
+
}) {
|
|
77
87
|
const debug = await outcome(client.send('Debugger.enable', {}, session));
|
|
78
88
|
const probe = outcome(
|
|
79
89
|
reader.send(
|
|
@@ -87,7 +97,7 @@ async function debuggerWorld({ client, reader, session, observer, world, events,
|
|
|
87
97
|
observer
|
|
88
98
|
)
|
|
89
99
|
);
|
|
90
|
-
const deadline = Date.now() +
|
|
100
|
+
const deadline = Date.now() + pauseMs;
|
|
91
101
|
while (
|
|
92
102
|
debug.ok &&
|
|
93
103
|
!events().some((event) => event.method === 'Debugger.paused') &&
|
|
@@ -123,9 +133,11 @@ async function debuggerWorld({ client, reader, session, observer, world, events,
|
|
|
123
133
|
event.method === 'Debugger.scriptParsed' && event.params.executionContextId === world.id
|
|
124
134
|
) || Boolean(paused);
|
|
125
135
|
await outcome(client.send('Debugger.disable', {}, session));
|
|
136
|
+
const incomplete = side === 'control' && debug.ok && !paused;
|
|
126
137
|
return {
|
|
127
138
|
exists: true,
|
|
128
|
-
reachable: reached,
|
|
139
|
+
reachable: incomplete ? 'incomplete' : reached,
|
|
140
|
+
...(incomplete ? { incomplete: `no Debugger.paused within ${pauseMs}ms` } : {}),
|
|
129
141
|
protected: refused(debug) || frameRefused || !exposed,
|
|
130
142
|
alive: await live(),
|
|
131
143
|
};
|
|
@@ -310,6 +322,7 @@ export async function extensionWorld({
|
|
|
310
322
|
side,
|
|
311
323
|
controls,
|
|
312
324
|
extensionId = DEFAULT_EXTENSION_WORLD_ID,
|
|
325
|
+
pauseMs = side === 'control' ? 5000 : 1500,
|
|
313
326
|
}) {
|
|
314
327
|
const attached = await reader.send('Target.attachToTarget', { targetId, flatten: true });
|
|
315
328
|
const observer = attached.sessionId;
|
|
@@ -370,7 +383,18 @@ export async function extensionWorld({
|
|
|
370
383
|
await outcome(client.send('Debugger.disable', {}, session));
|
|
371
384
|
return { exists: true, listed, leaks, defaultSeen, alive: enabled.ok && (await live()) };
|
|
372
385
|
}
|
|
373
|
-
const input = {
|
|
386
|
+
const input = {
|
|
387
|
+
client,
|
|
388
|
+
reader,
|
|
389
|
+
session,
|
|
390
|
+
observer,
|
|
391
|
+
world,
|
|
392
|
+
events,
|
|
393
|
+
live,
|
|
394
|
+
side,
|
|
395
|
+
controls,
|
|
396
|
+
pauseMs,
|
|
397
|
+
};
|
|
374
398
|
if (mode === 'drivable') return await drivable(client, session, live);
|
|
375
399
|
if (mode === 'debugger') return await debuggerWorld(input);
|
|
376
400
|
return await selectWorld(input);
|
package/guard-driver.mjs
CHANGED
|
@@ -144,10 +144,14 @@ export function judge(vector, side, observed) {
|
|
|
144
144
|
(observed?.[k] === 'not-supported' && vector.observe?.[k]?.requires?.length > 0) ||
|
|
145
145
|
(vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
|
|
146
146
|
);
|
|
147
|
+
const incomplete = Object.keys(want).filter(
|
|
148
|
+
(k) => !skipped.includes(k) && observed?.[`${k}.either`] === 'incomplete'
|
|
149
|
+
);
|
|
147
150
|
const mismatches = Object.entries(want)
|
|
148
151
|
.filter(
|
|
149
152
|
([k, v]) =>
|
|
150
153
|
!skipped.includes(k) &&
|
|
154
|
+
!incomplete.includes(k) &&
|
|
151
155
|
!matches(observed?.[`${k}.either`] === true ? 'either' : v, observed?.[k])
|
|
152
156
|
)
|
|
153
157
|
.map(([k, v]) => `${k}: got ${q(observed?.[k])}, want ${q(v)}`);
|
|
@@ -222,7 +226,7 @@ export function result(vector, run) {
|
|
|
222
226
|
why.push('refused on the protected target without a passing control');
|
|
223
227
|
} else why.push(...g.mismatches);
|
|
224
228
|
const live = run.live === true ? 'pass' : 'fail';
|
|
225
|
-
if (live === 'fail') why.push('not live');
|
|
229
|
+
if (live === 'fail') why.push(['not live', run.liveWhy].filter(Boolean).join(': '));
|
|
226
230
|
return {
|
|
227
231
|
...row,
|
|
228
232
|
control,
|
package/guard-runner.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
result,
|
|
14
14
|
steps,
|
|
15
15
|
} from './guard-driver.mjs';
|
|
16
|
+
import { protectedErrorPage } from './protected-error-page.mjs';
|
|
16
17
|
import { canonicalURL, unparseableURLs } from './unparseable-url.mjs';
|
|
17
18
|
|
|
18
19
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -113,8 +114,10 @@ class Execution {
|
|
|
113
114
|
this.contexts = [];
|
|
114
115
|
this.pending = [];
|
|
115
116
|
this.errors = [];
|
|
117
|
+
this.preSent = [];
|
|
116
118
|
this.notes = [];
|
|
117
119
|
this.answers = [];
|
|
120
|
+
this.internalSessions = new Set();
|
|
118
121
|
this.attachSettings = new Map();
|
|
119
122
|
this.childSteps = [];
|
|
120
123
|
this.deferred = [];
|
|
@@ -234,6 +237,17 @@ class Execution {
|
|
|
234
237
|
return read.ok && canary.untouched(state.value, read.result?.result?.value);
|
|
235
238
|
}
|
|
236
239
|
|
|
240
|
+
async awaitPre() {
|
|
241
|
+
const sent = [...new Set(this.preSent)];
|
|
242
|
+
if (!sent.length || !usesPre(this.vector)) return [];
|
|
243
|
+
const missing = async () => {
|
|
244
|
+
const seen = await Promise.all(sent.map((url) => this.attemptCheck({ pre: url })));
|
|
245
|
+
return sent.filter((_, index) => !seen[index]);
|
|
246
|
+
};
|
|
247
|
+
await this.poll(async () => (await missing()).length === 0, 5000);
|
|
248
|
+
return missing();
|
|
249
|
+
}
|
|
250
|
+
|
|
237
251
|
async attemptCheck(c) {
|
|
238
252
|
const logs = owned((await this.options.fixture.log()).slice(this.requestStart), this.vars.tag);
|
|
239
253
|
if (c.pre) {
|
|
@@ -450,7 +464,10 @@ class Execution {
|
|
|
450
464
|
for (const event of this.client.events.slice(this.eventStart)) {
|
|
451
465
|
if (this.processed.has(event)) continue;
|
|
452
466
|
this.processed.add(event);
|
|
453
|
-
if (
|
|
467
|
+
if (
|
|
468
|
+
(event.method === 'Fetch.requestPaused' || event.method === 'Fetch.authRequired') &&
|
|
469
|
+
!this.internalSessions.has(event.sessionId)
|
|
470
|
+
) {
|
|
454
471
|
this.pending.push(
|
|
455
472
|
this.answer(event).catch((error) => this.errors.push(String(error.message ?? error)))
|
|
456
473
|
);
|
|
@@ -604,9 +621,11 @@ class Execution {
|
|
|
604
621
|
this.errors.push(
|
|
605
622
|
`${step.refusal.name} failed without a refusal: ${String(out.error?.message ?? out.error)}`
|
|
606
623
|
);
|
|
607
|
-
if (
|
|
624
|
+
if (step.extensionWorld && out.result?.incomplete)
|
|
625
|
+
this.errors.push(`extensionWorld ${step.extensionWorld}: ${out.result.incomplete}`);
|
|
626
|
+
if ((step.extensionWorld || step.unparseable || step.errorPage) && !out.ok)
|
|
608
627
|
this.errors.push(
|
|
609
|
-
`${step.unparseable ? 'unparseable' : 'extensionWorld'}: ${out.error?.message ?? 'probe failed'}`
|
|
628
|
+
`${step.unparseable ? 'unparseable' : step.errorPage ? 'errorPage' : 'extensionWorld'}: ${out.error?.message ?? 'probe failed'}`
|
|
610
629
|
);
|
|
611
630
|
if (step.as && !out.result?.pending) this.calls[step.as] = out;
|
|
612
631
|
if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
|
|
@@ -657,6 +676,22 @@ class Execution {
|
|
|
657
676
|
await this.settle(step.settle);
|
|
658
677
|
return;
|
|
659
678
|
}
|
|
679
|
+
if (step.errorPage)
|
|
680
|
+
return outcome(
|
|
681
|
+
protectedErrorPage({
|
|
682
|
+
client: this.client,
|
|
683
|
+
reader: this.reader,
|
|
684
|
+
session,
|
|
685
|
+
targetId: this.pageTarget,
|
|
686
|
+
ordinary: this.vars.ordinary,
|
|
687
|
+
protectedOrigin: this.vars.seven,
|
|
688
|
+
tag: this.vars.tag,
|
|
689
|
+
internalSessions: this.internalSessions,
|
|
690
|
+
mode: step.errorPage,
|
|
691
|
+
side: this.side,
|
|
692
|
+
secret: step.secret,
|
|
693
|
+
})
|
|
694
|
+
);
|
|
660
695
|
if (step.unparseable)
|
|
661
696
|
return outcome(
|
|
662
697
|
unparseableURLs({
|
|
@@ -681,6 +716,7 @@ class Execution {
|
|
|
681
716
|
origin: this.vars.ordinary,
|
|
682
717
|
side: this.side,
|
|
683
718
|
controls: (this.clients.extensionWorldControls ??= {}),
|
|
719
|
+
pauseMs: this.bound(this.side === 'control' ? 5000 : 1500, this.side !== 'control'),
|
|
684
720
|
extensionId: this.options.extensionWorldId,
|
|
685
721
|
})
|
|
686
722
|
);
|
|
@@ -814,16 +850,19 @@ class Execution {
|
|
|
814
850
|
this.pageTarget = tab.targetId;
|
|
815
851
|
return { ok: true, result: tab };
|
|
816
852
|
}
|
|
853
|
+
if (pre && step.page !== 'framed') this.preSent.push(url);
|
|
817
854
|
if (step.page === 'navigate') {
|
|
818
855
|
if (pre) await this.evaluate(pre, session);
|
|
819
856
|
return this.navigate(this.client, session, url);
|
|
820
857
|
}
|
|
821
|
-
if (step.page === 'framed')
|
|
858
|
+
if (step.page === 'framed') {
|
|
859
|
+
if (phase === 'trigger') this.preSent.push(url);
|
|
822
860
|
return this.navigate(
|
|
823
861
|
this.client,
|
|
824
862
|
session,
|
|
825
863
|
`${step.from}/framed?tag=${encodeURIComponent(this.vars.tag)}&url=${encodeURIComponent(url)}&log=${encodeURIComponent(this.vars.log)}`
|
|
826
864
|
);
|
|
865
|
+
}
|
|
827
866
|
let expression = pageExpression(step, url);
|
|
828
867
|
if (pre) expression = `(() => { ${pre} return (${expression}); })()`;
|
|
829
868
|
const promise = this.evaluate(expression, session);
|
|
@@ -880,6 +919,8 @@ class Execution {
|
|
|
880
919
|
const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
|
|
881
920
|
name
|
|
882
921
|
];
|
|
922
|
+
if (check?.result && at(this.calls[check.result], check.path) === 'incomplete')
|
|
923
|
+
return 'incomplete';
|
|
883
924
|
if (want !== true || check?.observer !== 'fixture') return this.check(check);
|
|
884
925
|
let seen = false;
|
|
885
926
|
await this.poll(async () => {
|
|
@@ -1078,6 +1119,42 @@ class Execution {
|
|
|
1078
1119
|
}
|
|
1079
1120
|
}
|
|
1080
1121
|
|
|
1122
|
+
async observeAll(unseen) {
|
|
1123
|
+
const observed = { ...this.refused };
|
|
1124
|
+
const incomplete = [];
|
|
1125
|
+
for (const [name, observation] of Object.entries(this.vector.observe ?? {})) {
|
|
1126
|
+
if (!supported(observation.requires, this.options.capabilities)) {
|
|
1127
|
+
observed[name] = 'not-supported';
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
|
|
1131
|
+
observed[name] = await this.observe(name, observation.check);
|
|
1132
|
+
if (!observation.eitherWhen || this.side !== 'guarded') continue;
|
|
1133
|
+
const holds = await this.either(observation.eitherWhen, observed[name], name, unseen);
|
|
1134
|
+
observed[`${name}.either`] = holds;
|
|
1135
|
+
if (holds === 'incomplete') incomplete.push(name);
|
|
1136
|
+
}
|
|
1137
|
+
return { observed, incomplete };
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async either(check, value, name, unseen) {
|
|
1141
|
+
const holds = () => this.check(check);
|
|
1142
|
+
if (await holds()) return true;
|
|
1143
|
+
if (unseen.length) return usesPre(check) ? 'incomplete' : false;
|
|
1144
|
+
if (value === this.vector.expected?.[name]) return false;
|
|
1145
|
+
return this.poll(holds, 1500);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async liveness(unseen, incomplete) {
|
|
1149
|
+
if (this.side === 'control') return { live: true };
|
|
1150
|
+
const alive = () => this.check(this.vector.liveCheck);
|
|
1151
|
+
let live = await alive();
|
|
1152
|
+
if (!live && !unseen.length) live = await this.poll(alive, 1500);
|
|
1153
|
+
if (!unseen.length || (live && !incomplete.length)) return { live };
|
|
1154
|
+
const detail = incomplete.length ? ` (${incomplete.join(', ')} incomplete)` : '';
|
|
1155
|
+
return { live: false, liveWhy: `pre beacon not observed for ${unseen.join(', ')}${detail}` };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1081
1158
|
async run() {
|
|
1082
1159
|
this.requestStart = (await this.options.fixture.log()).length;
|
|
1083
1160
|
let pumping;
|
|
@@ -1093,25 +1170,16 @@ class Execution {
|
|
|
1093
1170
|
await this.execute(plan.setup, undefined, 'setup');
|
|
1094
1171
|
await this.execute(plan.trigger, undefined, 'trigger');
|
|
1095
1172
|
await sleep(100);
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
if (!supported(observation.requires, this.options.capabilities)) {
|
|
1099
|
-
observed[name] = 'not-supported';
|
|
1100
|
-
continue;
|
|
1101
|
-
}
|
|
1102
|
-
if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
|
|
1103
|
-
observed[name] = await this.observe(name, observation.check);
|
|
1104
|
-
if (observation.eitherWhen && this.side === 'guarded')
|
|
1105
|
-
observed[`${name}.either`] = await this.check(observation.eitherWhen);
|
|
1106
|
-
}
|
|
1173
|
+
const unseen = await this.awaitPre();
|
|
1174
|
+
const { observed, incomplete } = await this.observeAll(unseen);
|
|
1107
1175
|
this.options.debug?.({
|
|
1108
1176
|
events: this.client.events.slice(this.eventStart),
|
|
1109
1177
|
calls: this.calls,
|
|
1110
1178
|
requests: await this.options.fixture.log(),
|
|
1111
1179
|
side: this.side,
|
|
1112
1180
|
});
|
|
1113
|
-
const live
|
|
1114
|
-
return { observed, live, errors: this.errors, notes: this.notes };
|
|
1181
|
+
const { live, liveWhy } = await this.liveness(unseen, incomplete);
|
|
1182
|
+
return { observed, live, liveWhy, errors: this.errors, notes: this.notes };
|
|
1115
1183
|
} finally {
|
|
1116
1184
|
this.stopped = true;
|
|
1117
1185
|
await pumping;
|
|
@@ -1134,6 +1202,12 @@ class Execution {
|
|
|
1134
1202
|
}
|
|
1135
1203
|
}
|
|
1136
1204
|
|
|
1205
|
+
const usesPre = (value) =>
|
|
1206
|
+
Boolean(value && typeof value === 'object') &&
|
|
1207
|
+
(Object.hasOwn(value, 'pre') ||
|
|
1208
|
+
Object.hasOwn(value, 'history') ||
|
|
1209
|
+
Object.values(value).some(usesPre));
|
|
1210
|
+
|
|
1137
1211
|
const observerRequired = (check) => {
|
|
1138
1212
|
if (check?.observer === 'unguarded') return true;
|
|
1139
1213
|
if (check?.all) return check.all.some(observerRequired);
|
|
@@ -1426,6 +1500,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
|
1426
1500
|
control: legs.control.observed,
|
|
1427
1501
|
guarded: legs.guarded.observed,
|
|
1428
1502
|
live: legs.control.live && legs.guarded.live,
|
|
1503
|
+
liveWhy: legs.guarded.liveWhy,
|
|
1429
1504
|
}),
|
|
1430
1505
|
transportVariant: base.transportVariant,
|
|
1431
1506
|
observations: { control: legs.control.observed, guarded: legs.guarded.observed },
|