@ai-ecoverse/slicc-browsers 1.12.0 → 1.13.1

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.
@@ -0,0 +1,23 @@
1
+ import { outcome } from './guard-driver.mjs';
2
+
3
+ export async function createObserverTarget(reader, params, notes = []) {
4
+ const result = await reader.send('Target.createTarget', { ...params, newWindow: true });
5
+ try {
6
+ const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId: result.targetId });
7
+ if (!targetInfo || Object.hasOwn(targetInfo, 'openerId'))
8
+ throw new Error('observer target must have no openerId');
9
+ notes.push(`observer target ${result.targetId}: newWindow=true, openerId absent`);
10
+ return result;
11
+ } catch (error) {
12
+ await outcome(reader.send('Target.closeTarget', { targetId: result.targetId }));
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ export function discoveryViolation(events, declared) {
18
+ if (declared) return null;
19
+ const event = events.find((entry) =>
20
+ ['Target.targetCreated', 'Target.targetInfoChanged'].includes(entry.method)
21
+ );
22
+ return event ? `discovery not declared, but transport delivered ${event.method}` : null;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -38,7 +38,8 @@
38
38
  "./package.json": "./package.json",
39
39
  "./guard-runner.mjs": "./guard-runner.mjs",
40
40
  "./guard-runner": "./guard-runner.mjs",
41
- "./fixture": "./fixture-server.mjs"
41
+ "./fixture": "./fixture-server.mjs",
42
+ "./fixture-client": "./fixture-client.mjs"
42
43
  },
43
44
  "files": [
44
45
  "catalog.json",
@@ -53,7 +54,11 @@
53
54
  "fixture-server.mjs",
54
55
  "extension-world.mjs",
55
56
  "unparseable-url.mjs",
56
- "protected-error-page.mjs"
57
+ "protected-error-page.mjs",
58
+ "redirect-secret.mjs",
59
+ "redirect-fragments.mjs",
60
+ "observer-target.mjs",
61
+ "fixture-client.mjs"
57
62
  ],
58
63
  "scripts": {
59
64
  "prepare": "lefthook install",
@@ -61,21 +66,22 @@
61
66
  "validate": "node scripts/validate.mjs",
62
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",
63
68
  "format": "biome check --write . && gofmt -w . && cargo fmt",
64
- "test": "npm run validate && node --test test/integration/self-contained.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 && go test -race -count=1 ./test/integration/... && cargo test --all-features",
65
70
  "test:unit": "go test -race -cover ./...",
66
71
  "build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
67
72
  "trust": "npx fledgling sync -y --skip-publish",
68
73
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
69
74
  "release": "semantic-release",
70
75
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
71
- "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-error-page.test.mjs test/integration/guard-scales.test.mjs test/integration/guard-shared-log.test.mjs test/integration/guard-pre-beacon.test.mjs test/integration/guard-world-pause.test.mjs test/integration/self-contained.test.mjs",
76
+ "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-redirect-secret.test.mjs test/integration/guard-error-page.test.mjs test/integration/guard-discovery.test.mjs test/integration/guard-browser-page.test.mjs test/integration/guard-scales.test.mjs test/integration/guard-shared-log.test.mjs test/integration/guard-pre-beacon.test.mjs test/integration/guard-world-pause.test.mjs test/integration/self-contained.test.mjs",
72
77
  "test:guard-sweep": "node scripts/test-guards.mjs",
73
78
  "test:gates": "node --test test/integration/gates.test.mjs",
74
79
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
75
- "test:unit:guard": "node --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-lines=100 test/unit/guard-*.test.mjs"
80
+ "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",
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"
76
82
  },
77
83
  "devDependencies": {
78
- "@ai-ecoverse/slicc-shared-web": "1.29.1",
84
+ "@ai-ecoverse/slicc-shared-web": "2.0.2",
79
85
  "@biomejs/biome": "2.5.15",
80
86
  "ajv": "8.20.0",
81
87
  "ajv-formats": "3.0.1",
@@ -1,4 +1,5 @@
1
1
  import { outcome } from './guard-driver.mjs';
2
+ import { createObserverTarget } from './observer-target.mjs';
2
3
 
3
4
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
5
  const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
@@ -43,7 +44,15 @@ async function lockChecks({ client, session, targetId, result }) {
43
44
  result.closeRefused = refused(await outcome(client.send('Target.closeTarget', { targetId })));
44
45
  }
45
46
 
46
- async function redaction({ client, targetId, failedURL, protectedOrigin, start, result }) {
47
+ async function redaction({
48
+ client,
49
+ targetId,
50
+ failedURL,
51
+ protectedOrigin,
52
+ start,
53
+ result,
54
+ discovery,
55
+ }) {
47
56
  const info = await outcome(client.send('Target.getTargetInfo', { targetId }));
48
57
  const list = await outcome(client.send('Target.getTargets'));
49
58
  const before = client.events.length;
@@ -71,11 +80,13 @@ async function redaction({ client, targetId, failedURL, protectedOrigin, start,
71
80
  .slice(start)
72
81
  .filter(
73
82
  (e) =>
83
+ ['Target.targetCreated', 'Target.targetInfoChanged'].includes(e.method) &&
74
84
  e.params?.targetInfo?.targetId === targetId &&
75
85
  e.params.targetInfo.url.includes(new URL(protectedOrigin).host)
76
86
  );
77
- result.eventsRedacted =
78
- targetEvents.length > 0 && targetEvents.every((e) => only(e.params.targetInfo));
87
+ result.eventsRedacted = discovery
88
+ ? targetEvents.length > 0 && targetEvents.every((e) => only(e.params.targetInfo))
89
+ : 'not-supported';
79
90
  }
80
91
 
81
92
  const secrets = {
@@ -94,12 +105,19 @@ export async function protectedErrorPage({
94
105
  side,
95
106
  internalSessions = new Set(),
96
107
  secret = 'all',
108
+ discovery = true,
109
+ notes = [],
97
110
  }) {
98
111
  const initial = `${protectedOrigin}/doc?n=${tag}-history`;
99
112
  const failedURL = `${protectedOrigin}${secrets[secret](tag)}`;
100
113
  const result = {};
114
+ if (!discovery) notes.push('eventsRedacted: not-supported:discovery');
101
115
  const start = client.events.length;
102
- const { targetId } = await reader.send('Target.createTarget', { url: initial, background: true });
116
+ const { targetId } = await createObserverTarget(
117
+ reader,
118
+ { url: initial, background: true },
119
+ notes
120
+ );
103
121
  let session;
104
122
  let observer;
105
123
  let failing = false;
@@ -142,7 +160,7 @@ export async function protectedErrorPage({
142
160
  if (!session) return { live: false, attached: false };
143
161
  await client.send('Page.enable', {}, session);
144
162
  await client.send('Runtime.enable', {}, session);
145
- await client.send('Target.setDiscoverTargets', { discover: true });
163
+ if (discovery) await client.send('Target.setDiscoverTargets', { discover: true });
146
164
  if (side === 'control') {
147
165
  failing = true;
148
166
  await reader.send(
@@ -172,7 +190,7 @@ export async function protectedErrorPage({
172
190
  throw new Error(
173
191
  `protected-error-page: missing error commit ${JSON.stringify(result.evidence)}`
174
192
  );
175
- await redaction({ client, targetId, failedURL, protectedOrigin, start, result });
193
+ await redaction({ client, targetId, failedURL, protectedOrigin, start, result, discovery });
176
194
  const beforeReload = (await frame()).loaderId;
177
195
  result.reload = (await outcome(client.send('Page.reload', {}, session))).ok;
178
196
  if (result.reload) {
@@ -0,0 +1,38 @@
1
+ const fields = (value) => {
2
+ if (!value || typeof value !== 'object') return [];
3
+ return [
4
+ ...(typeof value.urlFragment === 'string' ? [value] : []),
5
+ ...Object.values(value).flatMap(fields),
6
+ ];
7
+ };
8
+
9
+ export function redirectFragments({ messages, events, replies, protectedOrigins, contains }) {
10
+ const protectedURL = (text) => {
11
+ try {
12
+ return protectedOrigins.includes(new URL(text).origin);
13
+ } catch {
14
+ return false;
15
+ }
16
+ };
17
+ const visible = (value) =>
18
+ fields(value).some((field) => field.urlFragment !== '' && contains(field.urlFragment));
19
+ const reply = (method) =>
20
+ replies.filter((entry) => entry.method === method).map((entry) => entry.result);
21
+ return {
22
+ fragmentsHidden: messages
23
+ .flatMap((message) => fields(JSON.parse(message)))
24
+ .every(
25
+ (field) =>
26
+ (!protectedURL(field.url) && !protectedURL(field.unreachableUrl)) ||
27
+ field.urlFragment === ''
28
+ ),
29
+ networkFragmentVisible: events.some(
30
+ (event) => event.method === 'Network.requestWillBeSent' && visible(event.params.request)
31
+ ),
32
+ frameFragmentVisible: events.some(
33
+ (event) => event.method === 'Page.frameNavigated' && visible(event.params.frame)
34
+ ),
35
+ frameTreeFragmentVisible: visible(reply('Page.getFrameTree')),
36
+ resourceTreeFragmentVisible: visible(reply('Page.getResourceTree')),
37
+ };
38
+ }
@@ -0,0 +1,482 @@
1
+ import { load, outcome } from './guard-driver.mjs';
2
+ import { redirectFragments } from './redirect-fragments.mjs';
3
+
4
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ const decodeBase64 = (text) =>
6
+ new TextDecoder().decode(Uint8Array.from(atob(text), (char) => char.charCodeAt(0)));
7
+ const base64Slices = (value) => {
8
+ const normalized = value.replaceAll('-', '+').replaceAll('_', '/').replace(/=+$/, '');
9
+ const candidates = [];
10
+ for (let offset = 0; offset < 4; offset++) {
11
+ const part = normalized.slice(offset);
12
+ const usable = part.slice(0, part.length - (part.length % 4 === 1 ? 1 : 0));
13
+ candidates.push(usable.padEnd(Math.ceil(usable.length / 4) * 4, '='));
14
+ candidates.push(part.slice(0, Math.floor(part.length / 4) * 4));
15
+ }
16
+ return [...new Set(candidates)].filter(Boolean);
17
+ };
18
+ const decodedBase64 = (value) =>
19
+ [...value.matchAll(/[A-Za-z0-9+/_-]{12,}={0,2}/g)].flatMap(([text]) =>
20
+ base64Slices(text).map(decodeBase64)
21
+ );
22
+ const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
23
+ const redirects = [301, 302, 303, 307, 308];
24
+ const redirectHeaders = (to) => ({
25
+ location: to,
26
+ 'content-location': to,
27
+ refresh: `0;url=${to}`,
28
+ link: `<${to}>; rel=preconnect`,
29
+ });
30
+ const headersMatch = (headers, to) => {
31
+ const map = Object.fromEntries(
32
+ (Array.isArray(headers)
33
+ ? headers.map(({ name, value }) => [name, value])
34
+ : Object.entries(headers ?? {})
35
+ ).map(([name, value]) => [name.toLowerCase(), value])
36
+ );
37
+ return Object.entries(redirectHeaders(to)).every(([name, value]) => map[name] === value);
38
+ };
39
+ const headersTextMatch = (text, to) =>
40
+ Object.entries(redirectHeaders(to)).every(([name, value]) =>
41
+ text
42
+ ?.split('\r\n')
43
+ .some(
44
+ (line) =>
45
+ line.slice(0, name.length + 2).toLowerCase() === `${name}: ` &&
46
+ line.slice(name.length + 2) === value
47
+ )
48
+ );
49
+
50
+ export function containsSecret(value, marker) {
51
+ const visit = (value, depth = 0) => {
52
+ if (typeof value === 'string') {
53
+ if (value.includes(marker)) return true;
54
+ if (depth >= 8) return false;
55
+ const decoded = [];
56
+ try {
57
+ decoded.push(decodeURIComponent(value));
58
+ } catch {}
59
+ try {
60
+ decoded.push(JSON.parse(value));
61
+ } catch {}
62
+ try {
63
+ decoded.push(JSON.parse(`"${value}"`));
64
+ } catch {}
65
+ decoded.push(...decodedBase64(value));
66
+ return decoded.some((text) => text !== value && visit(text, depth + 1));
67
+ }
68
+ if (value && typeof value === 'object')
69
+ return Object.values(value).some((v) => visit(v, depth));
70
+ return false;
71
+ };
72
+ return visit(value);
73
+ }
74
+
75
+ class RedirectProbe {
76
+ constructor(options) {
77
+ Object.assign(this, options);
78
+ this.marker = `${this.tag}-secret/+"token`;
79
+ this.cookie = `redirect-secret-${this.tag}`;
80
+ this.messagesStart = this.client.messages?.length;
81
+ this.eventsStart = this.client.events.length;
82
+ this.cursor = this.eventsStart;
83
+ this.responses = [];
84
+ this.attempts = [];
85
+ this.jobs = new Set();
86
+ this.networkBodies = new Set();
87
+ this.registrations = [];
88
+ this.protectedURLs = [
89
+ `https://seven.sliccy.ai/auth/callback?code=${encodeURIComponent(this.marker)}&state=${encodeURIComponent(this.marker)}`,
90
+ `https://vault.sliccy.ai/oauth/mcp-callback?code=${encodeURIComponent(this.marker)}`,
91
+ `https://seven.sliccy.ai/auth/callback#access_token=${encodeURIComponent(this.marker)}&state=${encodeURIComponent(this.marker)}`,
92
+ `https://vault.sliccy.ai/oauth/mcp-callback#access_token=${encodeURIComponent(this.marker)}`,
93
+ ];
94
+ this.plainURL = `${this.ordinary}/redirect-secret-body/${this.tag}-plain?code=${encodeURIComponent(this.marker)}`;
95
+ this.destinations =
96
+ this.side === 'control'
97
+ ? this.protectedURLs.map((url) => `${this.plainURL}${new URL(url).hash}`)
98
+ : this.protectedURLs;
99
+ }
100
+
101
+ async send(method, params = {}) {
102
+ const out = await outcome(this.client.send(method, params, this.session));
103
+ this.responses.push({ method, ...out });
104
+ return out;
105
+ }
106
+
107
+ track(job) {
108
+ this.jobs.add(job);
109
+ job.finally(() => this.jobs.delete(job));
110
+ }
111
+
112
+ async fetchPause(event) {
113
+ if (redirects.includes(event.params.responseStatusCode)) {
114
+ const out = await this.send('Fetch.getResponseBody', { requestId: event.params.requestId });
115
+ this.attempts.push({ method: 'Fetch.getResponseBody', ...out });
116
+ }
117
+ if (
118
+ this.holdURL &&
119
+ event.params.request.url === this.holdURL &&
120
+ !event.params.responseStatusCode
121
+ ) {
122
+ this.held = event;
123
+ return;
124
+ }
125
+ await this.send('Fetch.continueRequest', { requestId: event.params.requestId });
126
+ }
127
+
128
+ async watch() {
129
+ while (!this.stopped) {
130
+ for (const event of this.client.events.slice(this.cursor)) {
131
+ this.cursor++;
132
+ if (event.sessionId !== this.session) continue;
133
+ if (event.method === 'Fetch.requestPaused') this.track(this.fetchPause(event));
134
+ const response = event.params?.redirectResponse ?? event.params?.response;
135
+ if (!response || !redirects.includes(response.status)) continue;
136
+ const requestId = event.params.requestId;
137
+ if (this.networkBodies.has(requestId)) continue;
138
+ this.networkBodies.add(requestId);
139
+ this.track(
140
+ this.send('Network.getResponseBody', { requestId }).then((out) => {
141
+ this.attempts.push({ method: 'Network.getResponseBody', ...out });
142
+ })
143
+ );
144
+ }
145
+ await pause(5);
146
+ }
147
+ }
148
+
149
+ async register(key, to, code = 302) {
150
+ const url = `${this.ordinary}/redirect-secret/${this.tag}-${key}`;
151
+ const path = new URL(url).pathname;
152
+ await this.fixture.redirectSecret({ path, to, code, body: this.marker });
153
+ this.registrations.push({ path });
154
+ return url;
155
+ }
156
+
157
+ async reset(suffix = 'reset') {
158
+ await load(this.reader, this.observer, `${this.ordinary}/doc?n=${this.tag}-${suffix}`);
159
+ }
160
+
161
+ async setup() {
162
+ this.observer = (
163
+ await this.reader.send('Target.attachToTarget', { targetId: this.targetId, flatten: true })
164
+ ).sessionId;
165
+ this.watching = this.watch();
166
+ await this.reader.send('Page.enable', {}, this.observer);
167
+ await this.reader.send('Storage.setCookies', {
168
+ cookies: [
169
+ {
170
+ name: this.cookie,
171
+ value: 'secret-cookie',
172
+ domain: 'seven.sliccy.ai',
173
+ path: '/',
174
+ secure: true,
175
+ sameSite: 'None',
176
+ },
177
+ ],
178
+ });
179
+ for (const domain of ['Page', 'Network', 'Runtime', 'Log', 'Audits'])
180
+ await this.send(`${domain}.enable`);
181
+ await this.send('Fetch.enable', {
182
+ patterns: [
183
+ { urlPattern: '*', requestStage: 'Request' },
184
+ { urlPattern: '*', requestStage: 'Response' },
185
+ ],
186
+ });
187
+ for (const to of [...this.protectedURLs, this.plainURL]) {
188
+ const url = new URL(to);
189
+ if (url.hash) continue;
190
+ const registration = { path: url.pathname, host: url.hostname, search: url.search };
191
+ await this.fixture.redirectSecret({ ...registration, body: `protected-body-${this.marker}` });
192
+ this.registrations.push(registration);
193
+ }
194
+ }
195
+
196
+ async headers() {
197
+ for (const [index, to] of this.destinations.entries()) {
198
+ for (const kind of ['navigate', 'href', 'fetch', 'xhr']) {
199
+ const code = this.mode === 'mix' ? [301, 303, 307, 308][index] : 302;
200
+ const url = await this.register(`${index}-${kind}`, to, code);
201
+ if (kind === 'navigate') await this.send('Page.navigate', { url });
202
+ else {
203
+ await this.reset();
204
+ const expression =
205
+ kind === 'href'
206
+ ? `location.href=${JSON.stringify(url)};void 0`
207
+ : kind === 'fetch'
208
+ ? `fetch(${JSON.stringify(url)}).catch(()=>{});void 0`
209
+ : this.xhrExpression(url);
210
+ await this.send('Runtime.evaluate', { expression });
211
+ }
212
+ await pause(250);
213
+ await this.send('Page.getFrameTree');
214
+ await this.send('Page.getResourceTree');
215
+ await this.send('Page.getNavigationHistory');
216
+ await this.reset();
217
+ }
218
+ }
219
+ }
220
+
221
+ xhrExpression(url) {
222
+ return `(()=>{const x=new XMLHttpRequest();x.open('GET',${JSON.stringify(url)});x.send()})();void 0`;
223
+ }
224
+
225
+ async stream(handle) {
226
+ let text = '';
227
+ if (!handle) return text;
228
+ for (let i = 0; i < 32; i++) {
229
+ const read = await this.send('IO.read', { handle });
230
+ text += read.result?.base64Encoded
231
+ ? decodeBase64(read.result.data)
232
+ : (read.result?.data ?? '');
233
+ if (!read.ok || read.result.eof) break;
234
+ }
235
+ await this.send('IO.close', { handle });
236
+ return text;
237
+ }
238
+
239
+ async snapshots(url, to, frameId) {
240
+ const navigate = await this.send('Page.navigate', { url });
241
+ await pause(500);
242
+ const resource = await this.send('Page.getResourceContent', {
243
+ frameId: navigate.result?.frameId ?? frameId,
244
+ url: to,
245
+ });
246
+ const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
247
+ const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
248
+ this.responses.push({
249
+ resourceContentAttempted: true,
250
+ snapshotAttempted: true,
251
+ domSnapshotAttempted: true,
252
+ ordinaryResource: containsSecret(resource, this.marker),
253
+ ordinarySnapshot: containsSecret(snapshot, this.marker),
254
+ ordinaryDOM: containsSecret(dom, this.marker),
255
+ });
256
+ }
257
+
258
+ async override(url) {
259
+ await this.reset('override');
260
+ this.holdURL = `${this.ordinary}/data?n=${this.tag}-override`;
261
+ this.held = undefined;
262
+ await this.send('Runtime.evaluate', {
263
+ expression: `fetch(${JSON.stringify(this.holdURL)}).catch(()=>{});void 0`,
264
+ });
265
+ const deadline = Date.now() + 5000;
266
+ while (!this.held && Date.now() < deadline) await pause(10);
267
+ if (!this.held) throw new Error('redirect body: Fetch override pause missing');
268
+ const requestId = this.held.params.requestId;
269
+ const out = await this.send('Fetch.continueRequest', { requestId, url });
270
+ this.responses.push({
271
+ overrideMethod: 'Fetch.continueRequest',
272
+ overrideRefused: refused(out),
273
+ overrideWorked: out.ok,
274
+ });
275
+ this.holdURL = undefined;
276
+ if (!out.ok) await this.send('Fetch.continueRequest', { requestId });
277
+ await pause(500);
278
+ }
279
+
280
+ async body() {
281
+ const to = this.destinations[0];
282
+ const url = await this.register('body', to);
283
+ const tree = await this.send('Page.getFrameTree');
284
+ const frameId = tree.result?.frameTree.frame.id;
285
+ const start = (await this.fixture.log()).length;
286
+ const resource = await this.send('Network.loadNetworkResource', {
287
+ frameId,
288
+ url,
289
+ options: { disableCache: true, includeCredentials: true },
290
+ });
291
+ const text = await this.stream(resource.result?.resource?.stream);
292
+ this.responses.push({
293
+ protectedBody: this.side !== 'control' && text.includes(this.marker),
294
+ ordinaryBody: this.side === 'control' && text.includes(this.marker),
295
+ });
296
+ const before = this.client.events.length;
297
+ await this.send('Runtime.evaluate', { expression: this.xhrExpression(url) });
298
+ await pause(500);
299
+ const xhr = this.client.events
300
+ .slice(before)
301
+ .find(
302
+ (event) =>
303
+ event.method === 'Network.requestWillBeSent' &&
304
+ event.params.type === 'XHR' &&
305
+ event.params.request.url === url
306
+ );
307
+ if (!xhr) throw new Error('redirect body: XHR request was not observed');
308
+ await this.send('Network.replayXHR', { requestId: xhr.params.requestId });
309
+ this.responses.push({ replayAttempted: true });
310
+ await pause(500);
311
+ await this.snapshots(url, to, frameId);
312
+ await this.iframe();
313
+ await this.override(url);
314
+ this.responses.push({
315
+ cookieSent: (await this.fixture.log())
316
+ .slice(start)
317
+ .some((entry) => entry.headers.cookie?.includes(`${this.cookie}=`)),
318
+ });
319
+ }
320
+
321
+ async iframe() {
322
+ await this.reset('iframe');
323
+ const url = await this.register('iframe', this.destinations[1]);
324
+ await this.send('Runtime.evaluate', {
325
+ expression: `(()=>{const f=document.createElement('iframe');f.src=${JSON.stringify(url)};document.body.append(f)})();void 0`,
326
+ });
327
+ await pause(500);
328
+ await this.send('Page.getFrameTree');
329
+ const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
330
+ const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
331
+ this.responses.push({
332
+ iframeSnapshotAttempted: true,
333
+ ordinaryIframeSnapshot: containsSecret(snapshot, this.marker),
334
+ ordinaryIframeDOM: containsSecret(dom, this.marker),
335
+ });
336
+ }
337
+
338
+ async evidence() {
339
+ await pause(8000);
340
+ await this.send('Fetch.disable');
341
+ await this.reset('survive');
342
+ const survives = await this.send('Runtime.evaluate', {
343
+ expression: 'document.title',
344
+ returnByValue: true,
345
+ });
346
+ const events = this.client.events.slice(this.eventsStart);
347
+ const messages = this.client.messages?.slice(this.messagesStart) ?? [];
348
+ const ordinaryEvents = events.filter(
349
+ (event) =>
350
+ containsSecret(event, this.marker) &&
351
+ JSON.stringify(event).includes(new URL(this.plainURL).host)
352
+ );
353
+ const extra = events.filter(
354
+ (event) =>
355
+ event.sessionId === this.session &&
356
+ event.method === 'Network.responseReceivedExtraInfo' &&
357
+ redirects.includes(event.params.statusCode)
358
+ );
359
+ const extraInPlace =
360
+ extra.length >= this.destinations.length * 4 &&
361
+ extra.every((event) => {
362
+ const headers = event.params.headers;
363
+ const origin = headers.location ?? headers.Location;
364
+ return (
365
+ this.protectedURLs.some((url) => new URL(url).origin === origin) &&
366
+ headersMatch(headers, origin) &&
367
+ headersTextMatch(event.params.headersText, origin)
368
+ );
369
+ });
370
+ const found = (key) => this.responses.some((entry) => entry[key]);
371
+ const attempted = (method) => this.attempts.some((entry) => entry.method === method);
372
+ const bodyRefused = (method) =>
373
+ this.attempts.filter((entry) => entry.method === method).length > 0 &&
374
+ this.attempts.filter((entry) => entry.method === method).every(refused);
375
+ return {
376
+ live: (await this.fixture.log()).some((entry) =>
377
+ entry.url.includes(`/redirect-secret/${this.tag}`)
378
+ ),
379
+ rawRecorded: this.messagesStart !== undefined && messages.length > events.length,
380
+ leakChannels: [...messages, ...this.responses]
381
+ .filter((message) => containsSecret(message, this.marker))
382
+ .map((message) =>
383
+ typeof message === 'string'
384
+ ? (JSON.parse(message).method ?? 'reply')
385
+ : (message.method ?? 'reply')
386
+ ),
387
+ secretHidden: !containsSecret([...messages, ...this.responses], this.marker),
388
+ sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
389
+ fetchBodyAttempted: attempted('Fetch.getResponseBody'),
390
+ networkBodyAttempted: attempted('Network.getResponseBody'),
391
+ fetchBodyRefused: bodyRefused('Fetch.getResponseBody'),
392
+ networkBodyRefused: bodyRefused('Network.getResponseBody'),
393
+ extraInPlace,
394
+ ...redirectFragments({
395
+ messages,
396
+ events,
397
+ replies: this.responses,
398
+ protectedOrigins: this.protectedURLs.map((url) => new URL(url).origin),
399
+ contains: (value) => containsSecret(value, this.marker),
400
+ }),
401
+ redirectLocation: ordinaryEvents.some(
402
+ (event) =>
403
+ event.method === 'Network.requestWillBeSent' &&
404
+ headersMatch(event.params.redirectResponse?.headers, this.plainURL)
405
+ ),
406
+ extraLocation:
407
+ this.side === 'guarded'
408
+ ? extraInPlace
409
+ : ordinaryEvents.some(
410
+ (event) =>
411
+ event.method === 'Network.responseReceivedExtraInfo' &&
412
+ headersMatch(event.params.headers, this.plainURL) &&
413
+ headersTextMatch(event.params.headersText, this.plainURL)
414
+ ),
415
+ fetchLocation: ordinaryEvents.some(
416
+ (event) =>
417
+ event.method === 'Fetch.requestPaused' &&
418
+ headersMatch(event.params.responseHeaders, this.plainURL)
419
+ ),
420
+ protectedBodyHidden: !found('protectedBody'),
421
+ cookieHidden: !found('cookieSent'),
422
+ ...Object.fromEntries(
423
+ [
424
+ 'ordinaryBody',
425
+ 'replayAttempted',
426
+ 'resourceContentAttempted',
427
+ 'snapshotAttempted',
428
+ 'domSnapshotAttempted',
429
+ 'ordinaryResource',
430
+ 'ordinarySnapshot',
431
+ 'ordinaryDOM',
432
+ 'iframeSnapshotAttempted',
433
+ 'ordinaryIframeSnapshot',
434
+ 'ordinaryIframeDOM',
435
+ ].map((key) => [key, found(key)])
436
+ ),
437
+ overridesRefused:
438
+ this.responses.some((entry) => entry.overrideMethod) &&
439
+ this.responses
440
+ .filter((entry) => entry.overrideMethod)
441
+ .every((entry) => entry.overrideRefused),
442
+ overridesWorked:
443
+ this.responses.some((entry) => entry.overrideMethod) &&
444
+ this.responses
445
+ .filter((entry) => entry.overrideMethod)
446
+ .every((entry) => entry.overrideWorked),
447
+ };
448
+ }
449
+
450
+ async close() {
451
+ this.stopped = true;
452
+ await this.watching;
453
+ await Promise.allSettled([...this.jobs]);
454
+ await outcome(this.client.send('Fetch.disable', {}, this.session));
455
+ if (this.observer) {
456
+ await outcome(
457
+ this.reader.send(
458
+ 'Network.deleteCookies',
459
+ { name: this.cookie, domain: 'seven.sliccy.ai', path: '/' },
460
+ this.observer
461
+ )
462
+ );
463
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: this.observer }));
464
+ }
465
+ for (const registration of this.registrations)
466
+ await this.fixture.redirectSecret({ ...registration, remove: true });
467
+ }
468
+ }
469
+
470
+ export async function redirectSecret(options) {
471
+ if (!options.fixture?.redirectSecret)
472
+ throw new Error('redirectSecret fixture registration required');
473
+ const probe = new RedirectProbe(options);
474
+ try {
475
+ await probe.setup();
476
+ if (options.mode === 'body') await probe.body();
477
+ else await probe.headers();
478
+ return await probe.evidence();
479
+ } finally {
480
+ await probe.close();
481
+ }
482
+ }