@ai-ecoverse/slicc-browsers 1.13.3 → 1.13.5
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 +7 -6
- package/guard-driver.mjs +96 -2
- package/guard-runner.mjs +13 -24
- package/guard-vectors.json +91 -7
- package/package.json +2 -2
- package/protected-error-page.mjs +17 -9
- package/redirect-secret.mjs +141 -19
package/README.md
CHANGED
|
@@ -190,9 +190,9 @@ The `C9` foreign extension isolated-world family requires `extensionWorld`: a br
|
|
|
190
190
|
|
|
191
191
|
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.
|
|
192
192
|
|
|
193
|
-
`C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies, across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
|
|
193
|
+
`C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies but not the probe’s own observer session traffic, across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. `Network.getResponseBody` is attempted, once per id, with every network id the client learns for a 301–308 hop: the `requestId` of a Network event carrying the redirect status and the `networkId` of the Fetch response-stage pause. For each registered hop whose id the client learned neither way, the probe reads the hop’s real id from the harness’s own unguarded tap (the observer session, which enables Network on the guarded leg) and attempts the read on the guarded session with it. The evidence’s `networkBodyCarriers` counts the ids per carrier, and `networkBodyPath` names the weakest carrier any id needed: `withheld+tap`, then `fetch-networkId`, then `network`, or `none` without any id. These reads never hold up a Fetch pause, and the probe gives them five seconds after `Fetch.disable` to settle. A read still unanswered then is not a refusal. `networkBodyAttempted` and `networkBodyRefused` hold only when every attempt is refused. On the guarded leg a `fetch-networkId` or `withheld+tap` carrier also needs the same row’s unguarded control to have seen the redirect’s Network events. Without that proof, or with no id at all, both read `incomplete` and the probe returns `redirect-secret: network body leg <reason>`; an attempt that is not refused is measured and fails. Every request the client sees for a registered redirect URL, through its initial `Network.requestWillBeSent` or a redirect `requestWillBeSent` whose `redirectResponse.url` is that URL, must end within the step in a redirect `requestWillBeSent`, `loadingFinished` or `loadingFailed` for that `requestId`. The evidence’s `hops` records per `requestId` the `delivery` (`redacted` for an origin-only protected redirect in place, `withheld` for a terminal without the redirect, `ordinary`, `raw` or `unterminated`) and the `terminal` event method. `requestsTerminated` is false on any `unterminated` request, a measured guard failure rather than missing evidence; `hopsRedacted` is false on any `raw` or `ordinary` hop, so a delivered protected hop must be origin-only, never the raw or a substituted URL; the guarded leg expects it. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
|
|
194
194
|
|
|
195
|
-
`C2.redirect-body` calls `Network.loadNetworkResource` on an ordinary redirect with `includeCredentials: true`, reads any stream with `IO.read`, replays a redirected XHR, requests Page resource content, and captures Page/MHTML and DOM snapshots after a main-frame redirect. It also captures an ordinary parent containing an iframe redirected to vault, including its error document, and overrides a paused ordinary Fetch request to the allowed redirect URL. No protected marker may appear in any message or body, and no request from those operations may send the independently seeded seven cookie. The ordinary control must return the body, resource content and snapshots, and accept the URL override. Each probe cleans up its redirect registrations, observer session and cookie. The focused Chrome tests use HTTPS with SPKI-pinned fixture trust and default-port protected URLs; host resolver rules route seven and vault to the fixture rather than the public service.
|
|
195
|
+
`C2.redirect-body` calls `Network.loadNetworkResource` on an ordinary redirect with `includeCredentials: true`, reads any stream with `IO.read`, replays a redirected XHR, requests Page resource content, and captures Page/MHTML and DOM snapshots after a main-frame redirect. It also captures an ordinary parent containing an iframe redirected to vault, including its error document, and overrides a paused ordinary Fetch request to the allowed redirect URL. No protected marker may appear in any message or body, and no request from those operations may send the independently seeded seven cookie. The ordinary control must return the body, resource content and snapshots, and accept the URL override. Its network-body carrier, `networkBodyAttempted`, `networkBodyRefused`, `hops`, `requestsTerminated` and `hopsRedacted` follow `C2.redirect-secret`. Each probe cleans up its redirect registrations, observer session and cookie. The focused Chrome tests use HTTPS with SPKI-pinned fixture trust and default-port protected URLs; host resolver rules route seven and vault to the fixture rather than the public service.
|
|
196
196
|
|
|
197
197
|
`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 runs on both browser-session and page-session-only transports, uses the default context, and cleans up its own targets and cookie. Cookie seeding always uses the unguarded observer’s browser session, never the guarded client. Only guarded browser-session target listing requires `browserSession`; without it, `listed` records `not-supported` explicitly in probe and observation evidence, and the result lists that field in `notSupported`. Creation, navigation, attachment and `Network.getCookies` with `urls` still require matched refusals; filtering cookies without refusing fails `cookieRefused`. Fixture URLs omit the colon when the port is empty, and reported URLs are compared after canonicalisation, including Chrome’s WHATWG-unparseable encoded-space hosts. Launchers must resolve the raw/canonical space, U+200D/punycode and invalid-punycode `.sliccy.ai` fixture hosts to the fixture server, including for default-port HTTP or HTTPS origins. The HTTP fixture accepts Chrome’s encoded-space Host header without reparsing it as a WHATWG origin.
|
|
198
198
|
|
|
@@ -261,7 +261,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
261
261
|
|
|
262
262
|
`{ "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.
|
|
263
263
|
|
|
264
|
-
`{ "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 whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself. Each of the probe’s waits is bounded at 5000 ms, scaled by `settleScale`. When the probe’s own evidence is missing, it returns `incomplete` with `protected-error-page: <reason>` instead of throwing: the observer’s history seed or allowed document did not commit (or its setup failed), the control’s own Fetch failure injection left no error commit, the protected history entry is missing, no real `BackForwardCacheRestore` arrived, or the observer’s sticky failure left no error page. The guarded leg’s missing error commit is not incomplete: it is the guard’s, and stays `live: false`.
|
|
264
|
+
`{ "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 whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself. Each of the probe’s waits is bounded at 5000 ms, scaled by `settleScale`. When the probe’s own evidence is missing, it returns `incomplete` with `protected-error-page: <reason>` instead of throwing: the observer target could not be created or attached (an `openerId` included, after cleaning the target up), the observer’s history seed or allowed document did not commit (or its setup failed), the control’s own Fetch failure injection left no error commit, the protected history entry is missing, no real `BackForwardCacheRestore` arrived, or the observer’s sticky failure left no error page. The guarded leg’s missing error commit is not incomplete: it is the guard’s, and stays `live: false`.
|
|
265
265
|
|
|
266
266
|
`{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. Whenever a run includes a `C2.redirect-*` row or a `redirectSecret` step, `run()` retains an explicit `fixture.redirectSecret` hook or automatically installs a fetch-only `POST /redirect` bridge. Its base is `fixtureBase`, then `fixture.origin`, then `targets.ordinary`; a browser-page alias or reverse proxy must forward that endpoint to the bundled fixture. The bundled `fixtureServer()` always supplies both its direct hook and HTTP control endpoint, so no custom registration method is needed. Missing, unreachable or non-successful registration is an explicit row/summary error containing `redirectSecret fixture registration required`, never a pass or a skipped observation. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream. The leak scan memoizes each decoded string and yields to the event loop between items, so concurrent rows in the same process are not starved. When the probe’s own unguarded observer setup (attach, `Page.enable` and the seven cookie) fails before the client is used, the probe returns `incomplete` with `redirect-secret: observer setup failed: <reason>` instead of throwing.
|
|
267
267
|
|
|
@@ -306,9 +306,10 @@ It exports:
|
|
|
306
306
|
- `waitFor(check, ms, step?)`: polls `check` every `step` ms (default 25) until it holds, checking once more after the bound. A timer heartbeat every `step` runs for the whole wait, including while `check` is awaiting a reply, and any gap between beats longer than a second (or four steps, if longer) extends the bound by that gap, so a synchronous event-loop stall elsewhere in the process does not consume it. The extensions add up to at most `max(4 × ms, 30000)` ms, so a throttled page cannot stretch a wait without limit. `load`, the runner's waits and the error-page and redirect-body probes use it;
|
|
307
307
|
- `judge(vector, side, observed)`: `{ pass, refused, skipped, mismatches }`, where `refused` lists the leg's refusals and `skipped` the observations that depend on them or explicitly record unavailable observation capabilities;
|
|
308
308
|
- `result(vector, run)`: a report row from `{ target, variant, transport, via?, capabilities?, control, guarded, live }`. `capabilities` is the transport's; without it, the transport has them all;
|
|
309
|
+
- `knownOrigins` and `originDefaults`: the origin names a runner can miss, and the origins `plain`, `cross`, `log` and `own` fall back to when unset;
|
|
309
310
|
- `parseURL(value, base?)`: a `URL`, or `null` for anything `new URL` rejects or a non-string; every parse of a fixture log entry or a Chrome-reported URL in the runner and the fixture goes through it (`protected.mjs` keeps its own copy, since it imports nothing), so an unparseable entry never throws;
|
|
310
311
|
- `owned(entries, tag)`: the fixture log entries whose raw URL contains `tag`, the row's attribution through its `n=`, `tag=` or `to=` query;
|
|
311
|
-
- `summary({ guard, sha, set, results })`: `set` is the parsed `guard-vectors.json`. It returns `{ set, schemaVersion, guard, sha, variants, targets, transports, results, skipped, notRun, missing, pass, line }`. `missing` lists each `{ id, target, variant, transport }` without a row, for every vector whose `scope` is `any` or the guard, every target it lists, every variant in `variants` or `results`, and every transport in `results`; `pass` needs it empty, where `line` is `GUARD-PASS <guard>@<sha> (vectors @ai-ecoverse/slicc-browsers@X.Y.Z)` or `GUARD-FAIL …`.
|
|
312
|
+
- `summary({ guard, sha, set, results })`: `set` is the parsed `guard-vectors.json`. It returns `{ set, schemaVersion, guard, sha, variants, targets, transports, results, skipped, notRun, missing, forged, pass, line }`. `missing` lists each `{ id, target, variant, transport }` without a row, for every vector whose `scope` is `any` or the guard, every target it lists, every variant in `variants` or `results`, and every transport in `results`; `pass` needs it empty, where `line` is `GUARD-PASS <guard>@<sha> (vectors @ai-ecoverse/slicc-browsers@X.Y.Z)` or `GUARD-FAIL …`. `forged` lists each `{ id, target, variant, transport, why }` that is `not-run` on a vector that isn't `kind: "text"`, or `skip` without a reason the runner gives (`not-applicable`, `not-applicable:no-<origin>-origin` for an origin in `knownOrigins` that the row's target, `plain`, the control's origin, the steps or a check names, directly or through `originDefaults`, where only the first branch of an `any` counts, the vector's own `skip.reason` on its target, or `not-supported:<capability>` for a capability the set requires, `browserSession` or `unguarded-observer`); `pass` needs it empty too, and `line` then ends in `forged <n>`.
|
|
312
313
|
|
|
313
314
|
**`npm run validate`** checks that:
|
|
314
315
|
- the file matches the schema, and ids are unique;
|
|
@@ -349,7 +350,7 @@ The factory can configure the guard's own transport delay from this metadata;
|
|
|
349
350
|
the runner never reaches into the guard. `transportVariant: true` acknowledges
|
|
350
351
|
that configuration, and every result records that boolean (false by default).
|
|
351
352
|
`slot` allows pooling browsers while keeping each row's clients independent.
|
|
352
|
-
A factory that rejects makes only that row `incomplete` for control, guarded and live, with `clientFactory: <reason>` in its `incomplete`; the run goes on with the next row instead of rejecting. Such a row is labelled with the transport of the last client the factory made (`cdp` if none), and is passed to `log` once the run completes, so `summary()` never counts a placeholder transport. The optional `close` releases the row's clients; release pooled browsers after
|
|
353
|
+
A factory that rejects or throws makes only that row `incomplete` for control, guarded and live, with `clientFactory: <reason>` in its `incomplete`; the run goes on with the next row instead of rejecting. Such a row is labelled with the transport of the last client the factory made (`cdp` if none), and is passed to `log` once the run completes, so `summary()` never counts a placeholder transport. The optional `close` releases the row's clients; release pooled browsers after
|
|
353
354
|
`run` completes. Each parallel slot needs an independent browser for
|
|
354
355
|
browser-wide settings. Without a factory execution is serial. Cookie and storage
|
|
355
356
|
rows use fresh browser contexts when `capabilities.browserContexts` is true;
|
|
@@ -381,7 +382,7 @@ every alternative of an `any`, or a required (`all`, `not` or plain) check, need
|
|
|
381
382
|
selects one variant or an array; omission selects all. `parallel` defaults to 1.
|
|
382
383
|
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.
|
|
383
384
|
|
|
384
|
-
Every target created by the runner’s own unguarded observer uses `Target.createTarget` with `newWindow: true`, then verifies through `Target.getTargetInfo` that `openerId` is absent. A supplied opener or missing target evidence fails and cleans up that target, rather than inheriting the active client tab (which could be seven). This includes ordinary control setup, storage canaries, protected-error history seeds and the malformed-URL observer. The row notes record `newWindow=true, openerId absent`. Intentional guarded-client creation commands and popup/opener tests keep their original parameters.
|
|
385
|
+
Every target created by the runner’s own unguarded observer uses `Target.createTarget` with `newWindow: true`, then verifies through `Target.getTargetInfo` that `openerId` is absent. A supplied opener or missing target evidence fails and cleans up that target (in the protected-error-page probe, the row is `incomplete` instead), rather than inheriting the active client tab (which could be seven). This includes ordinary control setup, storage canaries, protected-error history seeds and the malformed-URL observer. The row notes record `newWindow=true, openerId absent`. Intentional guarded-client creation commands and popup/opener tests keep their original parameters.
|
|
385
386
|
|
|
386
387
|
`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.
|
|
387
388
|
|
package/guard-driver.mjs
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
const q = JSON.stringify;
|
|
2
2
|
|
|
3
|
+
export const originDefaults = {
|
|
4
|
+
plain: 'ordinary',
|
|
5
|
+
cross: 'client',
|
|
6
|
+
log: 'ordinary',
|
|
7
|
+
own: 'client',
|
|
8
|
+
};
|
|
9
|
+
export const knownOrigins = [
|
|
10
|
+
'ordinary',
|
|
11
|
+
'ordinary-same-site',
|
|
12
|
+
'seven',
|
|
13
|
+
'vault',
|
|
14
|
+
'vault-x',
|
|
15
|
+
'apex',
|
|
16
|
+
'own',
|
|
17
|
+
'client',
|
|
18
|
+
'plain',
|
|
19
|
+
'cross',
|
|
20
|
+
'log',
|
|
21
|
+
];
|
|
22
|
+
|
|
3
23
|
export const beacon = (log, tag) =>
|
|
4
24
|
`try { const u = ${q(log)} + '/beacon?tag=' + ${q(tag)} + '&origin=' + encodeURIComponent(location.origin); if (!navigator.sendBeacon(u)) fetch(u, { keepalive: true, mode: 'no-cors' }).catch(() => {}); } catch {}`;
|
|
5
25
|
|
|
@@ -276,6 +296,77 @@ const passed = (r) =>
|
|
|
276
296
|
[r.control, r.guarded, r.live].every((s) => s === 'skip') ||
|
|
277
297
|
[r.control, r.guarded, r.live].every((s) => s === 'not-run');
|
|
278
298
|
|
|
299
|
+
const capabilities = (value) =>
|
|
300
|
+
Array.isArray(value)
|
|
301
|
+
? value.flatMap(capabilities)
|
|
302
|
+
: value && typeof value === 'object'
|
|
303
|
+
? Object.entries(value).flatMap(([key, item]) =>
|
|
304
|
+
key === 'requires' && Array.isArray(item) ? item : capabilities(item)
|
|
305
|
+
)
|
|
306
|
+
: [];
|
|
307
|
+
|
|
308
|
+
const originNames = (value) =>
|
|
309
|
+
[...q(value ?? null).matchAll(/\{([A-Za-z][A-Za-z-]*)\}/g)]
|
|
310
|
+
.map((match) => (match[1] === 'beacon' || match[1] === 'probe' ? 'log' : match[1]))
|
|
311
|
+
.flatMap((name) => [name, originDefaults[name]]);
|
|
312
|
+
|
|
313
|
+
const checkOrigins = (check) => {
|
|
314
|
+
if (!check || typeof check !== 'object') return [];
|
|
315
|
+
if (check.any) return checkOrigins(check.any[0]);
|
|
316
|
+
if (check.all || check.not) return (check.all ?? [check.not]).flatMap(checkOrigins);
|
|
317
|
+
return originNames(check);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
function originReason(vector, row, reason) {
|
|
321
|
+
const origin = /^not-applicable:no-(.+)-origin$/.exec(reason)?.[1];
|
|
322
|
+
if (!knownOrigins.includes(origin)) return false;
|
|
323
|
+
const fixed = [
|
|
324
|
+
row.target,
|
|
325
|
+
'{plain}',
|
|
326
|
+
vector?.control?.origin,
|
|
327
|
+
vector?.setup,
|
|
328
|
+
vector?.trigger,
|
|
329
|
+
].map((value) => (typeof value === 'string' && !value.startsWith('{') ? `{${value}}` : value));
|
|
330
|
+
return [
|
|
331
|
+
...originNames(fixed),
|
|
332
|
+
...checkOrigins(vector?.liveCheck),
|
|
333
|
+
...Object.values(vector?.observe ?? {}).flatMap((observation) =>
|
|
334
|
+
checkOrigins(observation?.check)
|
|
335
|
+
),
|
|
336
|
+
].includes(origin);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function skipWhy(vector, row, known) {
|
|
340
|
+
const { reason } = row;
|
|
341
|
+
if (typeof reason !== 'string' || reason === '') return 'skip without a reason';
|
|
342
|
+
if (reason === 'not-applicable' || originReason(vector, row, reason)) return null;
|
|
343
|
+
if (vector?.skip?.target === row.target && vector.skip.reason === reason) return null;
|
|
344
|
+
if (reason.startsWith('not-supported:') && known.has(reason.slice('not-supported:'.length)))
|
|
345
|
+
return null;
|
|
346
|
+
return `skip with a reason the runner never gives: ${reason}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function forgeries(set, results) {
|
|
350
|
+
const vectors = new Map(set.vectors.map((v) => [v.id, v]));
|
|
351
|
+
const known = new Set([
|
|
352
|
+
'browserSession',
|
|
353
|
+
'unguarded-observer',
|
|
354
|
+
...capabilities(set.vectors).filter((c) => typeof c === 'string' && c !== ''),
|
|
355
|
+
]);
|
|
356
|
+
return results.flatMap((row) => {
|
|
357
|
+
const legs = [row.control, row.guarded, row.live];
|
|
358
|
+
const vector = vectors.get(row.id);
|
|
359
|
+
const why = [];
|
|
360
|
+
if (legs.includes('not-run') && vector?.kind !== 'text')
|
|
361
|
+
why.push('not-run on a vector that is not kind text');
|
|
362
|
+
if (legs.includes('skip')) why.push(skipWhy(vector, row, known));
|
|
363
|
+
const found = why.filter(Boolean);
|
|
364
|
+
if (!found.length) return [];
|
|
365
|
+
const { id, target, variant, transport } = row;
|
|
366
|
+
return [{ id, target, variant, transport, why: found.join('; ') }];
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
279
370
|
export function summary({ guard, sha, set, results }) {
|
|
280
371
|
const uniq = (k) => [...new Set(results.map((r) => r[k]))].sort();
|
|
281
372
|
const variants = [...new Set([...Object.keys(set.variants ?? {}), ...uniq('variant')])].sort();
|
|
@@ -286,7 +377,9 @@ export function summary({ guard, sha, set, results }) {
|
|
|
286
377
|
.flatMap((row) => variants.map((variant) => ({ ...row, variant })))
|
|
287
378
|
.flatMap((row) => uniq('transport').map((transport) => ({ ...row, transport })))
|
|
288
379
|
.filter((r) => !has.has(q([r.id, r.target, r.variant, r.transport])));
|
|
289
|
-
const
|
|
380
|
+
const forged = forgeries(set, results);
|
|
381
|
+
const pass =
|
|
382
|
+
results.length > 0 && missing.length === 0 && forged.length === 0 && results.every(passed);
|
|
290
383
|
const name = `@ai-ecoverse/slicc-browsers@${set.setVersion}`;
|
|
291
384
|
return {
|
|
292
385
|
set: name,
|
|
@@ -302,8 +395,9 @@ export function summary({ guard, sha, set, results }) {
|
|
|
302
395
|
.map(({ id, target, reason }) => ({ id, target, reason })),
|
|
303
396
|
notRun: [...new Set(results.filter((r) => r.control === 'not-run').map((r) => r.id))],
|
|
304
397
|
missing,
|
|
398
|
+
forged,
|
|
305
399
|
pass,
|
|
306
|
-
line: `${pass ? 'GUARD-PASS' : 'GUARD-FAIL'} ${guard}@${sha} (vectors ${name})`,
|
|
400
|
+
line: `${pass ? 'GUARD-PASS' : 'GUARD-FAIL'} ${guard}@${sha} (vectors ${name})${forged.length ? ` forged ${forged.length}` : ''}`,
|
|
307
401
|
};
|
|
308
402
|
}
|
|
309
403
|
|
package/guard-runner.mjs
CHANGED
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
expand,
|
|
10
10
|
hop,
|
|
11
11
|
isRefusal,
|
|
12
|
+
knownOrigins,
|
|
12
13
|
loadedAfter,
|
|
14
|
+
originDefaults,
|
|
13
15
|
outcome,
|
|
14
16
|
owned,
|
|
15
17
|
parseURL,
|
|
@@ -703,6 +705,7 @@ class Execution {
|
|
|
703
705
|
fixture: this.options.fixture,
|
|
704
706
|
mode: step.redirectSecret,
|
|
705
707
|
side: this.side,
|
|
708
|
+
controls: (this.clients.redirectSecretControls ??= {}),
|
|
706
709
|
})
|
|
707
710
|
);
|
|
708
711
|
} finally {
|
|
@@ -1301,21 +1304,6 @@ const browserContextStep = (vector, capabilities) =>
|
|
|
1301
1304
|
supported(['browserContexts'], capabilities))
|
|
1302
1305
|
);
|
|
1303
1306
|
|
|
1304
|
-
const originDefaults = { plain: 'ordinary', cross: 'client', log: 'ordinary', own: 'client' };
|
|
1305
|
-
const knownOrigins = [
|
|
1306
|
-
'ordinary',
|
|
1307
|
-
'ordinary-same-site',
|
|
1308
|
-
'seven',
|
|
1309
|
-
'vault',
|
|
1310
|
-
'vault-x',
|
|
1311
|
-
'apex',
|
|
1312
|
-
'own',
|
|
1313
|
-
'client',
|
|
1314
|
-
'plain',
|
|
1315
|
-
'cross',
|
|
1316
|
-
'log',
|
|
1317
|
-
];
|
|
1318
|
-
|
|
1319
1307
|
function nullOrigins(value, targets) {
|
|
1320
1308
|
return [...q(value).matchAll(/\{([A-Za-z][A-Za-z-]*)\}/g)]
|
|
1321
1309
|
.map((match) => (match[1] === 'beacon' || match[1] === 'probe' ? 'log' : match[1]))
|
|
@@ -1678,15 +1666,16 @@ function validate(set, opts) {
|
|
|
1678
1666
|
|
|
1679
1667
|
function factoryClients(opts, set, job, slot) {
|
|
1680
1668
|
return outcome(
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1669
|
+
(async () =>
|
|
1670
|
+
opts.clientFactory
|
|
1671
|
+
? opts.clientFactory({
|
|
1672
|
+
slot,
|
|
1673
|
+
variant: job.variant,
|
|
1674
|
+
detachDelay: set.variants[job.variant],
|
|
1675
|
+
vector: job.vector,
|
|
1676
|
+
target: job.target,
|
|
1677
|
+
})
|
|
1678
|
+
: opts.client)()
|
|
1690
1679
|
);
|
|
1691
1680
|
}
|
|
1692
1681
|
|
package/guard-vectors.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"setVersion": "1.13.
|
|
3
|
+
"setVersion": "1.13.5",
|
|
4
4
|
"sources": [
|
|
5
5
|
{
|
|
6
6
|
"repo": "ai-ecoverse/slicc-cli",
|
|
@@ -309,6 +309,42 @@
|
|
|
309
309
|
"equals": true,
|
|
310
310
|
"observer": "client"
|
|
311
311
|
}
|
|
312
|
+
},
|
|
313
|
+
"requestsTerminated": {
|
|
314
|
+
"means": "redirect secret: every seen redirect request ends, no unterminated request",
|
|
315
|
+
"check": {
|
|
316
|
+
"result": "redirect",
|
|
317
|
+
"path": "result.requestsTerminated",
|
|
318
|
+
"equals": true,
|
|
319
|
+
"observer": "client"
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
"hopsRedacted": {
|
|
323
|
+
"means": "redirect secret: every protected hop is origin-only, never the raw URL",
|
|
324
|
+
"check": {
|
|
325
|
+
"result": "redirect",
|
|
326
|
+
"path": "result.hopsRedacted",
|
|
327
|
+
"equals": true,
|
|
328
|
+
"observer": "client"
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
"networkBodyAttempted": {
|
|
332
|
+
"means": "redirect secret: networkBodyAttempted",
|
|
333
|
+
"check": {
|
|
334
|
+
"result": "redirect",
|
|
335
|
+
"path": "result.networkBodyAttempted",
|
|
336
|
+
"equals": true,
|
|
337
|
+
"observer": "client"
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
"networkBodyRefused": {
|
|
341
|
+
"means": "redirect secret: networkBodyRefused",
|
|
342
|
+
"check": {
|
|
343
|
+
"result": "redirect",
|
|
344
|
+
"path": "result.networkBodyRefused",
|
|
345
|
+
"equals": true,
|
|
346
|
+
"observer": "client"
|
|
347
|
+
}
|
|
312
348
|
}
|
|
313
349
|
},
|
|
314
350
|
"live": "the fixture served the registered ordinary redirect",
|
|
@@ -337,7 +373,9 @@
|
|
|
337
373
|
"resourceContentAttempted": true,
|
|
338
374
|
"snapshotAttempted": true,
|
|
339
375
|
"domSnapshotAttempted": true,
|
|
340
|
-
"overridesWorked": true
|
|
376
|
+
"overridesWorked": true,
|
|
377
|
+
"requestsTerminated": true,
|
|
378
|
+
"networkBodyAttempted": true
|
|
341
379
|
}
|
|
342
380
|
},
|
|
343
381
|
"expected": {
|
|
@@ -352,7 +390,11 @@
|
|
|
352
390
|
"snapshotAttempted": true,
|
|
353
391
|
"domSnapshotAttempted": true,
|
|
354
392
|
"overridesRefused": true,
|
|
355
|
-
"iframeSnapshotAttempted": true
|
|
393
|
+
"iframeSnapshotAttempted": true,
|
|
394
|
+
"requestsTerminated": true,
|
|
395
|
+
"hopsRedacted": true,
|
|
396
|
+
"networkBodyAttempted": true,
|
|
397
|
+
"networkBodyRefused": true
|
|
356
398
|
}
|
|
357
399
|
},
|
|
358
400
|
{
|
|
@@ -518,6 +560,24 @@
|
|
|
518
560
|
"equals": true,
|
|
519
561
|
"observer": "client"
|
|
520
562
|
}
|
|
563
|
+
},
|
|
564
|
+
"requestsTerminated": {
|
|
565
|
+
"means": "redirect secret: every seen redirect request ends, no unterminated request",
|
|
566
|
+
"check": {
|
|
567
|
+
"result": "redirect",
|
|
568
|
+
"path": "result.requestsTerminated",
|
|
569
|
+
"equals": true,
|
|
570
|
+
"observer": "client"
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
"hopsRedacted": {
|
|
574
|
+
"means": "redirect secret: every protected hop is origin-only, never the raw URL",
|
|
575
|
+
"check": {
|
|
576
|
+
"result": "redirect",
|
|
577
|
+
"path": "result.hopsRedacted",
|
|
578
|
+
"equals": true,
|
|
579
|
+
"observer": "client"
|
|
580
|
+
}
|
|
521
581
|
}
|
|
522
582
|
},
|
|
523
583
|
"live": "the fixture served the registered ordinary redirect",
|
|
@@ -543,7 +603,8 @@
|
|
|
543
603
|
"networkFragmentVisible": true,
|
|
544
604
|
"frameFragmentVisible": true,
|
|
545
605
|
"frameTreeFragmentVisible": true,
|
|
546
|
-
"resourceTreeFragmentVisible": true
|
|
606
|
+
"resourceTreeFragmentVisible": true,
|
|
607
|
+
"requestsTerminated": true
|
|
547
608
|
}
|
|
548
609
|
},
|
|
549
610
|
"expected": {
|
|
@@ -556,7 +617,9 @@
|
|
|
556
617
|
"fetchBodyRefused": true,
|
|
557
618
|
"networkBodyRefused": true,
|
|
558
619
|
"extraLocation": true,
|
|
559
|
-
"fragmentsHidden": true
|
|
620
|
+
"fragmentsHidden": true,
|
|
621
|
+
"requestsTerminated": true,
|
|
622
|
+
"hopsRedacted": true
|
|
560
623
|
}
|
|
561
624
|
},
|
|
562
625
|
{
|
|
@@ -722,6 +785,24 @@
|
|
|
722
785
|
"equals": true,
|
|
723
786
|
"observer": "client"
|
|
724
787
|
}
|
|
788
|
+
},
|
|
789
|
+
"requestsTerminated": {
|
|
790
|
+
"means": "redirect secret: every seen redirect request ends, no unterminated request",
|
|
791
|
+
"check": {
|
|
792
|
+
"result": "redirect",
|
|
793
|
+
"path": "result.requestsTerminated",
|
|
794
|
+
"equals": true,
|
|
795
|
+
"observer": "client"
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
"hopsRedacted": {
|
|
799
|
+
"means": "redirect secret: every protected hop is origin-only, never the raw URL",
|
|
800
|
+
"check": {
|
|
801
|
+
"result": "redirect",
|
|
802
|
+
"path": "result.hopsRedacted",
|
|
803
|
+
"equals": true,
|
|
804
|
+
"observer": "client"
|
|
805
|
+
}
|
|
725
806
|
}
|
|
726
807
|
},
|
|
727
808
|
"live": "the fixture served the registered ordinary redirect",
|
|
@@ -747,7 +828,8 @@
|
|
|
747
828
|
"networkFragmentVisible": true,
|
|
748
829
|
"frameFragmentVisible": true,
|
|
749
830
|
"frameTreeFragmentVisible": true,
|
|
750
|
-
"resourceTreeFragmentVisible": true
|
|
831
|
+
"resourceTreeFragmentVisible": true,
|
|
832
|
+
"requestsTerminated": true
|
|
751
833
|
}
|
|
752
834
|
},
|
|
753
835
|
"expected": {
|
|
@@ -760,7 +842,9 @@
|
|
|
760
842
|
"fetchBodyRefused": true,
|
|
761
843
|
"networkBodyRefused": true,
|
|
762
844
|
"extraLocation": true,
|
|
763
|
-
"fragmentsHidden": true
|
|
845
|
+
"fragmentsHidden": true,
|
|
846
|
+
"requestsTerminated": true,
|
|
847
|
+
"hopsRedacted": true
|
|
764
848
|
}
|
|
765
849
|
},
|
|
766
850
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-browsers",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.5",
|
|
4
4
|
"description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"validate": "node scripts/validate.mjs",
|
|
67
67
|
"lint": "slicc-lint && test -z \"$(gofmt -l .)\" && go mod tidy -diff && go vet ./... && golangci-lint run && cargo fmt --check && cargo clippy --all-features --all-targets -- -D warnings",
|
|
68
68
|
"format": "biome check --write . && gofmt -w . && cargo fmt",
|
|
69
|
-
"test": "npm run validate && node --test test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/dist-tag.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
69
|
+
"test": "npm run validate && node --test test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/dist-tag.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
70
70
|
"test:unit": "go test -race -cover ./...",
|
|
71
71
|
"build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
|
|
72
72
|
"trust": "npx fledgling sync -y --skip-publish",
|
package/protected-error-page.mjs
CHANGED
|
@@ -80,6 +80,18 @@ async function redaction({
|
|
|
80
80
|
: 'not-supported';
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
async function observerTarget(reader, initial, notes) {
|
|
84
|
+
const { targetId } = await createObserverTarget(
|
|
85
|
+
reader,
|
|
86
|
+
{ url: initial, background: true },
|
|
87
|
+
notes
|
|
88
|
+
);
|
|
89
|
+
const attached = await outcome(reader.send('Target.attachToTarget', { targetId, flatten: true }));
|
|
90
|
+
if (attached.ok) return { targetId, sessionId: attached.result.sessionId };
|
|
91
|
+
await outcome(reader.send('Target.closeTarget', { targetId }));
|
|
92
|
+
throw new Error(attached.error.message);
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
async function seed(reader, observer, loaded, initial, allowed) {
|
|
84
96
|
const out = await outcome(
|
|
85
97
|
(async () => {
|
|
@@ -119,17 +131,13 @@ export async function protectedErrorPage({
|
|
|
119
131
|
const missing = (reason) => ({ ...result, incomplete: `protected-error-page: ${reason}` });
|
|
120
132
|
if (!discovery) notes.push('eventsRedacted: not-supported:discovery');
|
|
121
133
|
const start = client.events.length;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
);
|
|
134
|
+
let cursor = reader.events.length;
|
|
135
|
+
const opened = await outcome(observerTarget(reader, initial, notes));
|
|
136
|
+
if (!opened.ok) return missing(`observer setup failed: ${opened.error.message}`);
|
|
137
|
+
const { targetId } = opened.result;
|
|
127
138
|
let session;
|
|
128
|
-
let observer;
|
|
139
|
+
let observer = opened.result.sessionId;
|
|
129
140
|
let failing = false;
|
|
130
|
-
let cursor = reader.events.length;
|
|
131
|
-
const { sessionId } = await reader.send('Target.attachToTarget', { targetId, flatten: true });
|
|
132
|
-
observer = sessionId;
|
|
133
141
|
internalSessions.add(observer);
|
|
134
142
|
const frame = async () => (await reader.send('Page.getFrameTree', {}, observer)).frameTree.frame;
|
|
135
143
|
const loaded = (url) => loadedDocument(reader, observer, url, until);
|
package/redirect-secret.mjs
CHANGED
|
@@ -89,6 +89,7 @@ class RedirectProbe {
|
|
|
89
89
|
this.attempts = [];
|
|
90
90
|
this.jobs = new Set();
|
|
91
91
|
this.networkBodies = new Set();
|
|
92
|
+
this.learned = { network: new Set(), fetch: new Set(), tap: new Set() };
|
|
92
93
|
this.registrations = [];
|
|
93
94
|
this.seen = new Map();
|
|
94
95
|
this.protectedURLs = [
|
|
@@ -97,6 +98,7 @@ class RedirectProbe {
|
|
|
97
98
|
`https://seven.sliccy.ai/auth/callback#access_token=${encodeURIComponent(this.marker)}&state=${encodeURIComponent(this.marker)}`,
|
|
98
99
|
`https://vault.sliccy.ai/oauth/mcp-callback#access_token=${encodeURIComponent(this.marker)}`,
|
|
99
100
|
];
|
|
101
|
+
this.prefix = new URL(`${this.ordinary}/redirect-secret/${this.tag}-`).href;
|
|
100
102
|
this.plainURL = `${this.ordinary}/redirect-secret-body/${this.tag}-plain?code=${encodeURIComponent(this.marker)}`;
|
|
101
103
|
this.destinations =
|
|
102
104
|
this.side === 'control'
|
|
@@ -119,6 +121,7 @@ class RedirectProbe {
|
|
|
119
121
|
if (redirects.includes(event.params.responseStatusCode)) {
|
|
120
122
|
const out = await this.send('Fetch.getResponseBody', { requestId: event.params.requestId });
|
|
121
123
|
this.attempts.push({ method: 'Fetch.getResponseBody', ...out });
|
|
124
|
+
if (event.params.networkId) this.track(this.networkBody(event.params.networkId, 'fetch'));
|
|
122
125
|
}
|
|
123
126
|
if (
|
|
124
127
|
this.holdURL &&
|
|
@@ -139,19 +142,131 @@ class RedirectProbe {
|
|
|
139
142
|
if (event.method === 'Fetch.requestPaused') this.track(this.fetchPause(event));
|
|
140
143
|
const response = event.params?.redirectResponse ?? event.params?.response;
|
|
141
144
|
if (!response || !redirects.includes(response.status)) continue;
|
|
142
|
-
|
|
143
|
-
if (this.networkBodies.has(requestId)) continue;
|
|
144
|
-
this.networkBodies.add(requestId);
|
|
145
|
-
this.track(
|
|
146
|
-
this.send('Network.getResponseBody', { requestId }).then((out) => {
|
|
147
|
-
this.attempts.push({ method: 'Network.getResponseBody', ...out });
|
|
148
|
-
})
|
|
149
|
-
);
|
|
145
|
+
this.track(this.networkBody(event.params.requestId, 'network'));
|
|
150
146
|
}
|
|
151
147
|
await pause(5);
|
|
152
148
|
}
|
|
153
149
|
}
|
|
154
150
|
|
|
151
|
+
async networkBody(requestId, source) {
|
|
152
|
+
this.learned[source].add(requestId);
|
|
153
|
+
if (this.networkBodies.has(requestId)) return;
|
|
154
|
+
this.networkBodies.add(requestId);
|
|
155
|
+
const entry = { method: 'Network.getResponseBody', source, pending: true };
|
|
156
|
+
this.attempts.push(entry);
|
|
157
|
+
const out = await this.send('Network.getResponseBody', { requestId });
|
|
158
|
+
delete entry.pending;
|
|
159
|
+
Object.assign(entry, out);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async settle() {
|
|
163
|
+
for (let i = 0; i < 500 && this.jobs.size; i++) await pause(10);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
tap() {
|
|
167
|
+
for (const event of this.reader.events ?? []) {
|
|
168
|
+
if (event.sessionId !== this.observer) continue;
|
|
169
|
+
const response = event.params?.redirectResponse ?? event.params?.response;
|
|
170
|
+
if (response?.url?.startsWith(this.prefix) && redirects.includes(response.status))
|
|
171
|
+
this.track(this.networkBody(event.params.requestId, 'tap'));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
networkLeg() {
|
|
176
|
+
const carriers = [...this.networkBodies].map((id) =>
|
|
177
|
+
this.learned.network.has(id)
|
|
178
|
+
? 'network'
|
|
179
|
+
: this.learned.fetch.has(id)
|
|
180
|
+
? 'fetch-networkId'
|
|
181
|
+
: 'withheld+tap'
|
|
182
|
+
);
|
|
183
|
+
const path =
|
|
184
|
+
['withheld+tap', 'fetch-networkId', 'network'].find((carrier) =>
|
|
185
|
+
carriers.includes(carrier)
|
|
186
|
+
) ?? 'none';
|
|
187
|
+
const networkBodyCarriers = Object.fromEntries(
|
|
188
|
+
[...new Set(carriers)].map((carrier) => [
|
|
189
|
+
carrier,
|
|
190
|
+
carriers.filter((entry) => entry === carrier).length,
|
|
191
|
+
])
|
|
192
|
+
);
|
|
193
|
+
const attempts = this.attempts.filter((entry) => entry.method === 'Network.getResponseBody');
|
|
194
|
+
const refusedAll = attempts.length > 0 && attempts.every(refused);
|
|
195
|
+
const proven = path === 'network' || this.side !== 'guarded' || this.controls?.networkEvents;
|
|
196
|
+
const reason =
|
|
197
|
+
path === 'none'
|
|
198
|
+
? 'no network id for the redirect'
|
|
199
|
+
: proven
|
|
200
|
+
? undefined
|
|
201
|
+
: 'control did not prove the redirect Network events';
|
|
202
|
+
if (reason && (!attempts.length || refusedAll))
|
|
203
|
+
return {
|
|
204
|
+
networkBodyPath: path,
|
|
205
|
+
networkBodyCarriers,
|
|
206
|
+
networkBodyAttempted: 'incomplete',
|
|
207
|
+
networkBodyRefused: 'incomplete',
|
|
208
|
+
incomplete: `redirect-secret: network body leg ${reason}`,
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
networkBodyPath: path,
|
|
212
|
+
networkBodyCarriers,
|
|
213
|
+
networkBodyAttempted: attempts.length > 0,
|
|
214
|
+
networkBodyRefused: refusedAll,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
hops(events) {
|
|
219
|
+
const own = events.filter((event) => event.sessionId === this.session);
|
|
220
|
+
const seen = new Set(
|
|
221
|
+
own
|
|
222
|
+
.filter(
|
|
223
|
+
(event) =>
|
|
224
|
+
event.method === 'Network.requestWillBeSent' &&
|
|
225
|
+
(event.params.redirectResponse
|
|
226
|
+
? event.params.redirectResponse.url?.startsWith(this.prefix)
|
|
227
|
+
: event.params.request?.url?.startsWith(this.prefix))
|
|
228
|
+
)
|
|
229
|
+
.map((event) => event.params.requestId)
|
|
230
|
+
);
|
|
231
|
+
const protectedOrigins = this.protectedURLs.map((url) => new URL(url).origin);
|
|
232
|
+
const hops = Object.fromEntries(
|
|
233
|
+
[...seen].map((requestId) => {
|
|
234
|
+
const mine = own.filter((event) => event.params?.requestId === requestId);
|
|
235
|
+
const hop = mine.find(
|
|
236
|
+
(event) =>
|
|
237
|
+
event.method === 'Network.requestWillBeSent' &&
|
|
238
|
+
event.params.redirectResponse?.url?.startsWith(this.prefix)
|
|
239
|
+
);
|
|
240
|
+
const end = mine.find((event) =>
|
|
241
|
+
['Network.loadingFinished', 'Network.loadingFailed'].includes(event.method)
|
|
242
|
+
);
|
|
243
|
+
const url = hop?.params.request?.url ?? '';
|
|
244
|
+
const origin = protectedOrigins.find((value) => url.startsWith(value));
|
|
245
|
+
return [
|
|
246
|
+
requestId,
|
|
247
|
+
{
|
|
248
|
+
delivery: !hop
|
|
249
|
+
? end
|
|
250
|
+
? 'withheld'
|
|
251
|
+
: 'unterminated'
|
|
252
|
+
: !origin
|
|
253
|
+
? 'ordinary'
|
|
254
|
+
: [origin, `${origin}/`].includes(url)
|
|
255
|
+
? 'redacted'
|
|
256
|
+
: 'raw',
|
|
257
|
+
terminal: (hop ?? end)?.method ?? null,
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
const deliveries = Object.values(hops).map((entry) => entry.delivery);
|
|
263
|
+
return {
|
|
264
|
+
hops,
|
|
265
|
+
requestsTerminated: !deliveries.includes('unterminated'),
|
|
266
|
+
hopsRedacted: !deliveries.some((delivery) => ['raw', 'ordinary'].includes(delivery)),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
155
270
|
async register(key, to, code = 302) {
|
|
156
271
|
const url = `${this.ordinary}/redirect-secret/${this.tag}-${key}`;
|
|
157
272
|
const path = new URL(url).pathname;
|
|
@@ -186,6 +301,7 @@ class RedirectProbe {
|
|
|
186
301
|
).sessionId;
|
|
187
302
|
this.watching = this.watch();
|
|
188
303
|
await this.reader.send('Page.enable', {}, this.observer);
|
|
304
|
+
if (this.side === 'guarded') await this.reader.send('Network.enable', {}, this.observer);
|
|
189
305
|
await this.reader.send('Storage.setCookies', {
|
|
190
306
|
cookies: [
|
|
191
307
|
{
|
|
@@ -361,14 +477,23 @@ class RedirectProbe {
|
|
|
361
477
|
|
|
362
478
|
async evidence() {
|
|
363
479
|
await pause(8000);
|
|
480
|
+
this.tap();
|
|
481
|
+
if (this.side === 'control' && this.controls)
|
|
482
|
+
this.controls.networkEvents = this.learned.network.size > 0;
|
|
364
483
|
await this.send('Fetch.disable');
|
|
365
484
|
await this.reset('survive');
|
|
366
485
|
const survives = await this.send('Runtime.evaluate', {
|
|
367
486
|
expression: 'document.title',
|
|
368
487
|
returnByValue: true,
|
|
369
488
|
});
|
|
370
|
-
|
|
371
|
-
const
|
|
489
|
+
await this.settle();
|
|
490
|
+
const tapped = `"sessionId":"${this.observer}"`;
|
|
491
|
+
const events = this.client.events
|
|
492
|
+
.slice(this.eventsStart)
|
|
493
|
+
.filter((event) => event.sessionId !== this.observer);
|
|
494
|
+
const messages = (this.client.messages?.slice(this.messagesStart) ?? []).filter(
|
|
495
|
+
(message) => !message.includes(tapped)
|
|
496
|
+
);
|
|
372
497
|
const eventLeaks = await this.scan(events);
|
|
373
498
|
const ordinaryEvents = events.filter(
|
|
374
499
|
(event, index) =>
|
|
@@ -394,10 +519,7 @@ class RedirectProbe {
|
|
|
394
519
|
);
|
|
395
520
|
});
|
|
396
521
|
const found = (key) => this.responses.some((entry) => entry[key]);
|
|
397
|
-
const
|
|
398
|
-
const bodyRefused = (method) =>
|
|
399
|
-
this.attempts.filter((entry) => entry.method === method).length > 0 &&
|
|
400
|
-
this.attempts.filter((entry) => entry.method === method).every(refused);
|
|
522
|
+
const fetchAttempts = this.attempts.filter((entry) => entry.method === 'Fetch.getResponseBody');
|
|
401
523
|
return {
|
|
402
524
|
live: (await this.fixture.log()).some((entry) =>
|
|
403
525
|
entry.url.includes(`/redirect-secret/${this.tag}`)
|
|
@@ -412,10 +534,10 @@ class RedirectProbe {
|
|
|
412
534
|
),
|
|
413
535
|
secretHidden: !replyLeaks.includes(true),
|
|
414
536
|
sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
|
|
415
|
-
fetchBodyAttempted:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
537
|
+
fetchBodyAttempted: fetchAttempts.length > 0,
|
|
538
|
+
fetchBodyRefused: fetchAttempts.length > 0 && fetchAttempts.every(refused),
|
|
539
|
+
...this.networkLeg(),
|
|
540
|
+
...this.hops(events),
|
|
419
541
|
extraInPlace,
|
|
420
542
|
...redirectFragments({
|
|
421
543
|
messages,
|
|
@@ -476,7 +598,7 @@ class RedirectProbe {
|
|
|
476
598
|
async close() {
|
|
477
599
|
this.stopped = true;
|
|
478
600
|
await this.watching;
|
|
479
|
-
await
|
|
601
|
+
await this.settle();
|
|
480
602
|
await outcome(this.client.send('Fetch.disable', {}, this.session));
|
|
481
603
|
if (this.observer) {
|
|
482
604
|
await outcome(
|