@ai-ecoverse/slicc-browsers 1.9.1 → 1.10.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 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) }`, each a boolean. It needs only the WHATWG `URL`, so it runs in Node and in a browser.
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.
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`), and `refusal`, the text of a 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`), `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`.
@@ -106,8 +106,8 @@ Every feature builds for `wasm32-wasip1`.
106
106
 
107
107
  Browser control must never navigate to, open or attach to a target on seven's own origin, or read or write its cookies or storage: not slicc-cli's `cdp` relay, not slicc-cdp's `playwright-cli`, not slicc-agent. `protectedOrigins` in `catalog.json` says what is protected, and every implementation runs the vectors in `protected.json`.
108
108
 
109
- **URLs.** A URL is parsed the way the WHATWG URL Standard (and so the browser) parses it, so `https:\\seven.sliccy.ai`, a tab or newline inside the URL, `%2E`, `。` and full-width letters all reach seven. In Go and Rust that takes a WHATWG parser, not `net/url`. A URL that doesn't parse isn't protected; the browser doesn't load it either. Then:
110
- 1. While its scheme is one of `innerSchemes`, the rest of the URL after `<scheme>:` takes its place, at most 8 times: `blob:https://seven.sliccy.ai/<uuid>` is `https://seven.sliccy.ai/<uuid>`.
109
+ **URLs.** A URL is parsed the way the WHATWG URL Standard (and so the browser) parses it, so `https:\\seven.sliccy.ai`, a tab or newline inside the URL, `%2E`, `。` and full-width letters all reach seven. In Go and Rust that takes a WHATWG parser, not `net/url`. A URL the guard’s WHATWG parser cannot parse is **protected** wherever the guard judges a URL the browser will load or has loaded: `unparseable: "protected"` in both `catalog.json.protectedOrigins` and `protected.json`. Chrome’s parser is not interchangeable with the guard’s parser; in particular it can load `http://exa%20mple.sliccy.ai/` even though the WHATWG parser rejects the decoded space. Then:
110
+ 1. While its scheme is one of `innerSchemes`, the rest of the URL after `<scheme>:` takes its place, at most 8 times: `blob:https://seven.sliccy.ai/<uuid>` is `https://seven.sliccy.ai/<uuid>`. A failing inner parse, or a URL still wrapped after the eighth unwrap, is unparseable and protected.
111
111
  2. A scheme in `anySchemes` is protected whatever follows: a DevTools front end (`devtools://`) can be inspecting seven.
112
112
  3. A scheme in `protectedSchemes` is protected like seven, whatever follows, except the `scheme://host` pairs in `schemeExceptions` (the new tab pages), on any path. Match the parsed scheme and exact host, never a raw URL prefix: `chrome-untrusted://new-tab-page/one-google-bar?paramsencoded=` is excepted, but `chrome-untrusted://new-tab-page.evil/` and `chrome-untrusted://other/` remain protected. Chrome's new tab embeds the excepted untrusted host, and `chrome://new-tab-page/` can commit as itself when opened directly:
113
113
  - Chrome's privileged UI (`chrome://settings`, `chrome://password-manager`, `chrome-untrusted://…`, `devtools://`) and the same UI in the catalog's other Chromium browsers (`edge://settings/passwords`, `edge://wallet`, `brave://`, `opera://`, `vivaldi://`, `arc://`), since a client attaches to whatever browser answers;
@@ -149,6 +149,12 @@ A guard must refuse navigation, creation and attach on protected schemes, includ
149
149
 
150
150
  **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
151
 
152
+ **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.
153
+
154
+ The empty string is exempt (`Target.createTarget`’s blank page, an empty history `userTypedURL`, or no override); opaque-origin spellings `null` and `://` are exempt too. `about:blank` still inherits provenance, so those exemptions never erase an already protected opener or target. A failing `about:` alias is judged as the original `about:` URL, not as a parse failure. `unparseableRefusal` is exactly `slicc: refused: slicc can't parse this URL, so it could be a protected page`; a URL the guard can parse and finds protected uses its ordinary refusal instead. IDNA validation differs between WHATWG implementations: Node accepts some punycode spellings that the Go parser rejects; the canonical `.sliccy.ai` spellings remain protected by the domain rule either way.
155
+
156
+ The deliberate cost includes otherwise ordinary `http://<U+200D>.example/` and `https://exa mple.com/`: an unparseable URL is not an ordinary-origin allowlist exception. The vectors include space/percent-space, U+200D/punycode and invalid-punycode `.sliccy.ai` forms; malformed hosts, ports and IPv6; failed wrappers and unwrap-limit exhaustion; and the explicit exemptions and read-only storage-key controls.
157
+
152
158
  **The vault.** `vault.sliccy.ai` is in `hosts` by name, and vault branch previews (`vault-preview.sliccy.ai`, `vault-*.sliccy.ai`) are covered by the `sliccy.ai` domain rule. `sliccy.ai` already covers them all, but vault must stay protected even if `domains` ever changes: the vault must never be a CDP target for agent tools. The one exception is slicc-cli's own pairing attach for the vault, which is the CLI's own connection and not browser control handed to the agent.
153
159
 
154
160
  **Targets.** A target whose `TargetInfo.type` is one of `targetTypes` (`browser_ui`, Chrome's own UI such as `chrome://omnibox-popup.top-chrome/`) is always protected, whatever its URL. Otherwise a target's effective origin is its URL's (`TargetInfo.url`), except when the URL inherits the origin of whoever made the document: the empty URL, and `inheriting` with any query or fragment. Such a target is protected when it was protected before this URL (a seven tab that went to `about:blank` keeps seven's origin), or when its opener (`openerId`, else `openerFrameId`) is protected or unknown, for an opener closed before the program saw it. The URL, `openerFrameId` and `browserContextId` alone can't tell: a blank popup that seven opens has seven's origin and the URL `about:blank`. A program that holds a session to the target also has the document's real origin: the main frame's `securityOrigin` in `Page.frameNavigated`, and the default context's `origin` in `Runtime.executionContextCreated`. When one of those is protected, the target is protected too, until a URL with an origin of its own replaces it.
