@ai-ecoverse/slicc-browsers 1.12.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.
@@ -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": { "type": "array", "items": { "$ref": "#/$defs/tab" } },
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,6 +427,15 @@
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,