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