@@ -161,7 +167,7 @@ A same-site frame shares its parent's process and has no target of its own. Ever
161
167
 
162
168
  **Refusals** carry the text in `refusal`, `slicc: refused: target is on seven's origin`.
163
169
 
164
- **`protected.json`** (schema `schema/protected.schema.json`): `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.
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.
165
171
 
166
172
  ## Guard vectors
167
173
 
@@ -172,9 +178,11 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
172
178
  - `C5.continue-request` and `C5.continue-response`, added next to `C5.document`;
173
179
  - 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.
174
180
 
175
- 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 guarded command must fail with the `slicc: refused:` prefix, never an ordinary browser error. `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.
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.
176
182
 
177
- The integration launcher loads shared-web 1.25.1’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.1 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.
183
+ 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
+
185
+ `C8.unparseable` probes both raw and canonical space/U+200D/invalid-punycode URLs through creation and navigation. Every guarded destination needs a matched refusal. Its unguarded controls record `document` or `native-rejection` outcomes, including Chrome builds that reject raw U+200D instead of committing it. Both raw and encoded-space forms must commit real fixture documents, because that is the proven cookie leak. An independent observer loads the encoded-space document and confirms it shares a uniquely seeded `.sliccy.ai` cookie; the guarded client must neither list nor attach to it, read its cookie, query that cookie by URL, nor cause a cookie-bearing destination request. The guard does not erase the observer’s private browser state. The row requires `browserSession`, uses the default context, and cleans up its own targets and cookie. The HTTP fixture accepts Chrome’s encoded-space Host header without reparsing it as a WHATWG origin.
178
186
 
179
187
  `C8.chrome-search` requests `chrome-search://most-visited/` through `Page.navigate` and requires an explicit guard refusal. Its unguarded control requires a CDP response, not a successful document commit: Chrome can answer with `errorText` when that internal data source is unavailable outside an Instant process. Browser-side load failure is not a guard refusal, and merely hiding the target must fail this row.
180
188
 
@@ -237,7 +245,9 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
237
245
  - `/plant`: sets the canary;
238
246
  - `/beacon`, and `/log?n=`, which returns `{ hits, beacons }`.
239
247
 
240
- `{ "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. 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 and unique context ids from the control are also replayed alongside fresh observer-discovered selectors. Every guarded replay needs an explicit refusal, including a stale handle (`not exposed`), rather than Chrome’s invalid-id error. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
248
+ `{ "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
+
250
+ `{ "unparseable": true, "as": "unparseable", "requires": ["browserSession"] }` 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.
241
251
 
242
252
  **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`.
243
253
 
package/catalog.json CHANGED
@@ -738,6 +738,8 @@
738
738
  "aboutScheme": "chrome",
739
739
  "targetTypes": ["browser_ui"],
740
740
  "inheriting": ["about:blank", "about:srcdoc"],
741
+ "unparseable": "protected",
742
+ "unparseableRefusal": "slicc: refused: slicc can't parse this URL, so it could be a protected page",
741
743
  "refusal": "slicc: refused: target is on seven's origin"
742
744
  },
743
745
  "profiles": {
@@ -1,12 +1,15 @@
1
1
  import { outcome } from './guard-driver.mjs';
2
2
 
3
+ export const DEFAULT_EXTENSION_WORLD_ID = 'ankmbodmgimjemnojmeddfcdlbocolob';
4
+
3
5
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
6
  const extension = (context) =>
5
7
  context?.origin?.startsWith('chrome-extension://') && !context.auxData?.isDefault;
6
- const marker = 'typeof chrome.runtime?.id === "string"';
8
+ const marker =
9
+ 'typeof root !== "undefined" && root === document.documentElement && root.dataset.sliccHarnessExtension === chrome.runtime.id';
7
10
  const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
8
11
 
9
- async function discover(reader, session) {
12
+ async function discover(reader, session, extensionId) {
10
13
  const start = reader.events.length;
11
14
  await reader.send('Runtime.enable', {}, session);
12
15
  const deadline = Date.now() + 5000;
@@ -17,12 +20,13 @@ async function discover(reader, session) {
17
20
  (event) =>
18
21
  event.sessionId === session &&
19
22
  event.method === 'Runtime.executionContextCreated' &&
20
- extension(event.params.context)
23
+ extension(event.params.context) &&
24
+ event.params.context.origin === `chrome-extension://${extensionId}`
21
25
  )?.params.context;
22
26
  if (world) return world;
23
27
  await pause(25);
24
28
  } while (Date.now() < deadline);
25
- throw new Error('extensionWorld: no foreign content-script context');
29
+ throw new Error(`extensionWorld: no content-script context for ${extensionId}`);
26
30
  }
27
31
 
28
32
  async function drivable(client, session, live) {
@@ -174,7 +178,74 @@ function commands(world, objectId, promiseObjectId, nodeId) {
174
178
  ];
175
179
  }
176
180
 
