@ai-ecoverse/slicc-browsers 1.18.2 → 1.18.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 +5 -5
- package/bfcache-restore.mjs +7 -17
- package/guard-runner.mjs +1 -0
- package/guard-vectors.json +1 -1
- package/package.json +3 -3
- package/redirect-secret.mjs +219 -70
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
|
-
`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.
|
|
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 iframe MHTML and DOM reads are polled until both show the marker, for up to 5000 ms scaled by `settleScale`, with the stall-tolerant `waitFor` bound. A channel that never shows it reads `incomplete`, while a measured channel stays true; the control is incomplete, never a pass or a failure solely for missing iframe evidence. Guarded leak checks remain measured on every received reply. 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
|
|
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
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.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",
|
|
@@ -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-redirect-iframe.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/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,25 +657,80 @@ 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`,
|
|
581
670
|
});
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
671
|
+
const control = this.side === 'control';
|
|
672
|
+
const ms = Math.round(5000 * this.scale);
|
|
673
|
+
let snapshot;
|
|
674
|
+
let dom;
|
|
675
|
+
let ordinaryIframeSnapshot = false;
|
|
676
|
+
let ordinaryIframeDOM = false;
|
|
677
|
+
const read = async () => {
|
|
678
|
+
await this.send('Page.getFrameTree');
|
|
679
|
+
snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
|
|
680
|
+
dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
|
|
681
|
+
ordinaryIframeSnapshot ||= snapshot.ok && this.contains(snapshot);
|
|
682
|
+
ordinaryIframeDOM ||= dom.ok && this.contains(dom);
|
|
683
|
+
return this.ended() || (ordinaryIframeSnapshot && ordinaryIframeDOM);
|
|
684
|
+
};
|
|
685
|
+
if (control) {
|
|
686
|
+
if (!(await waitFor(read, ms, 50))) {
|
|
687
|
+
const missing = [
|
|
688
|
+
!ordinaryIframeSnapshot && 'ordinaryIframeSnapshot',
|
|
689
|
+
!ordinaryIframeDOM && 'ordinaryIframeDOM',
|
|
690
|
+
].filter(Boolean);
|
|
691
|
+
this.missing.push(
|
|
692
|
+
`redirect-secret: control iframe ${missing.join(', ')} not observed within ${ms}ms`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
await pause(500);
|
|
697
|
+
await read();
|
|
698
|
+
}
|
|
586
699
|
this.responses.push({
|
|
587
700
|
iframeSnapshotAttempted: !snapshot.ended && !dom.ended,
|
|
588
|
-
ordinaryIframeSnapshot:
|
|
589
|
-
ordinaryIframeDOM:
|
|
701
|
+
ordinaryIframeSnapshot: ordinaryIframeSnapshot || (control ? 'incomplete' : false),
|
|
702
|
+
ordinaryIframeDOM: ordinaryIframeDOM || (control ? 'incomplete' : false),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
observerTraffic(value) {
|
|
707
|
+
return (
|
|
708
|
+
this.client === this.reader &&
|
|
709
|
+
this.observers.has(value?.sessionId) &&
|
|
710
|
+
!this.sessions.has(value?.sessionId)
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
observerMessage(message) {
|
|
715
|
+
try {
|
|
716
|
+
return this.observerTraffic(JSON.parse(message));
|
|
717
|
+
} catch {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
overrideCheck(key, unseen) {
|
|
723
|
+
const overrides = this.responses.filter((entry) => entry.overrideMethod);
|
|
724
|
+
if (!overrides.length) return this.overrideIncomplete ? 'incomplete' : unseen;
|
|
725
|
+
return overrides.every((entry) => entry[key]);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async survival() {
|
|
729
|
+
for (const session of this.sessions.keys()) await this.send('Fetch.disable', {}, session);
|
|
730
|
+
if (!(await this.reset('survive'))) return { ok: false };
|
|
731
|
+
return this.send('Runtime.evaluate', {
|
|
732
|
+
expression: 'document.title',
|
|
733
|
+
returnByValue: true,
|
|
590
734
|
});
|
|
591
735
|
}
|
|
592
736
|
|
|
@@ -595,20 +739,14 @@ class RedirectProbe {
|
|
|
595
739
|
if (!this.ended()) this.tap();
|
|
596
740
|
if (this.side === 'control' && this.controls)
|
|
597
741
|
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
|
-
});
|
|
742
|
+
const survives = await this.survival();
|
|
604
743
|
await this.settle();
|
|
605
744
|
await this.terminated();
|
|
606
|
-
const tapped = `"sessionId":"${this.observer}"`;
|
|
607
745
|
const events = this.client.events
|
|
608
746
|
.slice(this.eventsStart)
|
|
609
|
-
.filter((event) =>
|
|
747
|
+
.filter((event) => !this.observerTraffic(event));
|
|
610
748
|
const messages = (this.client.messages?.slice(this.messagesStart) ?? []).filter(
|
|
611
|
-
(message) => !
|
|
749
|
+
(message) => !this.observerMessage(message)
|
|
612
750
|
);
|
|
613
751
|
const eventLeaks = await this.scan(events);
|
|
614
752
|
const ordinaryEvents = events.filter(
|
|
@@ -619,7 +757,7 @@ class RedirectProbe {
|
|
|
619
757
|
const replyLeaks = await this.scan(replies);
|
|
620
758
|
const extra = events.filter(
|
|
621
759
|
(event) =>
|
|
622
|
-
event.sessionId
|
|
760
|
+
this.sessions.has(event.sessionId) &&
|
|
623
761
|
event.method === 'Network.responseReceivedExtraInfo' &&
|
|
624
762
|
redirects.includes(event.params.statusCode)
|
|
625
763
|
);
|
|
@@ -634,12 +772,14 @@ class RedirectProbe {
|
|
|
634
772
|
});
|
|
635
773
|
const extraInPlace =
|
|
636
774
|
extraHeld &&
|
|
637
|
-
(extra.length >= this.destinations.length * 4 || (this.
|
|
638
|
-
const unseen = this.end ? 'incomplete' : false;
|
|
639
|
-
const found = (key) =>
|
|
775
|
+
(extra.length >= this.destinations.length * 4 || (this.cut() ? 'incomplete' : false));
|
|
776
|
+
const unseen = this.end || this.halted ? 'incomplete' : false;
|
|
777
|
+
const found = (key) => {
|
|
778
|
+
const values = this.responses.map((entry) => entry[key]);
|
|
779
|
+
return values.includes(true) || (values.includes('incomplete') ? 'incomplete' : false);
|
|
780
|
+
};
|
|
640
781
|
const fetchReads = this.answers('Fetch.getResponseBody');
|
|
641
782
|
const fetchLeaked = fetchReads.answered.some((entry) => !refused(entry));
|
|
642
|
-
const overrides = this.responses.filter((entry) => entry.overrideMethod);
|
|
643
783
|
const evidence = {
|
|
644
784
|
live: (await this.fixture.log()).some((entry) =>
|
|
645
785
|
entry.url.includes(`/redirect-secret/${this.tag}`)
|
|
@@ -653,7 +793,9 @@ class RedirectProbe {
|
|
|
653
793
|
: (message.method ?? 'reply')
|
|
654
794
|
),
|
|
655
795
|
secretHidden: !replyLeaks.includes(true),
|
|
656
|
-
sessionSurvives:
|
|
796
|
+
sessionSurvives: this.halted
|
|
797
|
+
? 'incomplete'
|
|
798
|
+
: survives.ok && survives.result?.result?.value === 'doc',
|
|
657
799
|
sessionEnded: this.ended(),
|
|
658
800
|
fetchBodyAttempted: fetchReads.short ? 'incomplete' : fetchReads.answered.length > 0,
|
|
659
801
|
fetchBodyRefused: fetchLeaked
|
|
@@ -706,31 +848,38 @@ class RedirectProbe {
|
|
|
706
848
|
'ordinaryIframeDOM',
|
|
707
849
|
].map((key) => [key, found(key) || unseen])
|
|
708
850
|
),
|
|
709
|
-
overridesRefused:
|
|
710
|
-
|
|
711
|
-
: unseen,
|
|
712
|
-
overridesWorked: overrides.length ? overrides.every((entry) => entry.overrideWorked) : unseen,
|
|
851
|
+
overridesRefused: this.overrideCheck('overrideRefused', unseen),
|
|
852
|
+
overridesWorked: this.overrideCheck('overrideWorked', unseen),
|
|
713
853
|
};
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
854
|
+
const incomplete = [evidence.incomplete, ...this.missing];
|
|
855
|
+
if (this.end && Object.values(evidence).includes('incomplete'))
|
|
856
|
+
incomplete.push(`redirect-secret: session ended (${this.end})`);
|
|
857
|
+
return incomplete.some(Boolean)
|
|
858
|
+
? { ...evidence, incomplete: incomplete.filter(Boolean).join('; ') }
|
|
859
|
+
: evidence;
|
|
717
860
|
}
|
|
718
861
|
|
|
719
862
|
async close() {
|
|
720
863
|
this.stopped = true;
|
|
721
864
|
await this.watching;
|
|
722
865
|
await this.settle();
|
|
723
|
-
|
|
866
|
+
for (const session of this.sessions.keys()) {
|
|
867
|
+
if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, session));
|
|
868
|
+
if (session !== this.sessions.keys().next().value) this.internalSessions?.delete(session);
|
|
869
|
+
}
|
|
870
|
+
for (const observer of this.observers)
|
|
871
|
+
await outcome(this.reader.send('Target.detachFromTarget', { sessionId: observer }));
|
|
724
872
|
if (this.observer) {
|
|
725
873
|
await outcome(
|
|
726
|
-
this.reader.send(
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
)
|
|
874
|
+
this.reader.send('Network.deleteCookies', {
|
|
875
|
+
name: this.cookie,
|
|
876
|
+
domain: 'seven.sliccy.ai',
|
|
877
|
+
path: '/',
|
|
878
|
+
})
|
|
731
879
|
);
|
|
732
|
-
await outcome(this.reader.send('Target.detachFromTarget', { sessionId: this.observer }));
|
|
733
880
|
}
|
|
881
|
+
for (const targetId of this.targets)
|
|
882
|
+
await outcome(this.reader.send('Target.closeTarget', { targetId }));
|
|
734
883
|
for (const registration of this.registrations)
|
|
735
884
|
await this.fixture.redirectSecret({ ...registration, remove: true });
|
|
736
885
|
}
|
|
@@ -749,7 +898,7 @@ export async function redirectSecret(options) {
|
|
|
749
898
|
await probe.setup();
|
|
750
899
|
if (options.mode === 'body') await probe.body();
|
|
751
900
|
else await probe.headers();
|
|
752
|
-
probe.complete = !probe.ended();
|
|
901
|
+
probe.complete = !probe.ended() && !probe.halted;
|
|
753
902
|
const evidence = await probe.evidence();
|
|
754
903
|
if (probe.end && options.side !== 'guarded')
|
|
755
904
|
throw new Error(`redirect-secret: ${options.side} session ended (${probe.end})`);
|