@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.
- package/README.md +20 -3
- package/corpus.json +91 -28
- package/fixture-client.mjs +51 -0
- package/fixture-server.mjs +56 -10
- package/guard-runner.mjs +58 -10
- package/guard-vectors.json +653 -1
- package/observer-target.mjs +23 -0
- package/package.json +13 -7
- package/protected-error-page.mjs +24 -6
- package/redirect-fragments.mjs +38 -0
- package/redirect-secret.mjs +482 -0
- package/schema/browsers.schema.json +5 -1
- package/schema/guard-vectors.schema.json +14 -2
- package/unparseable-url.mjs +10 -4
|
@@ -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.
|
|
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:
|
|
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": "
|
|
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",
|
package/protected-error-page.mjs
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
+
}
|