@ai-ecoverse/slicc-browsers 1.16.0 → 1.16.2
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 +4 -2
- package/guard-vectors.json +11 -3
- package/package.json +3 -3
- package/redirect-secret.mjs +132 -59
package/README.md
CHANGED
|
@@ -187,7 +187,7 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
|
|
|
187
187
|
- the `C8` rows for privileged pages: `C8.navigate` (a page navigates to `chrome://settings`, `chrome://password-manager`, an extension page or `file:///`; refused), `C8.view-source` (the same through `view-source:`; refused), `C8.create` (`Target.createTarget` straight on those pages; refused), `C8.browser-ui-listed` (`Target.getTargets` lists no `browser_ui` target) and `C8.browser-ui-attach` (`Target.attachToTarget` by a `browser_ui` target's id; refused). All five have an unguarded control, since the guard refuses them only on the privileged target. Two cases aren't rows, because Chrome itself blocks them, so an unguarded client can't tell a guard from none: a page's `window.open('chrome://settings')`, and an `about:` alias such as `about:settings`, which `Page.navigate` and `Target.createTarget` both turn into `about:blank#blocked`. The `about:` aliases are in `protected.json` instead.
|
|
188
188
|
- slicc-cdp's owner vectors in `test/integration/guard-native.test.mjs` at `d85ac3c`: `C5.script`, a protected script subresource under the client's own `Fetch.enable` `*` at both stages (the guard neither pauses it for the client nor lets the client fulfil it, and the page runs the stand-in's real `/script`, while the control's paused request carries its headers as an object with `User-Agent` and runs the forged script), and `X1.after-exit`, the client lifecycle row below.
|
|
189
189
|
|
|
190
|
-
`X1.after-exit` (family `X`, client lifecycle) needs `clientExit`. Through the guard, the client opens an ordinary tab, registers `Page.addScriptToEvaluateOnNewDocument` with `{beacon}`, `Network.setExtraHTTPHeaders` with `x-probe: {tag}` and `Fetch.enable` `*` with a rule that fulfils the destination with a forged `FULFILLED` page, then loads the `/page` trigger whose `go()` navigates to `{target}/canary/{tag}`. Before the exit it must see its init-script beacon, its header on the trigger request and the trigger's `Fetch.requestPaused
|
|
190
|
+
`X1.after-exit` (family `X`, client lifecycle) needs `clientExit`. Through the guard, the client opens an ordinary tab, registers `Page.addScriptToEvaluateOnNewDocument` with `{beacon}`, `Network.setExtraHTTPHeaders` with `x-probe: {tag}` and `Fetch.enable` `*` with a rule that fulfils the destination with a forged `FULFILLED` page, then loads the `/page?k=href&tag={tag}&to={target}/canary/{tag}` trigger whose `go()` navigates to `{target}/canary/{tag}`. Before the exit it must see its init-script beacon, its header on the trigger request and the trigger's `Fetch.requestPaused`, matched up to `&to=`, so a guard that redacts the embedded protected URL to its origin still matches: those are the positive evidence on both legs. Then the client exits, the unguarded observer calls `go()`, and the destination must be served and read as the real `canary` document, with no beacon from `{target}`, no `x-probe` on its request, no forged body and no `Fetch.requestPaused` for it reaching the client: nothing the client registered may outlive it. The control runs the same on `{plain}` through the guard, and expects the same, since an ordinary navigation after the exit must be just as clean. A guard that keeps its upstream session, or its own init scripts, headers or interception, past the client's exit fails the row.
|
|
191
191
|
|
|
192
192
|
The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each command selecting the hidden world must fail with the `slicc: refused:` prefix, never an ordinary browser error. Numeric guesses also sweep ids 1–32: an id not exposed to this client must be refused, while every exposed numeric context must remain usable; none of those guarded probes may reach the fixture content-script marker. The marker combines the content script’s global lexical `root` binding with its extension-id DOM marker and `chrome.runtime.id`, and is independently required to be absent from the page default world. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. The control waits up to 5000 ms (scaled by `settleScale`) for `Debugger.paused`; when it never pauses, `reachable` records `"incomplete"`, not `false`, and the row fails with `extensionWorld debugger: no Debugger.paused within <ms>ms`. The guarded leg waits up to 1500 ms, scaled by `refusalScale`. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
|
|
193
193
|
|
|
@@ -197,6 +197,8 @@ The integration launcher loads shared-web 1.25.2’s harness extension exactly o
|
|
|
197
197
|
|
|
198
198
|
`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.
|
|
199
199
|
|
|
200
|
+
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, or a CDP reply that the session is closed or not found, or that the target, 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>)`.
|
|
201
|
+
|
|
200
202
|
`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.
|
|
201
203
|
|
|
202
204
|
`C8.chrome-search` requests `chrome-search://most-visited/` through `Page.navigate` and requires an explicit guard refusal. Its unguarded control requires a CDP response, not a successful document commit: Chrome can answer with `errorText` when that internal data source is unavailable outside an Instant process. Browser-side load failure is not a guard refusal, and merely hiding the target must fail this row.
|
|
@@ -207,7 +209,7 @@ The `C8.newtab` reachability control attaches through the guard to a settled ord
|
|
|
207
209
|
|
|
208
210
|
`C8.network-error` and `C8.iframe-error` are ordinary-error reachability controls: request the configured `network-error` URL (default `http://nonexistent.invalid/`) directly or in an ordinary iframe, observe a failed Document request, reattach to the settled tab, evaluate and snapshot its document, then navigate the same tab to an ordinary document. Both control and guarded legs must pass. The failing URL is unchanged on the control, rather than replaced with a healthy origin, and the rows require `browserSession` but not `chromeUrlsAttach`. With Chrome for Testing 156.0.8078.4, scheme-wide `chrome-error` protection made the direct-error row fail both legs because reattachment was refused; removing it passed both rows, with readable DOM and successful recovery. The old emulator still allowed the cross-process iframe parent because the iframe TargetInfo kept the failing HTTP URL, but that layout-specific behavior is not grounds for protecting the error scheme. Plain-client controls and reachability legs also passed both rows.
|
|
209
211
|
|
|
210
|
-
`E4` and the `E6` rows are runnable, each with a guarded-through control on the ordinary origin that must show the effect. `E4` sets a `Debugger.setBreakpointByUrl` on line 0 of the `listener=1` document's `/listener.js?n=<tag>` with a condition that sends `{beacon}` and returns `false`; the control needs that beacon, and the guarded leg needs neither the beacon nor a `Debugger.paused` in the protected document. `E4.instrumentation` sets `Debugger.setInstrumentationBreakpoint` with `beforeScriptWithSourceMapExecution`, so only `/listener.js`, whose `SourceMap` header names a source map, pauses: `beforeScriptExecution` would also pause the runner's own `Runtime.evaluate` and any extension's content script first. `E4.event-listener` sets `DOMDebugger.setEventListenerBreakpoint` for `load`. Both set up before the trigger, need a `Debugger.paused` on the control (in `/listener.js`, or for `E4.event-listener` with `listener:load`, which only that document registers, since a listener pause reports no script URL), none on the guarded leg, and end with `Debugger.disable`
|
|
212
|
+
`E4` and the `E6` rows are runnable, each with a guarded-through control on the ordinary origin that must show the effect. `E4` sets a `Debugger.setBreakpointByUrl` on line 0 of the `listener=1` document's `/listener.js?n=<tag>` with a condition that sends `{beacon}` and returns `false`; the control needs that beacon, and the guarded leg needs neither the beacon nor a `Debugger.paused` in the protected document. `E4.instrumentation` sets `Debugger.setInstrumentationBreakpoint` with `beforeScriptWithSourceMapExecution`, so only `/listener.js`, whose `SourceMap` header names a source map, pauses: `beforeScriptExecution` would also pause the runner's own `Runtime.evaluate` and any extension's content script first. `E4.event-listener` sets `DOMDebugger.setEventListenerBreakpoint` for `load`. Both set up before the trigger, need a `Debugger.paused` on the control (in `/listener.js`, or for `E4.event-listener` with `listener:load`, which only that document registers, since a listener pause reports no script URL), none on the guarded leg, and end with `Debugger.resume` and then `Debugger.disable` on the page session: Chrome doesn't answer `Debugger.disable` while the page sits in a pause, so a client that never times calls out would hang there. The resume can't hide a pause, since the `Debugger.paused` it ends was already seen, and every call in these rows must answer before the client's call timeout. The `E6` overrides are set on the page session before the trigger: `E6.headers` (`x-probe`), `E6.ua-network` and `E6.ua-emulation` (`User-Agent: slicc-probe-<tag>`) and `E6.cache-disabled` (`Pragma: no-cache`) are read from the stand-in's request log; `E6.network-conditions` (1500 ms latency) needs a navigation `duration` of at least 1000 ms, since Chrome delays the body rather than `responseStart`; `E6.locale` (`fr-CH`), `E6.timezone` (`Pacific/Chatham`) and `E6.network-state` (`navigator.onLine` false) are read by the unguarded observer in the destination document, and `E6.network-state` starts on `{cross}`, because Chrome drops that override on a same-site navigation once a second session is attached to the page. A guard that keeps its own session on the page, as the test emulator's Fetch shield does, can still lose it, and then fails that control rather than passing it; `E6.blocked-urls` has the observer fetch a blocked `/data` subresource there. Observer reads first check that the page is the exact destination URL, so a blocked or replaced document never shows the effect. The five overrides Chrome also applies inside a cross-site iframe have an `.iframe` row, triggered through `/framed`; `E6.network-conditions.iframe` reads the iframe's `duration` from the parent's resource timing. Each override is refusable at registration, with its observation depending on that refusal. These rows settle on the destination's load or request rather than on the detach variant, because each effect lands when the request is made or the script runs.
|
|
211
213
|
|
|
212
214
|
A client may register `Fetch.enable` patterns naming a protected origin in either of two conformant ways: the guard allows registration but never pauses or forwards protected requests to the client and refuses continue, fulfil or fail calls for request ids it did not hand out; or the guard refuses registration with its declared refusal text. Setup calls explicitly naming a protected placeholder are refusable with `refusal.onlyProtected: true`, and interception-dependent observations depend on that refusal. Wildcard and ordinary-only patterns do not receive this exemption. The ordinary-origin control still runs through the guard and must succeed.
|
|
213
215
|
|
package/guard-vectors.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"setVersion": "1.16.
|
|
3
|
+
"setVersion": "1.16.2",
|
|
4
4
|
"sources": [
|
|
5
5
|
{
|
|
6
6
|
"repo": "ai-ecoverse/slicc-cli",
|
|
@@ -14967,6 +14967,9 @@
|
|
|
14967
14967
|
{
|
|
14968
14968
|
"wait": 2000
|
|
14969
14969
|
},
|
|
14970
|
+
{
|
|
14971
|
+
"cdp": "Debugger.resume"
|
|
14972
|
+
},
|
|
14970
14973
|
{
|
|
14971
14974
|
"cdp": "Debugger.disable"
|
|
14972
14975
|
}
|
|
@@ -15121,6 +15124,9 @@
|
|
|
15121
15124
|
{
|
|
15122
15125
|
"wait": 2000
|
|
15123
15126
|
},
|
|
15127
|
+
{
|
|
15128
|
+
"cdp": "Debugger.resume"
|
|
15129
|
+
},
|
|
15124
15130
|
{
|
|
15125
15131
|
"cdp": "Debugger.disable"
|
|
15126
15132
|
}
|
|
@@ -18041,7 +18047,7 @@
|
|
|
18041
18047
|
"trigger": [
|
|
18042
18048
|
{
|
|
18043
18049
|
"page": "navigate",
|
|
18044
|
-
"url": "{ordinary}/page?k=href&to={target}/canary/{tag}
|
|
18050
|
+
"url": "{ordinary}/page?k=href&tag={tag}&to={target}/canary/{tag}"
|
|
18045
18051
|
},
|
|
18046
18052
|
{
|
|
18047
18053
|
"settle": {
|
|
@@ -18089,7 +18095,9 @@
|
|
|
18089
18095
|
"where": {
|
|
18090
18096
|
"params": {
|
|
18091
18097
|
"request": {
|
|
18092
|
-
"url":
|
|
18098
|
+
"url": {
|
|
18099
|
+
"$regex": "^{ordinary}/page\\?k=href&tag={tag}&to="
|
|
18100
|
+
}
|
|
18093
18101
|
}
|
|
18094
18102
|
}
|
|
18095
18103
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-browsers",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.2",
|
|
4
4
|
"description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"validate": "node scripts/validate.mjs",
|
|
67
67
|
"lint": "slicc-lint && test -z \"$(gofmt -l .)\" && go mod tidy -diff && go vet ./... && golangci-lint run && cargo fmt --check && cargo clippy --all-features --all-targets -- -D warnings",
|
|
68
68
|
"format": "biome check --write . && gofmt -w . && cargo fmt",
|
|
69
|
-
"test": "npm run validate && node --test test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/release-line.test.mjs test/integration/guard-redirect-carrier.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 && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
69
|
+
"test": "npm run validate && node --test test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/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 && go test -race -count=1 ./test/integration/... && cargo test --all-features",
|
|
70
70
|
"test:unit": "go test -race -cover ./...",
|
|
71
71
|
"build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
|
|
72
72
|
"trust": "npx fledgling sync -y --skip-publish",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"test:unit:guard": "mkdir -p coverage/unit && node --experimental-vm-modules --no-warnings --experimental-test-coverage --test --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=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"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@ai-ecoverse/slicc-shared-web": "2.7.
|
|
84
|
+
"@ai-ecoverse/slicc-shared-web": "2.7.4",
|
|
85
85
|
"@biomejs/biome": "2.5.15",
|
|
86
86
|
"ajv": "8.20.0",
|
|
87
87
|
"ajv-formats": "3.0.1",
|
package/redirect-secret.mjs
CHANGED
|
@@ -20,6 +20,12 @@ const decodedBase64 = (value) =>
|
|
|
20
20
|
base64Slices(text).map(decodeBase64)
|
|
21
21
|
);
|
|
22
22
|
const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
|
|
23
|
+
const gone = (out) =>
|
|
24
|
+
!out.ok &&
|
|
25
|
+
!refused(out) &&
|
|
26
|
+
/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
|
+
out.error?.message ?? ''
|
|
28
|
+
);
|
|
23
29
|
const redirects = [301, 302, 303, 307, 308];
|
|
24
30
|
const redirectHeaders = (to) => ({
|
|
25
31
|
location: to,
|
|
@@ -85,6 +91,7 @@ class RedirectProbe {
|
|
|
85
91
|
this.messagesStart = this.client.messages?.length;
|
|
86
92
|
this.eventsStart = this.client.events.length;
|
|
87
93
|
this.cursor = this.eventsStart;
|
|
94
|
+
this.detachScan = this.eventsStart;
|
|
88
95
|
this.responses = [];
|
|
89
96
|
this.attempts = [];
|
|
90
97
|
this.jobs = new Set();
|
|
@@ -107,8 +114,41 @@ class RedirectProbe {
|
|
|
107
114
|
: this.protectedURLs;
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
ended() {
|
|
118
|
+
const events = this.client.events;
|
|
119
|
+
for (; !this.end && this.detachScan < events.length; this.detachScan++) {
|
|
120
|
+
const event = events[this.detachScan];
|
|
121
|
+
if (event.method === 'Target.detachedFromTarget' && event.params?.sessionId === this.session)
|
|
122
|
+
this.end = 'Target.detachedFromTarget';
|
|
123
|
+
}
|
|
124
|
+
return Boolean(this.end);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
cut() {
|
|
128
|
+
return Boolean(this.end) && !this.complete;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async lost() {
|
|
132
|
+
if (!this.ended()) await this.send('Runtime.evaluate', { expression: 'void 0' });
|
|
133
|
+
return this.ended();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
answers(method) {
|
|
137
|
+
const all = this.attempts.filter((entry) => entry.method === method);
|
|
138
|
+
const answered = all.filter((entry) => !(this.end && (entry.pending || entry.ended)));
|
|
139
|
+
const short =
|
|
140
|
+
Boolean(this.end) && (!this.complete || !answered.length || answered.length < all.length);
|
|
141
|
+
return { answered, short };
|
|
142
|
+
}
|
|
143
|
+
|
|
110
144
|
async send(method, params = {}) {
|
|
145
|
+
if (this.ended())
|
|
146
|
+
return { ok: false, ended: true, error: { message: `session ended (${this.end})` } };
|
|
111
147
|
const out = await outcome(this.client.send(method, params, this.session));
|
|
148
|
+
if (gone(out)) {
|
|
149
|
+
this.end ??= out.error.message;
|
|
150
|
+
return { ...out, ended: true };
|
|
151
|
+
}
|
|
112
152
|
this.responses.push({ method, ...out });
|
|
113
153
|
return out;
|
|
114
154
|
}
|
|
@@ -151,7 +191,7 @@ class RedirectProbe {
|
|
|
151
191
|
|
|
152
192
|
async networkBody(requestId, source) {
|
|
153
193
|
this.learned[source].add(requestId);
|
|
154
|
-
if (this.networkBodies.has(requestId)) return;
|
|
194
|
+
if (this.networkBodies.has(requestId) || this.ended()) return;
|
|
155
195
|
this.networkBodies.add(requestId);
|
|
156
196
|
const entry = { method: 'Network.getResponseBody', source, pending: true };
|
|
157
197
|
this.attempts.push(entry);
|
|
@@ -191,15 +231,17 @@ class RedirectProbe {
|
|
|
191
231
|
carriers.filter((entry) => entry === carrier).length,
|
|
192
232
|
])
|
|
193
233
|
);
|
|
194
|
-
const attempts = this.
|
|
234
|
+
const { answered: attempts, short } = this.answers('Network.getResponseBody');
|
|
195
235
|
const refusedAll = attempts.length > 0 && attempts.every(refused);
|
|
196
236
|
const proven = path === 'network' || this.side !== 'guarded' || this.controls?.networkEvents;
|
|
197
237
|
const reason =
|
|
198
238
|
path === 'none'
|
|
199
239
|
? 'no network id for the redirect'
|
|
200
|
-
: proven
|
|
201
|
-
?
|
|
202
|
-
:
|
|
240
|
+
: !proven
|
|
241
|
+
? 'control did not prove the redirect Network events'
|
|
242
|
+
: short
|
|
243
|
+
? `session ended (${this.end}) before every network body read was answered`
|
|
244
|
+
: undefined;
|
|
203
245
|
if (reason && (!attempts.length || refusedAll))
|
|
204
246
|
return {
|
|
205
247
|
networkBodyPath: path,
|
|
@@ -254,11 +296,12 @@ class RedirectProbe {
|
|
|
254
296
|
async land(url, start) {
|
|
255
297
|
const path = new URL(url).pathname;
|
|
256
298
|
await pause(250);
|
|
257
|
-
if (this.stalled) return;
|
|
299
|
+
if (this.stalled || this.ended()) return;
|
|
258
300
|
const landed = await waitFor(
|
|
259
301
|
async () =>
|
|
260
|
-
|
|
261
|
-
|
|
302
|
+
this.ended() ||
|
|
303
|
+
((await this.fixture.log()).some((entry) => entry.url.includes(path)) &&
|
|
304
|
+
!this.open(this.client.events.slice(start), (seen) => seen === url).length),
|
|
262
305
|
Math.round(2000 * this.scale),
|
|
263
306
|
50
|
|
264
307
|
);
|
|
@@ -267,7 +310,7 @@ class RedirectProbe {
|
|
|
267
310
|
|
|
268
311
|
async terminated() {
|
|
269
312
|
await waitFor(
|
|
270
|
-
() => !this.open(this.client.events.slice(this.eventsStart)).length,
|
|
313
|
+
() => this.ended() || !this.open(this.client.events.slice(this.eventsStart)).length,
|
|
271
314
|
Math.round(5000 * this.scale),
|
|
272
315
|
50
|
|
273
316
|
);
|
|
@@ -300,13 +343,15 @@ class RedirectProbe {
|
|
|
300
343
|
);
|
|
301
344
|
const deliveries = Object.values(hops).map((entry) => entry.delivery);
|
|
302
345
|
const open = Object.keys(hops).filter((id) => hops[id].delivery === 'unterminated');
|
|
303
|
-
const missing = open.length > 0 && this.side === 'control';
|
|
346
|
+
const missing = (open.length > 0 && (this.side === 'control' || this.end)) || this.cut();
|
|
347
|
+
const who = this.end ? `session ended (${this.end}) with requests` : 'control requests';
|
|
348
|
+
const detail = open.length
|
|
349
|
+
? `${who} ${open.join(', ')} unterminated`
|
|
350
|
+
: `session ended (${this.end}) before every request was issued`;
|
|
304
351
|
return {
|
|
305
352
|
hops,
|
|
306
353
|
requestsTerminated: missing ? 'incomplete' : !open.length,
|
|
307
|
-
...(missing
|
|
308
|
-
? { incomplete: `redirect-secret: control requests ${open.join(', ')} unterminated` }
|
|
309
|
-
: {}),
|
|
354
|
+
...(missing ? { incomplete: `redirect-secret: ${detail}` } : {}),
|
|
310
355
|
hopsRedacted: !deliveries.some((delivery) => ['raw', 'ordinary'].includes(delivery)),
|
|
311
356
|
};
|
|
312
357
|
}
|
|
@@ -388,6 +433,7 @@ class RedirectProbe {
|
|
|
388
433
|
async headers() {
|
|
389
434
|
for (const [index, to] of this.destinations.entries()) {
|
|
390
435
|
for (const kind of ['navigate', 'href', 'fetch', 'xhr']) {
|
|
436
|
+
if (this.ended()) return;
|
|
391
437
|
const code = this.mode === 'mix' ? [301, 303, 307, 308][index] : 302;
|
|
392
438
|
const url = await this.register(`${index}-${kind}`, to, code);
|
|
393
439
|
const start = this.client.events.length;
|
|
@@ -439,9 +485,9 @@ class RedirectProbe {
|
|
|
439
485
|
const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
|
|
440
486
|
const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
|
|
441
487
|
this.responses.push({
|
|
442
|
-
resourceContentAttempted:
|
|
443
|
-
snapshotAttempted:
|
|
444
|
-
domSnapshotAttempted:
|
|
488
|
+
resourceContentAttempted: !resource.ended,
|
|
489
|
+
snapshotAttempted: !snapshot.ended,
|
|
490
|
+
domSnapshotAttempted: !dom.ended,
|
|
445
491
|
ordinaryResource: this.contains(resource),
|
|
446
492
|
ordinarySnapshot: this.contains(snapshot),
|
|
447
493
|
ordinaryDOM: this.contains(dom),
|
|
@@ -455,16 +501,21 @@ class RedirectProbe {
|
|
|
455
501
|
await this.send('Runtime.evaluate', {
|
|
456
502
|
expression: `fetch(${JSON.stringify(this.holdURL)}).catch(()=>{});void 0`,
|
|
457
503
|
});
|
|
458
|
-
|
|
504
|
+
await waitFor(() => Boolean(this.held) || this.ended(), 5000, 10);
|
|
505
|
+
if (!this.held) {
|
|
506
|
+
this.holdURL = undefined;
|
|
507
|
+
if (await this.lost()) return;
|
|
459
508
|
throw new Error('redirect body: Fetch override pause missing');
|
|
509
|
+
}
|
|
460
510
|
const requestId = this.held.params.requestId;
|
|
461
511
|
const out = await this.send('Fetch.continueRequest', { requestId, url });
|
|
512
|
+
this.holdURL = undefined;
|
|
513
|
+
if (out.ended) return;
|
|
462
514
|
this.responses.push({
|
|
463
515
|
overrideMethod: 'Fetch.continueRequest',
|
|
464
516
|
overrideRefused: refused(out),
|
|
465
517
|
overrideWorked: out.ok,
|
|
466
518
|
});
|
|
467
|
-
this.holdURL = undefined;
|
|
468
519
|
if (!out.ok) await this.send('Fetch.continueRequest', { requestId });
|
|
469
520
|
await pause(500);
|
|
470
521
|
}
|
|
@@ -475,6 +526,15 @@ class RedirectProbe {
|
|
|
475
526
|
const tree = await this.send('Page.getFrameTree');
|
|
476
527
|
const frameId = tree.result?.frameTree.frame.id;
|
|
477
528
|
const start = (await this.fixture.log()).length;
|
|
529
|
+
await this.reads(url, to, frameId);
|
|
530
|
+
this.responses.push({
|
|
531
|
+
cookieSent: (await this.fixture.log())
|
|
532
|
+
.slice(start)
|
|
533
|
+
.some((entry) => entry.headers.cookie?.includes(`${this.cookie}=`)),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async reads(url, to, frameId) {
|
|
478
538
|
const resource = await this.send('Network.loadNetworkResource', {
|
|
479
539
|
frameId,
|
|
480
540
|
url,
|
|
@@ -496,18 +556,21 @@ class RedirectProbe {
|
|
|
496
556
|
event.params.type === 'XHR' &&
|
|
497
557
|
event.params.request.url === url
|
|
498
558
|
);
|
|
499
|
-
if (!xhr)
|
|
500
|
-
|
|
501
|
-
|
|
559
|
+
if (!xhr) {
|
|
560
|
+
if (await this.lost()) return;
|
|
561
|
+
throw new Error('redirect body: XHR request was not observed');
|
|
562
|
+
}
|
|
563
|
+
const replay = await this.send('Network.replayXHR', { requestId: xhr.params.requestId });
|
|
564
|
+
this.responses.push({ replayAttempted: !replay.ended });
|
|
502
565
|
await pause(500);
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
566
|
+
for (const step of [
|
|
567
|
+
() => this.snapshots(url, to, frameId),
|
|
568
|
+
() => this.iframe(),
|
|
569
|
+
() => this.override(url),
|
|
570
|
+
]) {
|
|
571
|
+
if (this.ended()) return;
|
|
572
|
+
await step();
|
|
573
|
+
}
|
|
511
574
|
}
|
|
512
575
|
|
|
513
576
|
async iframe() {
|
|
@@ -521,15 +584,15 @@ class RedirectProbe {
|
|
|
521
584
|
const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
|
|
522
585
|
const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
|
|
523
586
|
this.responses.push({
|
|
524
|
-
iframeSnapshotAttempted:
|
|
587
|
+
iframeSnapshotAttempted: !snapshot.ended && !dom.ended,
|
|
525
588
|
ordinaryIframeSnapshot: this.contains(snapshot),
|
|
526
589
|
ordinaryIframeDOM: this.contains(dom),
|
|
527
590
|
});
|
|
528
591
|
}
|
|
529
592
|
|
|
530
593
|
async evidence() {
|
|
531
|
-
await pause(8000);
|
|
532
|
-
this.tap();
|
|
594
|
+
if (!this.ended()) await pause(8000);
|
|
595
|
+
if (!this.ended()) this.tap();
|
|
533
596
|
if (this.side === 'control' && this.controls)
|
|
534
597
|
this.controls.networkEvents = this.learned.network.size > 0;
|
|
535
598
|
await this.send('Fetch.disable');
|
|
@@ -560,20 +623,24 @@ class RedirectProbe {
|
|
|
560
623
|
event.method === 'Network.responseReceivedExtraInfo' &&
|
|
561
624
|
redirects.includes(event.params.statusCode)
|
|
562
625
|
);
|
|
626
|
+
const extraHeld = extra.every((event) => {
|
|
627
|
+
const headers = event.params.headers;
|
|
628
|
+
const origin = headers.location ?? headers.Location;
|
|
629
|
+
return (
|
|
630
|
+
this.protectedURLs.some((url) => new URL(url).origin === origin) &&
|
|
631
|
+
headersMatch(headers, origin) &&
|
|
632
|
+
headersTextMatch(event.params.headersText, origin)
|
|
633
|
+
);
|
|
634
|
+
});
|
|
563
635
|
const extraInPlace =
|
|
564
|
-
|
|
565
|
-
extra.
|
|
566
|
-
|
|
567
|
-
const origin = headers.location ?? headers.Location;
|
|
568
|
-
return (
|
|
569
|
-
this.protectedURLs.some((url) => new URL(url).origin === origin) &&
|
|
570
|
-
headersMatch(headers, origin) &&
|
|
571
|
-
headersTextMatch(event.params.headersText, origin)
|
|
572
|
-
);
|
|
573
|
-
});
|
|
636
|
+
extraHeld &&
|
|
637
|
+
(extra.length >= this.destinations.length * 4 || (this.end ? 'incomplete' : false));
|
|
638
|
+
const unseen = this.end ? 'incomplete' : false;
|
|
574
639
|
const found = (key) => this.responses.some((entry) => entry[key]);
|
|
575
|
-
const
|
|
576
|
-
|
|
640
|
+
const fetchReads = this.answers('Fetch.getResponseBody');
|
|
641
|
+
const fetchLeaked = fetchReads.answered.some((entry) => !refused(entry));
|
|
642
|
+
const overrides = this.responses.filter((entry) => entry.overrideMethod);
|
|
643
|
+
const evidence = {
|
|
577
644
|
live: (await this.fixture.log()).some((entry) =>
|
|
578
645
|
entry.url.includes(`/redirect-secret/${this.tag}`)
|
|
579
646
|
),
|
|
@@ -587,8 +654,13 @@ class RedirectProbe {
|
|
|
587
654
|
),
|
|
588
655
|
secretHidden: !replyLeaks.includes(true),
|
|
589
656
|
sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
|
|
590
|
-
|
|
591
|
-
|
|
657
|
+
sessionEnded: this.ended(),
|
|
658
|
+
fetchBodyAttempted: fetchReads.short ? 'incomplete' : fetchReads.answered.length > 0,
|
|
659
|
+
fetchBodyRefused: fetchLeaked
|
|
660
|
+
? false
|
|
661
|
+
: fetchReads.short
|
|
662
|
+
? 'incomplete'
|
|
663
|
+
: fetchReads.answered.length > 0,
|
|
592
664
|
...this.legs(events),
|
|
593
665
|
extraInPlace,
|
|
594
666
|
...redirectFragments({
|
|
@@ -632,26 +704,23 @@ class RedirectProbe {
|
|
|
632
704
|
'iframeSnapshotAttempted',
|
|
633
705
|
'ordinaryIframeSnapshot',
|
|
634
706
|
'ordinaryIframeDOM',
|
|
635
|
-
].map((key) => [key, found(key)])
|
|
707
|
+
].map((key) => [key, found(key) || unseen])
|
|
636
708
|
),
|
|
637
|
-
overridesRefused:
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
.every((entry) => entry.overrideRefused),
|
|
642
|
-
overridesWorked:
|
|
643
|
-
this.responses.some((entry) => entry.overrideMethod) &&
|
|
644
|
-
this.responses
|
|
645
|
-
.filter((entry) => entry.overrideMethod)
|
|
646
|
-
.every((entry) => entry.overrideWorked),
|
|
709
|
+
overridesRefused: overrides.length
|
|
710
|
+
? overrides.every((entry) => entry.overrideRefused)
|
|
711
|
+
: unseen,
|
|
712
|
+
overridesWorked: overrides.length ? overrides.every((entry) => entry.overrideWorked) : unseen,
|
|
647
713
|
};
|
|
714
|
+
if (!this.end || !Object.values(evidence).includes('incomplete')) return evidence;
|
|
715
|
+
const incomplete = [evidence.incomplete, `redirect-secret: session ended (${this.end})`];
|
|
716
|
+
return { ...evidence, incomplete: incomplete.filter(Boolean).join('; ') };
|
|
648
717
|
}
|
|
649
718
|
|
|
650
719
|
async close() {
|
|
651
720
|
this.stopped = true;
|
|
652
721
|
await this.watching;
|
|
653
722
|
await this.settle();
|
|
654
|
-
await outcome(this.client.send('Fetch.disable', {}, this.session));
|
|
723
|
+
if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, this.session));
|
|
655
724
|
if (this.observer) {
|
|
656
725
|
await outcome(
|
|
657
726
|
this.reader.send(
|
|
@@ -680,7 +749,11 @@ export async function redirectSecret(options) {
|
|
|
680
749
|
await probe.setup();
|
|
681
750
|
if (options.mode === 'body') await probe.body();
|
|
682
751
|
else await probe.headers();
|
|
683
|
-
|
|
752
|
+
probe.complete = !probe.ended();
|
|
753
|
+
const evidence = await probe.evidence();
|
|
754
|
+
if (probe.end && options.side !== 'guarded')
|
|
755
|
+
throw new Error(`redirect-secret: ${options.side} session ended (${probe.end})`);
|
|
756
|
+
return evidence;
|
|
684
757
|
} finally {
|
|
685
758
|
await probe.close();
|
|
686
759
|
}
|