177
- async function selectWorld({ client, session, world, side, controls, live }) {
181
+ async function guessWorld({ client, session, world, handles, events }) {
182
+ const exposed = new Set(
183
+ events()
184
+ .filter((event) => event.method === 'Runtime.executionContextCreated')
185
+ .map((event) => event.params.context.id)
186
+ );
187
+ const ids = new Set([
188
+ ...Array.from({ length: 32 }, (_, index) => index + 1),
189
+ handles.world.id,
190
+ world.id,
191
+ ...exposed,
192
+ ]);
193
+ const numeric = [];
194
+ for (const id of ids) {
195
+ const probes = [
196
+ ['Runtime.evaluate', { contextId: id, expression: marker, returnByValue: true }],
197
+ [
198
+ 'Runtime.callFunctionOn',
199
+ {
200
+ executionContextId: id,
201
+ functionDeclaration: `function(){return ${marker}}`,
202
+ returnByValue: true,
203
+ },
204
+ ],
205
+ ];
206
+ for (const [method, params] of probes) {
207
+ const out = await outcome(client.send(method, params, session));
208
+ numeric.push({
209
+ id,
210
+ method,
211
+ exposed: exposed.has(id),
212
+ refused: refused(out),
213
+ ok: out.ok && !out.result.exceptionDetails && typeof out.result.result?.value === 'boolean',
214
+ reached: out.ok && out.result.result?.value === true,
215
+ });
216
+ }
217
+ }
218
+ const unique = [];
219
+ for (const uniqueContextId of new Set([handles.world.uniqueId, world.uniqueId])) {
220
+ const out = await outcome(
221
+ client.send(
222
+ 'Runtime.evaluate',
223
+ { uniqueContextId, expression: marker, returnByValue: true },
224
+ session
225
+ )
226
+ );
227
+ unique.push({
228
+ uniqueContextId,
229
+ refused: refused(out),
230
+ reached: out.ok && out.result.result?.value === true,
231
+ });
232
+ }
233
+ return {
234
+ guessesRefused:
235
+ numeric.filter((guess) => !guess.exposed).every((guess) => guess.refused) &&
236
+ unique.every((guess) => guess.refused),
237
+ guessesReached: [...numeric, ...unique].some((guess) => guess.reached),
238
+ exposedContextsWork: numeric
239
+ .filter((guess) => guess.exposed)
240
+ .every((guess) => guess.ok && !guess.refused),
241
+ numericGuesses: numeric,
242
+ uniqueGuesses: unique,
243
+ controlNumericId: handles.world.id,
244
+ hiddenNumericId: world.id,
245
+ };
246
+ }
247
+
248
+ async function selectWorld({ client, session, world, side, controls, live, events }) {
178
249
  let handles = controls.select;
179
250
  if (side === 'control') {
180
251
  const object = await client.send(
@@ -220,19 +291,11 @@ async function selectWorld({ client, session, world, side, controls, live }) {
220
291
  : true);
221
292
  result[`${name}Error`] = out.error?.message ?? out.result?.exceptionDetails?.text;
222
293
  }
223
- const guesses = commands(
224
- handles.world,
225
- handles.objectId,
226
- handles.promiseObjectId,
227
- dom.result?.root.nodeId ?? 1
228
- ).slice(0, 3);
229
- result.guessesRefused = true;
230
- result.guessesReached = true;
231
- for (const [, method, params] of guesses) {
232
- const out = await outcome(client.send(method, params, session));
233
- result.guessesRefused &&= refused(out);
234
- result.guessesReached &&= out.ok && out.result.result?.value === true;
235
- }
294
+ Object.assign(result, await guessWorld({ client, session, world, handles, events }));
295
+ const pageMarker = await outcome(
296
+ client.send('Runtime.evaluate', { expression: marker, returnByValue: true }, session)
297
+ );
298
+ result.pageMarkerAbsent = pageMarker.ok && pageMarker.result.result?.value === false;
236
299
  result.alive = await live();
237
300
  return result;
238
301
  }
@@ -246,12 +309,13 @@ export async function extensionWorld({
246
309
  origin,
247
310
  side,
248
311
  controls,
312
+ extensionId = DEFAULT_EXTENSION_WORLD_ID,
249
313
  }) {
250
314
  const attached = await reader.send('Target.attachToTarget', { targetId, flatten: true });
251
315
  const observer = attached.sessionId;
252
316
  const seen = client.events.length;
253
317
  try {
254
- const world = await discover(reader, observer);
318
+ const world = await discover(reader, observer, extensionId);
255
319
  const enabled = await outcome(client.send('Runtime.enable', {}, session));
256
320
  await pause(100);
257
321
  const events = () => client.events.slice(seen).filter((event) => event.sessionId === session);
@@ -76,11 +76,28 @@ function route(req, url, send, html) {
76
76
  return send(404, 'missing', 'text/plain');
77
77
  }
78
78
 
79
+ function requestURL(req, scheme) {
80
+ const origin = `${scheme}://${req.headers.host}`;
81
+ try {
82
+ return new URL(req.url, origin);
83
+ } catch {
84
+ const path = new URL(req.url, `${scheme}://fixture.invalid`);
85
+ return {
86
+ pathname: path.pathname,
87
+ searchParams: path.searchParams,
88
+ href: `${origin}${path.pathname}${path.search}${path.hash}`,
89
+ origin,
90
+ host: req.headers.host,
91
+ hostname: req.headers.host.replace(/:\d+$/, ''),
92
+ };
93
+ }
94
+ }
95
+
79
96
  export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
80
97
  let entries = [];
81
98
  const scheme = tls ? 'https' : 'http';
82
99
  const handler = (req, res) => {
83
- const url = new URL(req.url, `${scheme}://${req.headers.host}`);
100
+ const url = requestURL(req, scheme);
84
101
  entries.push({
85
102
  url: url.href,
86
103
  method: req.method,
package/guard-runner.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  result,
12
12
  steps,
13
13
  } from './guard-driver.mjs';
14
+ import { unparseableURLs } from './unparseable-url.mjs';
14
15
 
15
16
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
17
  const namedResultMs = 10000;
@@ -600,8 +601,10 @@ class Execution {
600
601
  this.errors.push(
601
602
  `${step.refusal.name} failed without a refusal: ${String(out.error?.message ?? out.error)}`
602
603
  );
603
- if (step.extensionWorld && !out.ok)
604
- this.errors.push(`extensionWorld: ${out.error?.message ?? 'probe failed'}`);
604
+ if ((step.extensionWorld || step.unparseable) && !out.ok)
605
+ this.errors.push(
606
+ `${step.unparseable ? 'unparseable' : 'extensionWorld'}: ${out.error?.message ?? 'probe failed'}`
607
+ );
605
608
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
606
609
  if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
607
610
  }
@@ -651,6 +654,18 @@ class Execution {
651
654
  await this.settle(step.settle);
652
655
  return;
653
656
  }
657
+ if (step.unparseable)
658
+ return outcome(
659
+ unparseableURLs({
660
+ client: this.client,
661
+ reader: this.reader,
662
+ session,
663
+ targetId: this.pageTarget,
664
+ origin: this.vars.ordinary,
665
+ tag: this.vars.tag,
666
+ fixture: this.options.fixture,
667
+ })
668
+ );
654
669
  if (step.extensionWorld)
655
670
  return outcome(
656
671
  extensionWorld({
@@ -662,6 +677,7 @@ class Execution {
662
677
  origin: this.vars.ordinary,
663
678
  side: this.side,
664
679
  controls: (this.clients.extensionWorldControls ??= {}),
680
+ extensionId: this.options.extensionWorldId,
665
681
  })
666
682
  );
667
683
  if (step.guess) return this.guess(step.guess, session);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.9.1",
3
+ "setVersion": "1.10.0",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
@@ -118,6 +118,195 @@
118
118
  "browserUi": "the id of one of Chrome's own browser_ui targets, which the runner looks up with its own CDP"
119
119
  },
120
120
  "vectors": [
121
+ {
122
+ "id": "C8.unparseable",
123
+ "family": "C",
124
+ "kind": "cdp",
125
+ "scope": "any",
126
+ "defaultContext": true,
127
+ "setup": [
128
+ {
129
+ "page": "tab",
130
+ "url": "{ordinary}/doc?n={tag}-ordinary"
131
+ }
132
+ ],
133
+ "trigger": [
134
+ {
135
+ "unparseable": true,
136
+ "as": "unparseable",
137
+ "requires": ["browserSession"]
138
+ }
139
+ ],
140
+ "targets": ["ordinary"],
141
+ "requires": ["browserSession"],
142
+ "observe": {
143
+ "live": {
144
+ "means": "unparseable URL protection live",
145
+ "check": {
146
+ "result": "unparseable",
147
+ "path": "result.live",
148
+ "equals": true,
149
+ "observer": "client"
150
+ }
151
+ },
152
+ "createRefused": {
153
+ "means": "unparseable URL protection createRefused",
154
+ "check": {
155
+ "result": "unparseable",
156
+ "path": "result.createRefused",
157
+ "equals": true,
158
+ "observer": "client"
159
+ }
160
+ },
161
+ "navigateRefused": {
162
+ "means": "unparseable URL protection navigateRefused",
163
+ "check": {
164
+ "result": "unparseable",
165
+ "path": "result.navigateRefused",
166
+ "equals": true,
167
+ "observer": "client"
168
+ }
169
+ },
170
+ "listed": {
171
+ "means": "unparseable URL protection listed",
172
+ "check": {
173
+ "result": "unparseable",
174
+ "path": "result.listed",
175
+ "equals": true,
176
+ "observer": "client"
177
+ }
178
+ },
179
+ "attached": {
180
+ "means": "unparseable URL protection attached",
181
+ "check": {
182
+ "result": "unparseable",
183
+ "path": "result.attached",
184
+ "equals": true,
185
+ "observer": "client"
186
+ }
187
+ },
188
+ "attachRefused": {
189
+ "means": "unparseable URL protection attachRefused",
190
+ "check": {
191
+ "result": "unparseable",
192
+ "path": "result.attachRefused",
193
+ "equals": true,
194
+ "observer": "client"
195
+ }
196
+ },
197
+ "readCookie": {
198
+ "means": "unparseable URL protection readCookie",
199
+ "check": {
200
+ "result": "unparseable",
201
+ "path": "result.readCookie",
202
+ "equals": true,
203
+ "observer": "client"
204
+ }
205
+ },
206
+ "cookieRefused": {
207
+ "means": "unparseable URL protection cookieRefused",
208
+ "check": {
209
+ "result": "unparseable",
210
+ "path": "result.cookieRefused",
211
+ "equals": true,
212
+ "observer": "client"
213
+ }
214
+ },
215
+ "cookieDelivered": {
216
+ "means": "unparseable URL protection cookieDelivered",
217
+ "check": {
218
+ "result": "unparseable",
219
+ "path": "result.cookieDelivered",
220
+ "equals": true,
221
+ "observer": "client"
222
+ }
223
+ },
224
+ "cookieRequest": {
225
+ "means": "unparseable URL protection cookieRequest",
226
+ "check": {
227
+ "result": "unparseable",
228
+ "path": "result.cookieRequest",
229
+ "equals": true,
230
+ "observer": "client"
231
+ }
232
+ },
233
+ "createAnswered": {
234
+ "means": "Chrome answers the raw and canonical URL probes without a guard refusal, including native invalid-URL responses",
235
+ "check": {
236
+ "result": "unparseable",
237
+ "path": "result.createAnswered",
238
+ "equals": true,
239
+ "observer": "client"
240
+ }
241
+ },
242
+ "navigateAnswered": {
243
+ "means": "Chrome answers the raw and canonical URL probes without a guard refusal, including native invalid-URL responses",
244
+ "check": {
245
+ "result": "unparseable",
246
+ "path": "result.navigateAnswered",
247
+ "equals": true,
248
+ "observer": "client"
249
+ }
250
+ },
251
+ "spaceCreated": {
252
+ "means": "the encoded-space fixture URL commits a real document unguarded",
253
+ "check": {
254
+ "result": "unparseable",
255
+ "path": "result.spaceCreated",
256
+ "equals": true,
257
+ "observer": "client"
258
+ }
259
+ },
260
+ "spaceNavigated": {
261
+ "means": "the encoded-space fixture URL commits a real document unguarded",
262
+ "check": {
263
+ "result": "unparseable",
264
+ "path": "result.spaceNavigated",
265
+ "equals": true,
266
+ "observer": "client"
267
+ }
268
+ }
269
+ },
270
+ "live": "the independent observer loads a real Chrome document on the encoded-space host with a seeded seven cookie",
271
+ "liveCheck": {
272
+ "result": "unparseable",
273
+ "path": "result.live",
274
+ "equals": true,
275
+ "observer": "client"
276
+ },
277
+ "control": {
278
+ "origin": "ordinary",
279
+ "via": "unguarded",
280
+ "expected": {
281
+ "live": true,
282
+ "createRefused": false,
283
+ "navigateRefused": false,
284
+ "listed": true,
285
+ "attached": true,
286
+ "attachRefused": false,
287
+ "readCookie": true,
288
+ "cookieRefused": false,
289
+ "cookieDelivered": true,
290
+ "cookieRequest": true,
291
+ "createAnswered": true,
292
+ "navigateAnswered": true,
293
+ "spaceCreated": true,
294
+ "spaceNavigated": true
295
+ }
296
+ },
297
+ "expected": {
298
+ "live": true,
299
+ "createRefused": true,
300
+ "navigateRefused": true,
301
+ "listed": false,
302
+ "attached": false,
303
+ "attachRefused": true,
304
+ "readCookie": false,
305
+ "cookieRefused": true,
306
+ "cookieDelivered": false,
307
+ "cookieRequest": false
308
+ }
309
+ },
121
310
  {
122
311
  "id": "C9.world-hidden",
123
312
  "family": "C",
@@ -415,7 +604,7 @@
415
604
  }
416
605
  },
417
606
  "guessesRefused": {
418
- "means": "numeric and unique context ids guessed from the successful control leg",
607
+ "means": "numeric ids 1..32 not exposed to this client, the same-tab hidden numeric id and extension unique-id guesses receive matched refusals; exposed numeric ids need not be refused",
419
608
  "check": {
420
609
  "result": "world",
421
610
  "path": "result.guessesRefused",
@@ -424,13 +613,31 @@
424
613
  }
425
614
  },
426
615
  "guessesReached": {
427
- "means": "numeric and unique context ids guessed from the successful control leg",
616
+ "means": "numeric sweeps and extension unique-id guesses reach the content-script-only marker on the control, never on the guarded leg",
428
617
  "check": {
429
618
  "result": "world",
430
619
  "path": "result.guessesReached",
431
620
  "equals": true,
432
621
  "observer": "client"
433
622
  }
623
+ },
624
+ "exposedContextsWork": {
625
+ "means": "every numeric context id exposed to this client remains usable",
626
+ "check": {
627
+ "result": "world",
628
+ "path": "result.exposedContextsWork",
629
+ "equals": true,
630
+ "observer": "client"
631
+ }
632
+ },
633
+ "pageMarkerAbsent": {
634
+ "means": "the fixture content-script marker is absent from the page default world",
635
+ "check": {
636
+ "result": "world",
637
+ "path": "result.pageMarkerAbsent",
638
+ "equals": true,
639
+ "observer": "client"
640
+ }
434
641
  }
435
642
  },
436
643
  "live": "the observer confirms a foreign extension isolated world exists on an ordinary page",
@@ -465,7 +672,9 @@
465
672
  "resolveNodeRefused": false,
466
673
  "resolveNodeReached": true,
467
674
  "guessesRefused": false,
468
- "guessesReached": true
675
+ "guessesReached": true,
676
+ "exposedContextsWork": true,
677
+ "pageMarkerAbsent": true
469
678
  }
470
679
  },
471
680
  "expected": {
@@ -480,7 +689,10 @@
480
689
  "propertiesRefused": true,
481
690
  "bindingRefused": true,
482
691
  "resolveNodeRefused": true,
483
- "guessesRefused": true
692
+ "guessesRefused": true,
693
+ "exposedContextsWork": true,
694
+ "pageMarkerAbsent": true,
695
+ "guessesReached": false
484
696
  }
485
697
  },
486
698
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -51,7 +51,8 @@
51
51
  "schema",
52
52
  "guard-runner.mjs",
53
53
  "fixture-server.mjs",
54
- "extension-world.mjs"
54
+ "extension-world.mjs",
55
+ "unparseable-url.mjs"
55
56
  ],
56
57
  "scripts": {
57
58
  "prepare": "lefthook install",
@@ -66,14 +67,14 @@
66
67
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
67
68
  "release": "semantic-release",
68
69
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
69
- "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-scales.test.mjs",
70
+ "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-scales.test.mjs",
70
71
  "test:guard-sweep": "node scripts/test-guards.mjs",
71
72
  "test:gates": "node --test test/integration/gates.test.mjs",
72
73
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
73
74
  "test:unit:guard": "node --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-lines=100 test/unit/guard-*.test.mjs"
74
75
  },
75
76
  "devDependencies": {
76
- "@ai-ecoverse/slicc-shared-web": "1.25.1",
77
+ "@ai-ecoverse/slicc-shared-web": "1.25.2",
77
78
  "@biomejs/biome": "2.5.15",
78
79
  "ajv": "8.20.0",
79
80
  "ajv-formats": "3.0.1",
package/protected.json CHANGED
@@ -1,7 +1,89 @@
1
1
  {
2
2
  "schema": 1,
3
+ "unparseable": "protected",
4
+ "unparseableRefusal": "slicc: refused: slicc can't parse this URL, so it could be a protected page",
3
5
  "origins": ["http://127.0.0.1:5710", "https://seven.example.test"],
4
6
  "urls": [
7
+ {
8
+ "url": "http://exa mple.sliccy.ai/",
9
+ "protected": true
10
+ },
11
+ {
12
+ "url": "http://exa%20mple.sliccy.ai/",
13
+ "protected": true
14
+ },
15
+ {
16
+ "url": "http://‍.sliccy.ai/",
17
+ "protected": true
18
+ },
19
+ {
20
+ "url": "http://xn--1ug.sliccy.ai/",
21
+ "protected": true
22
+ },
23
+ {
24
+ "url": "http://xn--a.sliccy.ai/",
25
+ "protected": true
26
+ },
27
+ {
28
+ "url": "http://[::1",
29
+ "protected": true
30
+ },
31
+ {
32
+ "url": "http://",
33
+ "protected": true
34
+ },
35
+ {
36
+ "url": "http://a:99999/",
37
+ "protected": true
38
+ },
39
+ {
40
+ "url": "http://1.2.3.4.5/",
41
+ "protected": true
42
+ },
43
+ {
44
+ "url": "http://%zz/",
45
+ "protected": true
46
+ },
47
+ {
48
+ "url": "http://[1::2::3]/",
49
+ "protected": true
50
+ },
51
+ {
52
+ "url": "http://a^b/",
53
+ "protected": true
54
+ },
55
+ {
56
+ "url": "blob:http://[::1",
57
+ "protected": true
58
+ },
59
+ {
60
+ "url": "http://‍.example/",
61
+ "protected": true
62
+ },
63
+ {
64
+ "url": "https://exa mple.com/",
65
+ "protected": true
66
+ },
67
+ {
68
+ "url": "https://exa%20mple.com/",
69
+ "protected": true
70
+ },
71
+ {
72
+ "url": "view-source:view-source:view-source:view-source:view-source:view-source:view-source:view-source:view-source:https://example.com/",
73
+ "protected": true
74
+ },
75
+ {
76
+ "url": "null",
77
+ "protected": false
78
+ },
79
+ {
80
+ "url": "://",
81
+ "protected": false
82
+ },
83
+ {
84
+ "url": "view-source:view-source:view-source:view-source:view-source:view-source:view-source:view-source:https://example.com/",
85
+ "protected": false
86
+ },
5
87
  {
6
88
  "url": "https://seven.sliccy.ai/",
7
89
  "protected": true
@@ -264,7 +346,7 @@
264
346
  },
265
347
  {
266
348
  "url": "blob:null/0b9e0f6c",
267
- "protected": false
349
+ "protected": true
268
350
  },
269
351
  {
270
352
  "url": "blob:https://example.com/0b9e0f6c",
@@ -300,15 +382,15 @@
300
382
  },
301
383
  {
302
384
  "url": "https://",
303
- "protected": false
385
+ "protected": true
304
386
  },
305
387
  {
306
388
  "url": "seven.sliccy.ai",
307
- "protected": false
389
+ "protected": true
308
390
  },
309
391
  {
310
392
  "url": "not a url",
311
- "protected": false
393
+ "protected": true
312
394
  },
313
395
  {
314
396
  "url": "https://vault-preview.sliccy.ai/",
@@ -656,6 +738,74 @@
656
738
  }
657
739
  ],
658
740
  "targets": [
741
+ {
742
+ "url": "http://exa mple.sliccy.ai/",
743
+ "protected": true
744
+ },
745
+ {
746
+ "url": "http://exa%20mple.sliccy.ai/",
747
+ "protected": true
748
+ },
749
+ {
750
+ "url": "http://‍.sliccy.ai/",
751
+ "protected": true
752
+ },
753
+ {
754
+ "url": "http://xn--1ug.sliccy.ai/",
755
+ "protected": true
756
+ },
757
+ {
758
+ "url": "http://xn--a.sliccy.ai/",
759
+ "protected": true
760
+ },
761
+ {
762
+ "url": "http://[::1",
763
+ "protected": true
764
+ },
765
+ {
766
+ "url": "http://",
767
+ "protected": true
768
+ },
769
+ {
770
+ "url": "http://a:99999/",
771
+ "protected": true
772
+ },
773
+ {
774
+ "url": "http://1.2.3.4.5/",
775
+ "protected": true
776
+ },
777
+ {
778
+ "url": "http://%zz/",
779
+ "protected": true
780
+ },
781
+ {
782
+ "url": "http://[1::2::3]/",
783
+ "protected": true
784
+ },
785
+ {
786
+ "url": "http://a^b/",
787
+ "protected": true
788
+ },
789
+ {
790
+ "url": "blob:http://[::1",
791
+ "protected": true
792
+ },
793
+ {
794
+ "url": "http://‍.example/",
795
+ "protected": true
796
+ },
797
+ {
798
+ "url": "https://exa mple.com/",
799
+ "protected": true
800
+ },
801
+ {
802
+ "url": "https://exa%20mple.com/",
803
+ "protected": true
804
+ },
805
+ {
806
+ "url": "view-source:view-source:view-source:view-source:view-source:view-source:view-source:view-source:view-source:https://example.com/",
807
+ "protected": true
808
+ },
659
809
  {
660
810
  "url": "chrome-search://private/",
661
811
  "type": "iframe",
@@ -978,6 +1128,26 @@
978
1128
  }
979
1129
  ],
980
1130
  "storageKeys": [
1131
+ {
1132
+ "key": "http://[::1",
1133
+ "protected": false
1134
+ },
1135
+ {
1136
+ "key": "https://exa mple.com/",
1137
+ "protected": false
1138
+ },
1139
+ {
1140
+ "key": "blob:http://[::1",
1141
+ "protected": false
1142
+ },
1143
+ {
1144
+ "key": "view-source:view-source:view-source:view-source:view-source:view-source:view-source:view-source:view-source:https://seven.sliccy.ai/",
1145
+ "protected": false
1146
+ },
1147
+ {
1148
+ "key": "https://example.com/^0http://[::1",
1149
+ "protected": false
1150
+ },
981
1151
  {
982
1152
  "key": "https://seven.sliccy.ai/",
983
1153
  "protected": true
package/protected.mjs CHANGED
@@ -40,9 +40,13 @@ export function protectedOrigins(rules, extraOrigins = []) {
40
40
  return parse(`${rules.aboutScheme}://${u.pathname}${u.search}${u.hash}`) ?? u;
41
41
  };
42
42
 
43
- const url = (text) => {
43
+ const exempt = (text) => text === '' || text === 'null' || text === '://';
44
+ const failed = (u) => !u || rules.innerSchemes.includes(scheme(u));
45
+ const unparseable = (text) => !exempt(text) && failed(unwrap(parse(text)));
46
+
47
+ const url = (text, { readOnly = false } = {}) => {
44
48
  const u = alias(unwrap(parse(text)));
45
- if (!u) return false;
49
+ if (failed(u)) return rules.unparseable === 'protected' && !readOnly && !exempt(text);
46
50
  const s = scheme(u);
47
51
  if (rules.anySchemes.includes(s)) return true;
48
52
  if ((rules.protectedSchemes ?? []).includes(s))
@@ -80,8 +84,10 @@ export function protectedOrigins(rules, extraOrigins = []) {
80
84
 
81
85
  const storageKey = (key) => {
82
86
  const [first, ...rest] = key.split('^');
83
- return [first, ...rest.filter((p) => p.startsWith('0')).map((p) => p.slice(1))].some(url);
87
+ return [first, ...rest.filter((p) => p.startsWith('0')).map((p) => p.slice(1))].some((text) =>
88
+ url(text, { readOnly: true })
89
+ );
84
90
  };
85
91
 
86
- return { url, target, cookieDomain, storageKey };
92
+ return { url, target, cookieDomain, storageKey, unparseable };
87
93
  }
@@ -185,6 +185,8 @@
185
185
  "aboutScheme",
186
186
  "targetTypes",
187
187
  "inheriting",
188
+ "unparseable",
189
+ "unparseableRefusal",
188
190
  "refusal"
189
191
  ],
190
192
  "properties": {
@@ -225,6 +227,10 @@
225
227
  "uniqueItems": true,
226
228
  "items": { "type": "string", "pattern": "^about:[a-z]+$" }
227
229
  },
230
+ "unparseable": { "const": "protected" },
231
+ "unparseableRefusal": {
232
+ "const": "slicc: refused: slicc can't parse this URL, so it could be a protected page"
233
+ },
228
234
  "refusal": { "$ref": "#/$defs/name" }
229
235
  }
230
236
  },
@@ -427,6 +427,26 @@
427
427
  },
428
428
  "step": {
429
429
  "oneOf": [
430
+ {
431
+ "type": "object",
432
+ "additionalProperties": false,
433
+ "required": ["unparseable", "as", "requires"],
434
+ "properties": {
435
+ "unparseable": {
436
+ "const": true
437
+ },
438
+ "as": {
439
+ "$ref": "#/$defs/name"
440
+ },
441
+ "requires": {
442
+ "$ref": "#/$defs/requires",
443
+ "type": "array",
444
+ "contains": {
445
+ "const": "browserSession"
446
+ }
447
+ }
448
+ }
449
+ },
430
450
  {
431
451
  "type": "object",
432
452
  "additionalProperties": false,
@@ -4,9 +4,22 @@
4
4
  "title": "slicc-browsers protected-origin test vectors, schema 1",
5
5
  "type": "object",
6
6
  "additionalProperties": false,
7
- "required": ["schema", "origins", "urls", "targets", "cookieDomains", "storageKeys"],
7
+ "required": [
8
+ "schema",
9
+ "unparseable",
10
+ "unparseableRefusal",
11
+ "origins",
12
+ "urls",
13
+ "targets",
14
+ "cookieDomains",
15
+ "storageKeys"
16
+ ],
8
17
  "properties": {
9
18
  "schema": { "const": 1 },
19
+ "unparseable": { "const": "protected" },
20
+ "unparseableRefusal": {
21
+ "const": "slicc: refused: slicc can't parse this URL, so it could be a protected page"
22
+ },
10
23
  "origins": { "type": "array", "items": { "type": "string", "minLength": 1 } },
11
24
  "urls": {
12
25
  "type": "array",
@@ -0,0 +1,203 @@
1
+ import { outcome } from './guard-driver.mjs';
2
+
3
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
5
+ const hosts = [
6
+ ['exa mple.sliccy.ai', 'exa%20mple.sliccy.ai'],
7
+ ['exa%20mple.sliccy.ai', 'exa%20mple.sliccy.ai'],
8
+ ['\u200d.sliccy.ai', 'xn--1ug.sliccy.ai'],
9
+ ['xn--1ug.sliccy.ai', 'xn--1ug.sliccy.ai'],
10
+ ['xn--a.sliccy.ai', 'xn--a.sliccy.ai'],
11
+ ];
12
+
13
+ async function document(reader, targetId, expected, allowNativeRejection = false) {
14
+ const { sessionId } = await reader.send('Target.attachToTarget', { targetId, flatten: true });
15
+ try {
16
+ const deadline = Date.now() + 5000;
17
+ let last;
18
+ let targetURL;
19
+ do {
20
+ const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId });
21
+ targetURL = targetInfo.url;
22
+ if (targetInfo.url !== expected) {
23
+ last = { url: targetInfo.url, title: targetInfo.title };
24
+ await pause(25);
25
+ continue;
26
+ }
27
+ if (allowNativeRejection) {
28
+ const { frameTree } = await reader.send('Page.getFrameTree', {}, sessionId);
29
+ if (frameTree.frame.unreachableUrl || frameTree.frame.url.startsWith('chrome-error:'))
30
+ return { nativeRejected: true, url: frameTree.frame.url };
31
+ }
32
+ const out = await outcome(
33
+ reader.send(
34
+ 'Runtime.evaluate',
35
+ {
36
+ expression: '({ title: document.title, href: location.href, cookie: document.cookie })',
37
+ returnByValue: true,
38
+ },
39
+ sessionId
40
+ )
41
+ );
42
+ const value = out.result?.result?.value;
43
+ last = value ?? out;
44
+ if (out.ok && value?.title === 'doc' && value.href === expected) return value;
45
+ await pause(25);
46
+ } while (Date.now() < deadline);
47
+ if (allowNativeRejection && ['', 'about:blank', 'about:blank#blocked'].includes(targetURL))
48
+ return { nativeRejected: true, url: last?.url ?? '' };
49
+ throw new Error(
50
+ `unparseable: Chrome did not load a fixture document at ${expected}: ${JSON.stringify(last)}`
51
+ );
52
+ } finally {
53
+ await outcome(reader.send('Target.detachFromTarget', { sessionId }));
54
+ }
55
+ }
56
+
57
+ async function documentOutcome(reader, targetId, expected, out, strict) {
58
+ if (refused(out)) return 'guard-refusal';
59
+ if (!out.ok || out.result.errorText || !targetId) return 'native-rejection';
60
+ const loaded = await document(reader, targetId, expected, !strict);
61
+ return loaded.nativeRejected ? 'native-rejection' : 'document';
62
+ }
63
+
64
+ async function destinations({ client, reader, root, tag, targets, session, targetId }) {
65
+ const result = {
66
+ createRefused: true,
67
+ navigateRefused: true,
68
+ createAnswered: true,
69
+ navigateAnswered: true,
70
+ spaceCreated: true,
71
+ spaceNavigated: true,
72
+ responses: [],
73
+ };
74
+ const invalid = (out) =>
75
+ out.error?.code === -32000 && /invalid URL/i.test(out.error?.message ?? '');
76
+ for (const [index, [input, canonical]] of hosts.entries()) {
77
+ const strict = canonical === 'exa%20mple.sliccy.ai';
78
+ const base = `${root.protocol}//${canonical}:${root.port}/doc?n=${tag}`;
79
+ const created = await outcome(
80
+ client.send('Target.createTarget', {
81
+ url: `${root.protocol}//${input}:${root.port}/doc?n=${tag}-create-${index}`,
82
+ background: true,
83
+ })
84
+ );
85
+ result.createRefused &&= refused(created);
86
+ result.createAnswered &&= (created.ok && Boolean(created.result.targetId)) || invalid(created);
87
+ if (created.result?.targetId) targets.add(created.result.targetId);
88
+ const createOutcome = await documentOutcome(
89
+ reader,
90
+ created.result?.targetId,
91
+ `${base}-create-${index}`,
92
+ created,
93
+ strict
94
+ );
95
+ const navigated = await outcome(
96
+ client.send(
97
+ 'Page.navigate',
98
+ { url: `${root.protocol}//${input}:${root.port}/doc?n=${tag}-navigate-${index}` },
99
+ session
100
+ )
101
+ );
102
+ result.navigateRefused &&= refused(navigated);
103
+ result.navigateAnswered &&= navigated.ok || invalid(navigated);
104
+ const navigateOutcome = await documentOutcome(
105
+ reader,
106
+ targetId,
107
+ `${base}-navigate-${index}`,
108
+ navigated,
109
+ strict
110
+ );
111
+ if (strict) {
112
+ result.spaceCreated &&= createOutcome === 'document';
113
+ result.spaceNavigated &&= navigateOutcome === 'document';
114
+ }
115
+ result.responses.push({ input, canonical, created, navigated, createOutcome, navigateOutcome });
116
+ await outcome(
117
+ client.send(
118
+ 'Page.navigate',
119
+ { url: `${root.origin}/doc?n=${tag}-ordinary-${index}` },
120
+ session
121
+ )
122
+ );
123
+ }
124
+ return result;
125
+ }
126
+
127
+ export async function unparseableURLs({ client, reader, session, targetId, origin, tag, fixture }) {
128
+ const root = new URL(origin);
129
+ const cookie = `seven-unparseable-${tag}`;
130
+ const value = `secret-${tag}`;
131
+ const targets = new Set();
132
+ let observer;
133
+ try {
134
+ await reader.send('Storage.setCookies', {
135
+ cookies: [{ name: cookie, value, domain: '.sliccy.ai', path: '/', secure: false }],
136
+ });
137
+ const url = `${root.protocol}//exa%20mple.sliccy.ai:${root.port}/doc?n=${tag}-observer`;
138
+ const { targetId: probeId } = await reader.send('Target.createTarget', {
139
+ url,
140
+ background: true,
141
+ });
142
+ targets.add(probeId);
143
+ const ground = await document(reader, probeId, url);
144
+ observer = (await reader.send('Target.attachToTarget', { targetId: probeId, flatten: true }))
145
+ .sessionId;
146
+ const listed = await outcome(client.send('Target.getTargets', {}));
147
+ const attached = await outcome(
148
+ client.send('Target.attachToTarget', { targetId: probeId, flatten: true })
149
+ );
150
+ let readCookie = false;
151
+ if (attached.ok) {
152
+ const read = await outcome(
153
+ client.send(
154
+ 'Runtime.evaluate',
155
+ {
156
+ expression: `document.cookie.includes(${JSON.stringify(`${cookie}=${value}`)})`,
157
+ returnByValue: true,
158
+ },
159
+ attached.result.sessionId
160
+ )
161
+ );
162
+ readCookie = read.ok && read.result.result?.value === true;
163
+ await outcome(
164
+ client.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
165
+ );
166
+ }
167
+ const queried = await outcome(client.send('Network.getCookies', { urls: [url] }, session));
168
+ const result = {
169
+ browserVersion: (await reader.send('Browser.getVersion', {})).product,
170
+ live: ground.cookie.includes(`${cookie}=${value}`),
171
+ listed: listed.ok && listed.result.targetInfos?.some((info) => info.targetId === probeId),
172
+ attached: attached.ok,
173
+ attachRefused: refused(attached),
174
+ readCookie,
175
+ cookieRefused: refused(queried),
176
+ cookieDelivered:
177
+ queried.ok &&
178
+ queried.result.cookies?.some((entry) => entry.name === cookie && entry.value === value),
179
+ ...(await destinations({ client, reader, root, tag, targets, session, targetId })),
180
+ };
181
+ await pause(100);
182
+ const requests = await fixture.log();
183
+ result.cookieRequest = requests.some((entry) =>
184
+ entry.url.includes(`${tag}-create-`) || entry.url.includes(`${tag}-navigate-`)
185
+ ? entry.headers.cookie?.includes(`${cookie}=${value}`)
186
+ : false
187
+ );
188
+ return result;
189
+ } finally {
190
+ if (observer)
191
+ await outcome(
192
+ reader.send(
193
+ 'Network.deleteCookies',
194
+ { name: cookie, domain: '.sliccy.ai', path: '/' },
195
+ observer
196
+ )
197
+ );
198
+ const listed = await outcome(reader.send('Target.getTargets', {}));
199
+ for (const info of listed.result?.targetInfos ?? [])
200
+ if (info.url.includes(tag)) targets.add(info.targetId);
201
+ for (const targetId of targets) await outcome(reader.send('Target.closeTarget', { targetId }));
202
+ }
203
+ }