@ai-ecoverse/slicc-browsers 1.18.1 → 1.18.4
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 +5 -5
- package/bfcache-restore.mjs +7 -17
- package/guard-runner.mjs +10 -3
- package/guard-vectors.json +1 -1
- package/package.json +3 -3
- package/protected-error-page.mjs +32 -2
- package/redirect-secret.mjs +185 -63
package/README.md
CHANGED
|
@@ -195,11 +195,11 @@ The `C9` foreign extension isolated-world family requires `extensionWorld`: a br
|
|
|
195
195
|
|
|
196
196
|
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.
|
|
197
197
|
|
|
198
|
-
`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. After each one it waits, at least 250 ms and up to two seconds scaled by `settleScale` (stall-tolerant), for the fixture to log the redirect URL and every request seen for it to end, before it navigates away. On the guarded leg, once one action's wait times out, the remaining actions skip it, so a guard that never terminates costs that bound once per leg. 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
|
|
198
|
+
`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. After each one it waits, at least 250 ms and up to two seconds scaled by `settleScale` (stall-tolerant), for the fixture to log the redirect URL and every request seen for it to end, before it navigates away. On the guarded leg, once one action's wait times out, the remaining actions skip it, so a guard that never terminates costs that bound once per leg. 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; only traffic proven to belong to the shared unguarded observer is excluded. The scan runs 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, and again after the terminal wait for reads that late events started. 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`. Before the evidence is evaluated, on every leg, the probe waits up to five seconds scaled by `settleScale` (stall-tolerant) for every seen request to reach one of those events. 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. On the guarded leg `requestsTerminated` is false on any `unterminated` request after that wait, a measured guard failure rather than missing evidence; on the unguarded control an `unterminated` request is missing evidence, so `requestsTerminated` reads `incomplete` and the probe returns `redirect-secret: control requests <ids> unterminated`; `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. Recovery and each following action use a fresh ordinary tab, never a tab whose history contains the protected destination. Each request's body reads stay on its owning client session; all probe tabs and observers remain alive through the eight-second tail and terminal/body-read waits. A closed body owner leaves that read `incomplete`, not `sessionEnded`; another live owner of the same request id is still read. A closed retained tab does not cut the tail or the remaining actions short. `sessionSurvives` proves the client can evaluate the title in a fresh ordinary tab. A content-command refusal in a protected-history tab is not itself a failure; every received message still enters the leak checks. Missing recovery evidence or an unseen override pause is `incomplete`, not a pass. A measured leak takes precedence over incomplete evidence. 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.
|
|
199
199
|
|
|
200
200
|
`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.
|
|
201
201
|
|
|
202
|
-
Every C2 redirect probe reports `sessionEnded`. It becomes true when the guarded client’s session ends during the probe: a `Target.detachedFromTarget` for that session,
|
|
202
|
+
Every C2 redirect probe reports `sessionEnded`. It becomes true when the guarded client’s active session ends during the probe: a `Target.detachedFromTarget` for that active session unless its owner is already known closed, a non-body CDP reply that the active session is closed or not found, or that the websocket or transport is closed. `Inspected target navigated or closed` is an ordinary reply on a live session, not a session end. When an expected Fetch pause or XHR event is missing, the probe sends one `Runtime.evaluate` to check that the session is still alive. Once the session has ended, the probe sends nothing more on it, skips the eight-second tail and the termination wait, and evaluates what the client received before the end. `secretHidden`, `protectedBodyHidden`, `cookieHidden`, `fragmentsHidden` and `hopsRedacted` are measured on those messages, replies, bodies and fixture logs, so a guard that leaked before the end still fails them. A clean end passes them. `sessionSurvives` reads false. Checks that need evidence from after the end read `incomplete`, with `redirect-secret: session ended (<how>)` in the probe’s `incomplete`. If the end cuts the probe’s action matrix short, or leaves any body read pending or unanswered, then `fetchBodyAttempted`, `fetchBodyRefused` and the network-body leg read `incomplete`, even after some refused reads. Any answer that isn’t a refusal still fails its refusal check. `requestsTerminated` reads `incomplete` when requests are unterminated or the matrix was cut short. A short but origin-only `extraLocation` reads `incomplete`. So does each attempt or override check whose command never reached the client: it wasn’t sent because the session had already ended, or its reply was the session end. On the unguarded control, a session end is an error: the probe throws `redirect-secret: control session ended (<how>)`.
|
|
203
203
|
|
|
204
204
|
`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.
|
|
205
205
|
|
|
@@ -207,9 +207,9 @@ Every C2 redirect probe reports `sessionEnded`. It becomes true when the guarded
|
|
|
207
207
|
|
|
208
208
|
The `C8.newtab` reachability control attaches through the guard to a settled ordinary new tab, including its internal frames, then navigates that same tab to an ordinary document. Both legs must succeed, including without a guard. It requires `chromeUrlsAttach`, so an extension transport reports it as `skip` with `not-supported:chromeUrlsAttach`. The C8 protection rows expect a refusal on the guarded leg and run their controls unguarded, so they don't require it and still run on extension transports. It uses the default browser context, since an incognito new tab omits the ordinary new tab's internal frames. It covers `chrome://newtab/` and directly opened `chrome://new-tab-page/`.
|
|
209
209
|
|
|
210
|
-
`C8.protected-bfcache-restore` and `.history` test history restoration on seven and vault, with both detach variants. The independent observer seeds ordinary A, protected P and ordinary B on one tab. `/bfcache?n=<tag>` has no unload handler: it retains a global and sessionStorage marker, records `pageshow.persisted` and counts key events. The
|
|
210
|
+
`C8.protected-bfcache-restore` and `.history` test history restoration on seven and vault, with both detach variants. The independent observer seeds ordinary A, protected P and ordinary B on one tab before the first client attach. `/bfcache?n=<tag>` has no unload handler: it retains a global and sessionStorage marker, records `pageshow.persisted` and counts key events. The attached client acknowledges B with `Page.getFrameTree` before the gate is armed; no client script is required. Missing acknowledgement leaves the affected leg and guarded liveness `incomplete`. The first row restores P with the observer's `Page.navigateToHistoryEntry`; `.history` sends that navigation through the client instead. A matched client navigation refusal lets the observer restore the same entry. The unguarded control must observe a real `BackForwardCacheRestore`, persisted pageshow, the marker and protected origin in evaluate, a touched global, and positive evidence from every probe method.
|
|
211
211
|
|
|
212
|
-
The rows require `inboundDelay`. The guarded client supplies `inboundDelay({ targetId, sessionId, ms, side, client, gate: true })`, a harness gate before the guard's inbound stream. It holds every browser-to-guard message until explicit release; guard-to-browser writes remain possible. The control gates only its unguarded client stream, not the independent observer session. Immediately before restoration the runner requests a gate with a 1500 ms minimum hold. The hook returns `{ targetId, sessionId, ms, gate: true, protectedObserved, ordinaryObserved, release, audit? }`. Both observed flags acknowledge
|
|
212
|
+
The rows require `inboundDelay`. The guarded client supplies `inboundDelay({ targetId, sessionId, ms, side, client, gate: true })`, a harness gate before the guard's inbound stream. It holds every browser-to-guard message until explicit release; guard-to-browser writes remain possible. The control gates only its unguarded client stream, not the independent observer session. Immediately before restoration the runner requests a gate with a 1500 ms minimum hold. The hook returns `{ targetId, sessionId, ms, gate: true, protectedObserved, ordinaryObserved, release, audit? }`. Both observed flags acknowledge the observer’s protected seed and subsequent ordinary B before injection. `release()` restores delivery and returns `{ delayedEvents, firstDelayedAt, releasedAt }`, counting held restore events and recording their first hold and actual release times. Missing capability or hook is `not-supported:inboundDelay`; an unacknowledged gate, absent seed acknowledgement or no held restore event is `incomplete`. A timer-only event delay does not meet this contract.
|
|
213
213
|
|
|
214
214
|
After the observer confirms the persisted protected document, the client repeatedly probes `Runtime.evaluate`, `Runtime.callFunctionOn`, `DOM.getDocument`, `Accessibility.getFullAXTree`, `Input.dispatchKeyEvent`, `Page.captureScreenshot` and `IO.read` for 2500 ms while the gate stays closed. Runtime probes write `__touched`; Input is measured by the fixture's key counter. The observer supplies a real network-resource stream for IO's positive control: the read must use that handle and show the document marker, including base64-encoded bodies. Screenshot controls must return PNG bytes captured while P is current that match the independent observer's capture of P; unrelated or merely nonempty data is not evidence. The guard must hide the marker from all received messages and events and hide the protected origin from every probe reply, not only Runtime replies, and leave both side effects absent on P. A returned screenshot while P is current fails, regardless of its encoded contents. Refused, answered and unanswered outcomes are retained individually; an unanswered call alone never proves protection. The observer measures P's marker, persisted state and side effects before and after release. Missing restore, fixture, hold or side-effect evidence is `incomplete`, even when every measured observation matches; measured leaks or side effects fail even beside missing evidence. After release the runner drains probe replies for up to 5000 ms, scaled by `settleScale`, before scanning them and judging screenshots. An unresolved reply needs a complete final forwarding ledger proving terminal non-forwarding; otherwise its missing measurement is `incomplete`. A measured failure takes precedence over incomplete evidence.
|
|
215
215
|
|
|
@@ -283,7 +283,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
283
283
|
|
|
284
284
|
`{ "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.
|
|
285
285
|
|
|
286
|
-
`{ "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 client’s `Page.navigate` to the trigger failed or was refused, 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`.
|
|
286
|
+
`{ "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`. Its `diagnostics` record the client’s `Target.attachToTarget` as `clientAttach`, whose `outcome` is `answered` (with `sessionId`, whether the reply carried one), `refused` or `error` (both with the `error` text), and, once the client has navigated, the error-commit wait as `errorCommit`: `seen`, `elapsedMs` when seen or at timeout, `budgetMs` with `settleScale` applied, and the last `frame` and `targetInfo` read. They change no verdict. The row carries them as `evidence.control.errorPage` and `evidence.guarded.errorPage`, and a guarded leg that is not live because the attach gave no session or the commit was not seen says why: `not live: client attach refused: <text>` (or `failed: <text>`, or `answered without a sessionId`), or `not live: error commit not seen after <elapsed>ms (budget <budget>ms): frame <url> unreachableUrl <url or none>, target <url>`. 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 client’s `Page.navigate` to the trigger failed or was refused, 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`.
|
|
287
287
|
|
|
288
288
|
`{ "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.
|
|
289
289
|
|
package/bfcache-restore.mjs
CHANGED
|
@@ -109,33 +109,23 @@ class Restore {
|
|
|
109
109
|
await this.read('Page.enable', {});
|
|
110
110
|
if (!(await this.loaded(`${this.ordinary}/bfcache?n=${this.tag}-a`)))
|
|
111
111
|
return this.missingEvidence('ordinary A did not commit');
|
|
112
|
-
if (!(await this.attach())) return this.missingEvidence('initial client attach unanswered');
|
|
113
|
-
await this.call('Page.enable', {});
|
|
114
112
|
await this.read('Page.navigate', { url: this.url });
|
|
115
113
|
if (!(await this.loaded(this.url)))
|
|
116
114
|
return this.missingEvidence('protected seed did not commit');
|
|
117
115
|
const state = await this.state();
|
|
118
116
|
if (state?.marker !== this.marker || state.stored !== this.marker)
|
|
119
117
|
return this.missingEvidence('protected fixture marker absent');
|
|
120
|
-
|
|
121
|
-
expression: 'location.origin',
|
|
122
|
-
returnByValue: true,
|
|
123
|
-
});
|
|
124
|
-
this.result.protectedLocked = refusal(lock);
|
|
125
|
-
this.result.seed = { protectedURL: this.url, lock };
|
|
118
|
+
this.result.seed = { protectedURL: this.url };
|
|
126
119
|
const b = `${this.ordinary}/bfcache?n=${this.tag}-b`;
|
|
127
120
|
await this.read('Page.navigate', { url: b });
|
|
128
121
|
if (!(await this.loaded(b))) return this.missingEvidence('ordinary B did not commit');
|
|
129
|
-
await
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
);
|
|
133
|
-
const live = (await this.attach())
|
|
134
|
-
? await this.call('Runtime.evaluate', { expression: 'location.href', returnByValue: true })
|
|
135
|
-
: { ok: false };
|
|
136
|
-
this.result.sessionLive = value(live) === b;
|
|
122
|
+
if (!(await this.attach())) return this.missingEvidence('initial client attach unanswered');
|
|
123
|
+
const live = await this.call('Page.getFrameTree', {});
|
|
124
|
+
const frame = live.result?.frameTree?.frame;
|
|
137
125
|
this.result.seed.ordinaryAnswer = live;
|
|
138
|
-
if (!
|
|
126
|
+
if (!(live.ok && frame?.url === b && !frame.unreachableUrl))
|
|
127
|
+
return this.missingEvidence('ordinary B acknowledgement unavailable');
|
|
128
|
+
this.result.sessionLive = true;
|
|
139
129
|
await this.call('Page.enable', {});
|
|
140
130
|
const history = await this.read('Page.getNavigationHistory', {});
|
|
141
131
|
this.entry = history.result?.entries?.find((item) => item.url === this.url);
|
package/guard-runner.mjs
CHANGED
|
@@ -753,6 +753,7 @@ class Execution {
|
|
|
753
753
|
side: this.side,
|
|
754
754
|
settleScale: this.scale,
|
|
755
755
|
controls: (this.clients.redirectSecretControls ??= {}),
|
|
756
|
+
internalSessions: this.internalSessions,
|
|
756
757
|
})
|
|
757
758
|
);
|
|
758
759
|
} finally {
|
|
@@ -1283,7 +1284,10 @@ class Execution {
|
|
|
1283
1284
|
if (!live && this.unmeasured(this.vector.liveCheck))
|
|
1284
1285
|
return { live: 'incomplete', liveWhy: 'incomplete' };
|
|
1285
1286
|
if (!live && !unseen.length) live = await this.poll(alive, 1500);
|
|
1286
|
-
if (!unseen.length || (live && !incomplete.length))
|
|
1287
|
+
if (!unseen.length || (live && !incomplete.length))
|
|
1288
|
+
return live
|
|
1289
|
+
? { live }
|
|
1290
|
+
: { live, liveWhy: this.calls[this.vector.liveCheck.result]?.result?.liveWhy };
|
|
1287
1291
|
const detail = incomplete.length ? ` (${incomplete.join(', ')} incomplete)` : '';
|
|
1288
1292
|
return { live: false, liveWhy: `pre beacon not observed for ${unseen.join(', ')}${detail}` };
|
|
1289
1293
|
}
|
|
@@ -1321,8 +1325,10 @@ class Execution {
|
|
|
1321
1325
|
incomplete: this.incomplete,
|
|
1322
1326
|
evidence: Object.fromEntries(
|
|
1323
1327
|
Object.entries(this.calls)
|
|
1324
|
-
.filter(
|
|
1325
|
-
|
|
1328
|
+
.filter(
|
|
1329
|
+
([, out]) => (out?.result?.delay && out.result.probes) || out?.result?.diagnostics
|
|
1330
|
+
)
|
|
1331
|
+
.map(([name, out]) => [name, out.result.diagnostics ?? out.result])
|
|
1326
1332
|
),
|
|
1327
1333
|
};
|
|
1328
1334
|
} finally {
|
|
@@ -1746,6 +1752,7 @@ function settleIncomplete(row, vector, base, legs) {
|
|
|
1746
1752
|
control: fill('control'),
|
|
1747
1753
|
guarded: fill('guarded'),
|
|
1748
1754
|
live: legs.control.live === true && [true, 'incomplete'].includes(legs.guarded.live),
|
|
1755
|
+
liveWhy: legs.guarded.liveWhy,
|
|
1749
1756
|
});
|
|
1750
1757
|
for (const side of ['control', 'guarded'])
|
|
1751
1758
|
if (legs[side].incomplete?.length && filled[side] === 'pass') row[side] = 'incomplete';
|
package/guard-vectors.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-browsers",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.4",
|
|
4
4
|
"description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"validate": "node scripts/validate.mjs",
|
|
69
69
|
"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",
|
|
70
70
|
"format": "biome check --write . && gofmt -w . && cargo fmt",
|
|
71
|
-
"test": "npm run validate && node --test --test-concurrency=1 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/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs test/integration/guard-frame-read.test.mjs test/integration/guard-bfcache.test.mjs test/integration/guard-bfcache-review.test.mjs test/integration/guard-error-page-navigation.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
71
|
+
"test": "npm run validate && node --test --test-concurrency=1 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/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-redirect-history.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs test/integration/guard-frame-read.test.mjs test/integration/guard-bfcache.test.mjs test/integration/guard-bfcache-review.test.mjs test/integration/guard-bfcache-quarantine.test.mjs test/integration/guard-error-page-navigation.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
72
72
|
"test:unit": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-concurrency=1 --test-reporter=lcov --test-reporter-destination=coverage/unit/lcov.info test/unit/*.test.mjs && go test -race -cover ./...",
|
|
73
73
|
"build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
|
|
74
74
|
"trust": "npx fledgling sync -y --skip-publish",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"test:guard-sweep": "node scripts/test-guards.mjs",
|
|
80
80
|
"test:gates": "node --test test/integration/gates.test.mjs",
|
|
81
81
|
"test:guard-target": "node --test test/integration/guard-runner.test.mjs",
|
|
82
|
-
"test:unit:redirect": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-coverage-include=redirect-secret.mjs --test-coverage-include=redirect-fragments.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/redirect.lcov test/unit/guard-redirect-*.test.mjs && slicc-diff-cover coverage/unit/redirect.lcov",
|
|
82
|
+
"test:unit:redirect": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-concurrency=1 --test-coverage-include=redirect-secret.mjs --test-coverage-include=redirect-fragments.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/redirect.lcov test/unit/guard-redirect-*.test.mjs && slicc-diff-cover coverage/unit/redirect.lcov",
|
|
83
83
|
"test:unit:guard": "mkdir -p coverage/unit && node --experimental-vm-modules --no-warnings --experimental-test-coverage --test --test-concurrency=1 --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-include=fixture-client.mjs --test-coverage-include=observer-target.mjs --test-coverage-include=frame-read.mjs --test-coverage-include=bfcache-restore.mjs --test-coverage-include=protected-error-page.mjs --test-coverage-include=unparseable-url.mjs --test-coverage-include=scripts/browser-runner.mjs --test-coverage-include=scripts/validate.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/guard.lcov test/unit/guard-*.test.mjs && slicc-diff-cover coverage/unit/guard.lcov"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
package/protected-error-page.mjs
CHANGED
|
@@ -4,6 +4,22 @@ import { createObserverTarget } from './observer-target.mjs';
|
|
|
4
4
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
5
|
const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
|
|
6
6
|
|
|
7
|
+
function attachOutcome(out) {
|
|
8
|
+
if (out.ok) return { outcome: 'answered', sessionId: Boolean(out.result?.sessionId) };
|
|
9
|
+
return {
|
|
10
|
+
outcome: refused(out) ? 'refused' : 'error',
|
|
11
|
+
error: String(out.error?.message ?? out.error),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const attachWhy = ({ outcome: kind, error }) =>
|
|
16
|
+
kind === 'answered'
|
|
17
|
+
? 'client attach answered without a sessionId'
|
|
18
|
+
: `client attach ${kind === 'refused' ? 'refused' : 'failed'}: ${error}`;
|
|
19
|
+
|
|
20
|
+
const commitWhy = ({ elapsedMs, budgetMs, frame, targetInfo }) =>
|
|
21
|
+
`error commit not seen after ${elapsedMs}ms (budget ${budgetMs}ms): frame ${frame.url} unreachableUrl ${frame.unreachableUrl ?? 'none'}, target ${targetInfo.url}`;
|
|
22
|
+
|
|
7
23
|
async function loadedDocument(reader, observer, url, until) {
|
|
8
24
|
return until(async () => {
|
|
9
25
|
const { frameTree } = await reader.send('Page.getFrameTree', {}, observer);
|
|
@@ -197,7 +213,14 @@ export async function protectedErrorPage({
|
|
|
197
213
|
client.send('Target.attachToTarget', { targetId, flatten: true })
|
|
198
214
|
);
|
|
199
215
|
session = attached.result?.sessionId;
|
|
200
|
-
|
|
216
|
+
result.diagnostics = { clientAttach: attachOutcome(attached) };
|
|
217
|
+
if (!session)
|
|
218
|
+
return {
|
|
219
|
+
...result,
|
|
220
|
+
live: false,
|
|
221
|
+
attached: false,
|
|
222
|
+
liveWhy: attachWhy(result.diagnostics.clientAttach),
|
|
223
|
+
};
|
|
201
224
|
await client.send('Page.enable', {}, session);
|
|
202
225
|
await client.send('Runtime.enable', {}, session);
|
|
203
226
|
if (discovery) await client.send('Target.setDiscoverTargets', { discover: true });
|
|
@@ -212,6 +235,7 @@ export async function protectedErrorPage({
|
|
|
212
235
|
const unsent = await scripted(client, session, ordinary, failedURL);
|
|
213
236
|
if (unsent) return missing(unsent);
|
|
214
237
|
let evidence;
|
|
238
|
+
const waited = Date.now();
|
|
215
239
|
const live = await until(async () => {
|
|
216
240
|
const f = await frame();
|
|
217
241
|
const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId });
|
|
@@ -224,10 +248,16 @@ export async function protectedErrorPage({
|
|
|
224
248
|
);
|
|
225
249
|
});
|
|
226
250
|
result.evidence = evidence;
|
|
251
|
+
result.diagnostics.errorCommit = {
|
|
252
|
+
seen: live,
|
|
253
|
+
elapsedMs: Date.now() - waited,
|
|
254
|
+
budgetMs: budget,
|
|
255
|
+
...evidence,
|
|
256
|
+
};
|
|
227
257
|
if (!live && side === 'control')
|
|
228
258
|
return missing(`missing error commit ${JSON.stringify(evidence)}`);
|
|
229
259
|
result.live = live;
|
|
230
|
-
if (!live) return result;
|
|
260
|
+
if (!live) return { ...result, liveWhy: commitWhy(result.diagnostics.errorCommit) };
|
|
231
261
|
await redaction({ client, targetId, failedURL, protectedOrigin, start, result, discovery });
|
|
232
262
|
const beforeReload = (await frame()).loaderId;
|
|
233
263
|
result.reload = (await outcome(client.send('Page.reload', {}, session))).ok;
|
package/redirect-secret.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { load, outcome, waitFor } from './guard-driver.mjs';
|
|
2
|
+
import { createObserverTarget } from './observer-target.mjs';
|
|
2
3
|
import { redirectFragments } from './redirect-fragments.mjs';
|
|
3
4
|
|
|
4
5
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -26,6 +27,12 @@ const gone = (out) =>
|
|
|
26
27
|
/session with given id not found|no session with given id|\bsession (?:has been |is |was )?(?:closed|detached)|\btarget closed|target page, context or browser has been closed|\b(?:cdp|connection|transport|websocket|socket) (?:has been |is |was )?closed|websocket is not open/i.test(
|
|
27
28
|
out.error?.message ?? ''
|
|
28
29
|
);
|
|
30
|
+
const connectionGone = (out) =>
|
|
31
|
+
!out.ok &&
|
|
32
|
+
/\b(?:cdp|connection|transport|websocket|socket) (?:has been |is |was )?closed|websocket is not open/i.test(
|
|
33
|
+
out.error?.message ?? ''
|
|
34
|
+
);
|
|
35
|
+
const bodyRead = (method) => /^(?:Network|Fetch)\.getResponseBody$/.test(method);
|
|
29
36
|
const redirects = [301, 302, 303, 307, 308];
|
|
30
37
|
const redirectHeaders = (to) => ({
|
|
31
38
|
location: to,
|
|
@@ -98,6 +105,11 @@ class RedirectProbe {
|
|
|
98
105
|
this.networkBodies = new Set();
|
|
99
106
|
this.learned = { network: new Set(), fetch: new Set(), tap: new Set() };
|
|
100
107
|
this.registrations = [];
|
|
108
|
+
this.sessions = new Map([[this.session, undefined]]);
|
|
109
|
+
this.observers = new Set();
|
|
110
|
+
this.closedSessions = new Set();
|
|
111
|
+
this.targets = new Set();
|
|
112
|
+
this.missing = [];
|
|
101
113
|
this.seen = new Map();
|
|
102
114
|
this.scale = this.settleScale ?? 1;
|
|
103
115
|
this.protectedURLs = [
|
|
@@ -118,14 +130,21 @@ class RedirectProbe {
|
|
|
118
130
|
const events = this.client.events;
|
|
119
131
|
for (; !this.end && this.detachScan < events.length; this.detachScan++) {
|
|
120
132
|
const event = events[this.detachScan];
|
|
121
|
-
if (
|
|
122
|
-
|
|
133
|
+
if (
|
|
134
|
+
event.method === 'Target.detachedFromTarget' &&
|
|
135
|
+
this.sessions.has(event.params?.sessionId)
|
|
136
|
+
) {
|
|
137
|
+
const session = event.params.sessionId;
|
|
138
|
+
if (session === this.session && !this.closedSessions.has(session))
|
|
139
|
+
this.end = 'Target.detachedFromTarget';
|
|
140
|
+
else this.closedSessions.add(session);
|
|
141
|
+
}
|
|
123
142
|
}
|
|
124
143
|
return Boolean(this.end);
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
cut() {
|
|
128
|
-
return Boolean(this.end) && !this.complete;
|
|
147
|
+
return Boolean(this.end || this.halted) && !this.complete;
|
|
129
148
|
}
|
|
130
149
|
|
|
131
150
|
async lost() {
|
|
@@ -135,20 +154,35 @@ class RedirectProbe {
|
|
|
135
154
|
|
|
136
155
|
answers(method) {
|
|
137
156
|
const all = this.attempts.filter((entry) => entry.method === method);
|
|
138
|
-
const
|
|
157
|
+
const missing = (entry) =>
|
|
158
|
+
entry.incomplete ||
|
|
159
|
+
(entry.pending && this.closedSessions.has(entry.session)) ||
|
|
160
|
+
(this.end && (entry.pending || entry.ended));
|
|
161
|
+
const answered = all.filter((entry) => !missing(entry));
|
|
139
162
|
const short =
|
|
140
|
-
|
|
163
|
+
all.some(missing) ||
|
|
164
|
+
(Boolean(this.end || this.halted) &&
|
|
165
|
+
(!this.complete || !answered.length || answered.length < all.length));
|
|
141
166
|
return { answered, short };
|
|
142
167
|
}
|
|
143
168
|
|
|
144
|
-
async send(method, params = {}) {
|
|
169
|
+
async send(method, params = {}, session = this.session) {
|
|
145
170
|
if (this.ended())
|
|
146
171
|
return { ok: false, ended: true, error: { message: `session ended (${this.end})` } };
|
|
147
|
-
const out =
|
|
172
|
+
const out = this.closedSessions.has(session)
|
|
173
|
+
? { ok: false, incomplete: true, error: { message: 'owning tab closed' } }
|
|
174
|
+
: await outcome(this.client.send(method, params, session));
|
|
148
175
|
if (gone(out)) {
|
|
149
|
-
this.
|
|
150
|
-
|
|
176
|
+
if (!connectionGone(out) && (bodyRead(method) || session !== this.session)) {
|
|
177
|
+
this.closedSessions.add(session);
|
|
178
|
+
out.incomplete = true;
|
|
179
|
+
} else {
|
|
180
|
+
this.end ??= out.error.message;
|
|
181
|
+
return { ...out, ended: true };
|
|
182
|
+
}
|
|
151
183
|
}
|
|
184
|
+
if (bodyRead(method) && out.incomplete)
|
|
185
|
+
this.missing.push(`redirect-secret: ${method} ${params.requestId} owning tab closed`);
|
|
152
186
|
this.responses.push({ method, ...out });
|
|
153
187
|
return out;
|
|
154
188
|
}
|
|
@@ -160,11 +194,24 @@ class RedirectProbe {
|
|
|
160
194
|
|
|
161
195
|
async fetchPause(event) {
|
|
162
196
|
if (redirects.includes(event.params.responseStatusCode)) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
197
|
+
const entry = {
|
|
198
|
+
method: 'Fetch.getResponseBody',
|
|
199
|
+
session: event.sessionId,
|
|
200
|
+
pending: true,
|
|
201
|
+
};
|
|
202
|
+
this.attempts.push(entry);
|
|
203
|
+
const out = await this.send(
|
|
204
|
+
'Fetch.getResponseBody',
|
|
205
|
+
{ requestId: event.params.requestId },
|
|
206
|
+
event.sessionId
|
|
207
|
+
);
|
|
208
|
+
delete entry.pending;
|
|
209
|
+
Object.assign(entry, out);
|
|
210
|
+
if (event.params.networkId)
|
|
211
|
+
this.track(this.networkBody(event.params.networkId, 'fetch', event.sessionId));
|
|
166
212
|
}
|
|
167
213
|
if (
|
|
214
|
+
event.sessionId === this.session &&
|
|
168
215
|
this.holdURL &&
|
|
169
216
|
event.params.request.url === this.holdURL &&
|
|
170
217
|
!event.params.responseStatusCode
|
|
@@ -172,30 +219,35 @@ class RedirectProbe {
|
|
|
172
219
|
this.held = event;
|
|
173
220
|
return;
|
|
174
221
|
}
|
|
175
|
-
await this.send(
|
|
222
|
+
await this.send(
|
|
223
|
+
'Fetch.continueRequest',
|
|
224
|
+
{ requestId: event.params.requestId },
|
|
225
|
+
event.sessionId
|
|
226
|
+
);
|
|
176
227
|
}
|
|
177
228
|
|
|
178
229
|
async watch() {
|
|
179
230
|
while (!this.stopped) {
|
|
180
231
|
for (const event of this.client.events.slice(this.cursor)) {
|
|
181
232
|
this.cursor++;
|
|
182
|
-
if (event.sessionId
|
|
233
|
+
if (!this.sessions.has(event.sessionId)) continue;
|
|
183
234
|
if (event.method === 'Fetch.requestPaused') this.track(this.fetchPause(event));
|
|
184
235
|
const response = event.params?.redirectResponse ?? event.params?.response;
|
|
185
236
|
if (!response || !redirects.includes(response.status)) continue;
|
|
186
|
-
this.track(this.networkBody(event.params.requestId, 'network'));
|
|
237
|
+
this.track(this.networkBody(event.params.requestId, 'network', event.sessionId));
|
|
187
238
|
}
|
|
188
239
|
await pause(5);
|
|
189
240
|
}
|
|
190
241
|
}
|
|
191
242
|
|
|
192
|
-
async networkBody(requestId, source) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.networkBodies.
|
|
196
|
-
|
|
243
|
+
async networkBody(requestId, source, session) {
|
|
244
|
+
const key = `${session}:${requestId}`;
|
|
245
|
+
this.learned[source].add(key);
|
|
246
|
+
if (this.networkBodies.has(key) || this.ended()) return;
|
|
247
|
+
this.networkBodies.add(key);
|
|
248
|
+
const entry = { method: 'Network.getResponseBody', source, session, pending: true };
|
|
197
249
|
this.attempts.push(entry);
|
|
198
|
-
const out = await this.send('Network.getResponseBody', { requestId });
|
|
250
|
+
const out = await this.send('Network.getResponseBody', { requestId }, session);
|
|
199
251
|
delete entry.pending;
|
|
200
252
|
Object.assign(entry, out);
|
|
201
253
|
}
|
|
@@ -206,10 +258,11 @@ class RedirectProbe {
|
|
|
206
258
|
|
|
207
259
|
tap() {
|
|
208
260
|
for (const event of this.reader.events ?? []) {
|
|
209
|
-
|
|
261
|
+
const session = [...this.sessions].find(([, observer]) => observer === event.sessionId)?.[0];
|
|
262
|
+
if (!session) continue;
|
|
210
263
|
const response = event.params?.redirectResponse ?? event.params?.response;
|
|
211
264
|
if (response?.url?.startsWith(this.prefix) && redirects.includes(response.status))
|
|
212
|
-
this.track(this.networkBody(event.params.requestId, 'tap'));
|
|
265
|
+
this.track(this.networkBody(event.params.requestId, 'tap', session));
|
|
213
266
|
}
|
|
214
267
|
}
|
|
215
268
|
|
|
@@ -240,7 +293,7 @@ class RedirectProbe {
|
|
|
240
293
|
: !proven
|
|
241
294
|
? 'control did not prove the redirect Network events'
|
|
242
295
|
: short
|
|
243
|
-
?
|
|
296
|
+
? 'not every network body read was answered on a live owner'
|
|
244
297
|
: undefined;
|
|
245
298
|
if (reason && (!attempts.length || refusedAll))
|
|
246
299
|
return {
|
|
@@ -259,7 +312,7 @@ class RedirectProbe {
|
|
|
259
312
|
}
|
|
260
313
|
|
|
261
314
|
requests(events, match = (url) => url?.startsWith(this.prefix)) {
|
|
262
|
-
const own = events.filter((event) => event.sessionId
|
|
315
|
+
const own = events.filter((event) => this.sessions.has(event.sessionId));
|
|
263
316
|
const seen = new Set(
|
|
264
317
|
own
|
|
265
318
|
.filter(
|
|
@@ -347,7 +400,7 @@ class RedirectProbe {
|
|
|
347
400
|
const who = this.end ? `session ended (${this.end}) with requests` : 'control requests';
|
|
348
401
|
const detail = open.length
|
|
349
402
|
? `${who} ${open.join(', ')} unterminated`
|
|
350
|
-
:
|
|
403
|
+
: 'probe ended before every request was issued';
|
|
351
404
|
return {
|
|
352
405
|
hops,
|
|
353
406
|
requestsTerminated: missing ? 'incomplete' : !open.length,
|
|
@@ -372,7 +425,36 @@ class RedirectProbe {
|
|
|
372
425
|
}
|
|
373
426
|
|
|
374
427
|
async reset(suffix = 'reset') {
|
|
375
|
-
|
|
428
|
+
if (this.ended() || this.halted) return false;
|
|
429
|
+
const recovered = await outcome(
|
|
430
|
+
(async () => {
|
|
431
|
+
const { targetId } = await createObserverTarget(this.reader, { url: 'about:blank' });
|
|
432
|
+
this.targets.add(targetId);
|
|
433
|
+
const { sessionId: observer } = await this.reader.send('Target.attachToTarget', {
|
|
434
|
+
targetId,
|
|
435
|
+
flatten: true,
|
|
436
|
+
});
|
|
437
|
+
if (!observer) throw new Error('ordinary observer session missing');
|
|
438
|
+
this.observers.add(observer);
|
|
439
|
+
await this.reader.send('Page.enable', {}, observer);
|
|
440
|
+
if (this.side === 'guarded') await this.reader.send('Network.enable', {}, observer);
|
|
441
|
+
await load(this.reader, observer, `${this.ordinary}/doc?n=${this.tag}-${suffix}`);
|
|
442
|
+
const { sessionId } = await this.client.send('Target.attachToTarget', {
|
|
443
|
+
targetId,
|
|
444
|
+
flatten: true,
|
|
445
|
+
});
|
|
446
|
+
if (!sessionId) throw new Error('ordinary client session missing');
|
|
447
|
+
this.sessions.set(sessionId, observer);
|
|
448
|
+
this.internalSessions?.add(sessionId);
|
|
449
|
+
this.session = sessionId;
|
|
450
|
+
await this.enable();
|
|
451
|
+
})()
|
|
452
|
+
);
|
|
453
|
+
if (!recovered.ok) {
|
|
454
|
+
this.halted = true;
|
|
455
|
+
this.missing.push(`redirect-secret: ordinary tab recovery ${recovered.error.message}`);
|
|
456
|
+
}
|
|
457
|
+
return recovered.ok;
|
|
376
458
|
}
|
|
377
459
|
|
|
378
460
|
contains(value) {
|
|
@@ -395,6 +477,8 @@ class RedirectProbe {
|
|
|
395
477
|
this.observer = (
|
|
396
478
|
await this.reader.send('Target.attachToTarget', { targetId: this.targetId, flatten: true })
|
|
397
479
|
).sessionId;
|
|
480
|
+
this.sessions.set(this.session, this.observer);
|
|
481
|
+
this.observers.add(this.observer);
|
|
398
482
|
this.watching = this.watch();
|
|
399
483
|
await this.reader.send('Page.enable', {}, this.observer);
|
|
400
484
|
if (this.side === 'guarded') await this.reader.send('Network.enable', {}, this.observer);
|
|
@@ -412,7 +496,7 @@ class RedirectProbe {
|
|
|
412
496
|
});
|
|
413
497
|
}
|
|
414
498
|
|
|
415
|
-
async
|
|
499
|
+
async enable() {
|
|
416
500
|
for (const domain of ['Page', 'Network', 'Runtime', 'Log', 'Audits'])
|
|
417
501
|
await this.send(`${domain}.enable`);
|
|
418
502
|
await this.send('Fetch.enable', {
|
|
@@ -421,6 +505,10 @@ class RedirectProbe {
|
|
|
421
505
|
{ urlPattern: '*', requestStage: 'Response' },
|
|
422
506
|
],
|
|
423
507
|
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async setup() {
|
|
511
|
+
await this.enable();
|
|
424
512
|
for (const to of [...this.protectedURLs, this.plainURL]) {
|
|
425
513
|
const url = new URL(to);
|
|
426
514
|
if (url.hash) continue;
|
|
@@ -433,13 +521,12 @@ class RedirectProbe {
|
|
|
433
521
|
async headers() {
|
|
434
522
|
for (const [index, to] of this.destinations.entries()) {
|
|
435
523
|
for (const kind of ['navigate', 'href', 'fetch', 'xhr']) {
|
|
436
|
-
if (this.ended()) return;
|
|
524
|
+
if (this.ended() || this.halted) return;
|
|
437
525
|
const code = this.mode === 'mix' ? [301, 303, 307, 308][index] : 302;
|
|
438
526
|
const url = await this.register(`${index}-${kind}`, to, code);
|
|
439
527
|
const start = this.client.events.length;
|
|
440
528
|
if (kind === 'navigate') await this.send('Page.navigate', { url });
|
|
441
529
|
else {
|
|
442
|
-
await this.reset();
|
|
443
530
|
const expression =
|
|
444
531
|
kind === 'href'
|
|
445
532
|
? `location.href=${JSON.stringify(url)};void 0`
|
|
@@ -495,7 +582,7 @@ class RedirectProbe {
|
|
|
495
582
|
}
|
|
496
583
|
|
|
497
584
|
async override(url) {
|
|
498
|
-
await this.reset('override');
|
|
585
|
+
if (!(await this.reset('override'))) return;
|
|
499
586
|
this.holdURL = `${this.ordinary}/data?n=${this.tag}-override`;
|
|
500
587
|
this.held = undefined;
|
|
501
588
|
await this.send('Runtime.evaluate', {
|
|
@@ -505,7 +592,9 @@ class RedirectProbe {
|
|
|
505
592
|
if (!this.held) {
|
|
506
593
|
this.holdURL = undefined;
|
|
507
594
|
if (await this.lost()) return;
|
|
508
|
-
|
|
595
|
+
this.overrideIncomplete = true;
|
|
596
|
+
this.missing.push('redirect-secret: override pause not observed');
|
|
597
|
+
return;
|
|
509
598
|
}
|
|
510
599
|
const requestId = this.held.params.requestId;
|
|
511
600
|
const out = await this.send('Fetch.continueRequest', { requestId, url });
|
|
@@ -568,13 +657,13 @@ class RedirectProbe {
|
|
|
568
657
|
() => this.iframe(),
|
|
569
658
|
() => this.override(url),
|
|
570
659
|
]) {
|
|
571
|
-
if (this.ended()) return;
|
|
660
|
+
if (this.ended() || this.halted) return;
|
|
572
661
|
await step();
|
|
573
662
|
}
|
|
574
663
|
}
|
|
575
664
|
|
|
576
665
|
async iframe() {
|
|
577
|
-
await this.reset('iframe');
|
|
666
|
+
if (!(await this.reset('iframe'))) return;
|
|
578
667
|
const url = await this.register('iframe', this.destinations[1]);
|
|
579
668
|
await this.send('Runtime.evaluate', {
|
|
580
669
|
expression: `(()=>{const f=document.createElement('iframe');f.src=${JSON.stringify(url)};document.body.append(f)})();void 0`,
|
|
@@ -590,25 +679,50 @@ class RedirectProbe {
|
|
|
590
679
|
});
|
|
591
680
|
}
|
|
592
681
|
|
|
682
|
+
observerTraffic(value) {
|
|
683
|
+
return (
|
|
684
|
+
this.client === this.reader &&
|
|
685
|
+
this.observers.has(value?.sessionId) &&
|
|
686
|
+
!this.sessions.has(value?.sessionId)
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
observerMessage(message) {
|
|
691
|
+
try {
|
|
692
|
+
return this.observerTraffic(JSON.parse(message));
|
|
693
|
+
} catch {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
overrideCheck(key, unseen) {
|
|
699
|
+
const overrides = this.responses.filter((entry) => entry.overrideMethod);
|
|
700
|
+
if (!overrides.length) return this.overrideIncomplete ? 'incomplete' : unseen;
|
|
701
|
+
return overrides.every((entry) => entry[key]);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async survival() {
|
|
705
|
+
for (const session of this.sessions.keys()) await this.send('Fetch.disable', {}, session);
|
|
706
|
+
if (!(await this.reset('survive'))) return { ok: false };
|
|
707
|
+
return this.send('Runtime.evaluate', {
|
|
708
|
+
expression: 'document.title',
|
|
709
|
+
returnByValue: true,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
593
713
|
async evidence() {
|
|
594
714
|
if (!this.ended()) await pause(8000);
|
|
595
715
|
if (!this.ended()) this.tap();
|
|
596
716
|
if (this.side === 'control' && this.controls)
|
|
597
717
|
this.controls.networkEvents = this.learned.network.size > 0;
|
|
598
|
-
await this.
|
|
599
|
-
await this.reset('survive');
|
|
600
|
-
const survives = await this.send('Runtime.evaluate', {
|
|
601
|
-
expression: 'document.title',
|
|
602
|
-
returnByValue: true,
|
|
603
|
-
});
|
|
718
|
+
const survives = await this.survival();
|
|
604
719
|
await this.settle();
|
|
605
720
|
await this.terminated();
|
|
606
|
-
const tapped = `"sessionId":"${this.observer}"`;
|
|
607
721
|
const events = this.client.events
|
|
608
722
|
.slice(this.eventsStart)
|
|
609
|
-
.filter((event) =>
|
|
723
|
+
.filter((event) => !this.observerTraffic(event));
|
|
610
724
|
const messages = (this.client.messages?.slice(this.messagesStart) ?? []).filter(
|
|
611
|
-
(message) => !
|
|
725
|
+
(message) => !this.observerMessage(message)
|
|
612
726
|
);
|
|
613
727
|
const eventLeaks = await this.scan(events);
|
|
614
728
|
const ordinaryEvents = events.filter(
|
|
@@ -619,7 +733,7 @@ class RedirectProbe {
|
|
|
619
733
|
const replyLeaks = await this.scan(replies);
|
|
620
734
|
const extra = events.filter(
|
|
621
735
|
(event) =>
|
|
622
|
-
event.sessionId
|
|
736
|
+
this.sessions.has(event.sessionId) &&
|
|
623
737
|
event.method === 'Network.responseReceivedExtraInfo' &&
|
|
624
738
|
redirects.includes(event.params.statusCode)
|
|
625
739
|
);
|
|
@@ -634,12 +748,11 @@ class RedirectProbe {
|
|
|
634
748
|
});
|
|
635
749
|
const extraInPlace =
|
|
636
750
|
extraHeld &&
|
|
637
|
-
(extra.length >= this.destinations.length * 4 || (this.
|
|
638
|
-
const unseen = this.end ? 'incomplete' : false;
|
|
751
|
+
(extra.length >= this.destinations.length * 4 || (this.cut() ? 'incomplete' : false));
|
|
752
|
+
const unseen = this.end || this.halted ? 'incomplete' : false;
|
|
639
753
|
const found = (key) => this.responses.some((entry) => entry[key]);
|
|
640
754
|
const fetchReads = this.answers('Fetch.getResponseBody');
|
|
641
755
|
const fetchLeaked = fetchReads.answered.some((entry) => !refused(entry));
|
|
642
|
-
const overrides = this.responses.filter((entry) => entry.overrideMethod);
|
|
643
756
|
const evidence = {
|
|
644
757
|
live: (await this.fixture.log()).some((entry) =>
|
|
645
758
|
entry.url.includes(`/redirect-secret/${this.tag}`)
|
|
@@ -653,7 +766,9 @@ class RedirectProbe {
|
|
|
653
766
|
: (message.method ?? 'reply')
|
|
654
767
|
),
|
|
655
768
|
secretHidden: !replyLeaks.includes(true),
|
|
656
|
-
sessionSurvives:
|
|
769
|
+
sessionSurvives: this.halted
|
|
770
|
+
? 'incomplete'
|
|
771
|
+
: survives.ok && survives.result?.result?.value === 'doc',
|
|
657
772
|
sessionEnded: this.ended(),
|
|
658
773
|
fetchBodyAttempted: fetchReads.short ? 'incomplete' : fetchReads.answered.length > 0,
|
|
659
774
|
fetchBodyRefused: fetchLeaked
|
|
@@ -706,31 +821,38 @@ class RedirectProbe {
|
|
|
706
821
|
'ordinaryIframeDOM',
|
|
707
822
|
].map((key) => [key, found(key) || unseen])
|
|
708
823
|
),
|
|
709
|
-
overridesRefused:
|
|
710
|
-
|
|
711
|
-
: unseen,
|
|
712
|
-
overridesWorked: overrides.length ? overrides.every((entry) => entry.overrideWorked) : unseen,
|
|
824
|
+
overridesRefused: this.overrideCheck('overrideRefused', unseen),
|
|
825
|
+
overridesWorked: this.overrideCheck('overrideWorked', unseen),
|
|
713
826
|
};
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
827
|
+
const incomplete = [evidence.incomplete, ...this.missing];
|
|
828
|
+
if (this.end && Object.values(evidence).includes('incomplete'))
|
|
829
|
+
incomplete.push(`redirect-secret: session ended (${this.end})`);
|
|
830
|
+
return incomplete.some(Boolean)
|
|
831
|
+
? { ...evidence, incomplete: incomplete.filter(Boolean).join('; ') }
|
|
832
|
+
: evidence;
|
|
717
833
|
}
|
|
718
834
|
|
|
719
835
|
async close() {
|
|
720
836
|
this.stopped = true;
|
|
721
837
|
await this.watching;
|
|
722
838
|
await this.settle();
|
|
723
|
-
|
|
839
|
+
for (const session of this.sessions.keys()) {
|
|
840
|
+
if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, session));
|
|
841
|
+
if (session !== this.sessions.keys().next().value) this.internalSessions?.delete(session);
|
|
842
|
+
}
|
|
843
|
+
for (const observer of this.observers)
|
|
844
|
+
await outcome(this.reader.send('Target.detachFromTarget', { sessionId: observer }));
|
|
724
845
|
if (this.observer) {
|
|
725
846
|
await outcome(
|
|
726
|
-
this.reader.send(
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
)
|
|
847
|
+
this.reader.send('Network.deleteCookies', {
|
|
848
|
+
name: this.cookie,
|
|
849
|
+
domain: 'seven.sliccy.ai',
|
|
850
|
+
path: '/',
|
|
851
|
+
})
|
|
731
852
|
);
|
|
732
|
-
await outcome(this.reader.send('Target.detachFromTarget', { sessionId: this.observer }));
|
|
733
853
|
}
|
|
854
|
+
for (const targetId of this.targets)
|
|
855
|
+
await outcome(this.reader.send('Target.closeTarget', { targetId }));
|
|
734
856
|
for (const registration of this.registrations)
|
|
735
857
|
await this.fixture.redirectSecret({ ...registration, remove: true });
|
|
736
858
|
}
|
|
@@ -749,7 +871,7 @@ export async function redirectSecret(options) {
|
|
|
749
871
|
await probe.setup();
|
|
750
872
|
if (options.mode === 'body') await probe.body();
|
|
751
873
|
else await probe.headers();
|
|
752
|
-
probe.complete = !probe.ended();
|
|
874
|
+
probe.complete = !probe.ended() && !probe.halted;
|
|
753
875
|
const evidence = await probe.evidence();
|
|
754
876
|
if (probe.end && options.side !== 'guarded')
|
|
755
877
|
throw new Error(`redirect-secret: ${options.side} session ended (${probe.end})`);
|