@ai-ecoverse/slicc-browsers 1.16.1 → 1.17.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 +11 -4
- package/fixture-server.mjs +12 -0
- package/frame-read.mjs +182 -0
- package/guard-runner.mjs +27 -4
- package/guard-vectors.json +521 -2
- package/package.json +5 -4
- package/redirect-secret.mjs +132 -59
- package/schema/guard-vectors.schema.json +18 -3
package/redirect-secret.mjs
CHANGED
|
@@ -20,6 +20,12 @@ const decodedBase64 = (value) =>
|
|
|
20
20
|
base64Slices(text).map(decodeBase64)
|
|
21
21
|
);
|
|
22
22
|
const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
|
|
23
|
+
const gone = (out) =>
|
|
24
|
+
!out.ok &&
|
|
25
|
+
!refused(out) &&
|
|
26
|
+
/session with given id not found|no session with given id|\bsession (?:has been |is |was )?(?:closed|detached)|\btarget closed|target page, context or browser has been closed|\b(?:cdp|connection|transport|websocket|socket) (?:has been |is |was )?closed|websocket is not open/i.test(
|
|
27
|
+
out.error?.message ?? ''
|
|
28
|
+
);
|
|
23
29
|
const redirects = [301, 302, 303, 307, 308];
|
|
24
30
|
const redirectHeaders = (to) => ({
|
|
25
31
|
location: to,
|
|
@@ -85,6 +91,7 @@ class RedirectProbe {
|
|
|
85
91
|
this.messagesStart = this.client.messages?.length;
|
|
86
92
|
this.eventsStart = this.client.events.length;
|
|
87
93
|
this.cursor = this.eventsStart;
|
|
94
|
+
this.detachScan = this.eventsStart;
|
|
88
95
|
this.responses = [];
|
|
89
96
|
this.attempts = [];
|
|
90
97
|
this.jobs = new Set();
|
|
@@ -107,8 +114,41 @@ class RedirectProbe {
|
|
|
107
114
|
: this.protectedURLs;
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
ended() {
|
|
118
|
+
const events = this.client.events;
|
|
119
|
+
for (; !this.end && this.detachScan < events.length; this.detachScan++) {
|
|
120
|
+
const event = events[this.detachScan];
|
|
121
|
+
if (event.method === 'Target.detachedFromTarget' && event.params?.sessionId === this.session)
|
|
122
|
+
this.end = 'Target.detachedFromTarget';
|
|
123
|
+
}
|
|
124
|
+
return Boolean(this.end);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
cut() {
|
|
128
|
+
return Boolean(this.end) && !this.complete;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async lost() {
|
|
132
|
+
if (!this.ended()) await this.send('Runtime.evaluate', { expression: 'void 0' });
|
|
133
|
+
return this.ended();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
answers(method) {
|
|
137
|
+
const all = this.attempts.filter((entry) => entry.method === method);
|
|
138
|
+
const answered = all.filter((entry) => !(this.end && (entry.pending || entry.ended)));
|
|
139
|
+
const short =
|
|
140
|
+
Boolean(this.end) && (!this.complete || !answered.length || answered.length < all.length);
|
|
141
|
+
return { answered, short };
|
|
142
|
+
}
|
|
143
|
+
|
|
110
144
|
async send(method, params = {}) {
|
|
145
|
+
if (this.ended())
|
|
146
|
+
return { ok: false, ended: true, error: { message: `session ended (${this.end})` } };
|
|
111
147
|
const out = await outcome(this.client.send(method, params, this.session));
|
|
148
|
+
if (gone(out)) {
|
|
149
|
+
this.end ??= out.error.message;
|
|
150
|
+
return { ...out, ended: true };
|
|
151
|
+
}
|
|
112
152
|
this.responses.push({ method, ...out });
|
|
113
153
|
return out;
|
|
114
154
|
}
|
|
@@ -151,7 +191,7 @@ class RedirectProbe {
|
|
|
151
191
|
|
|
152
192
|
async networkBody(requestId, source) {
|
|
153
193
|
this.learned[source].add(requestId);
|
|
154
|
-
if (this.networkBodies.has(requestId)) return;
|
|
194
|
+
if (this.networkBodies.has(requestId) || this.ended()) return;
|
|
155
195
|
this.networkBodies.add(requestId);
|
|
156
196
|
const entry = { method: 'Network.getResponseBody', source, pending: true };
|
|
157
197
|
this.attempts.push(entry);
|
|
@@ -191,15 +231,17 @@ class RedirectProbe {
|
|
|
191
231
|
carriers.filter((entry) => entry === carrier).length,
|
|
192
232
|
])
|
|
193
233
|
);
|
|
194
|
-
const attempts = this.
|
|
234
|
+
const { answered: attempts, short } = this.answers('Network.getResponseBody');
|
|
195
235
|
const refusedAll = attempts.length > 0 && attempts.every(refused);
|
|
196
236
|
const proven = path === 'network' || this.side !== 'guarded' || this.controls?.networkEvents;
|
|
197
237
|
const reason =
|
|
198
238
|
path === 'none'
|
|
199
239
|
? 'no network id for the redirect'
|
|
200
|
-
: proven
|
|
201
|
-
?
|
|
202
|
-
:
|
|
240
|
+
: !proven
|
|
241
|
+
? 'control did not prove the redirect Network events'
|
|
242
|
+
: short
|
|
243
|
+
? `session ended (${this.end}) before every network body read was answered`
|
|
244
|
+
: undefined;
|
|
203
245
|
if (reason && (!attempts.length || refusedAll))
|
|
204
246
|
return {
|
|
205
247
|
networkBodyPath: path,
|
|
@@ -254,11 +296,12 @@ class RedirectProbe {
|
|
|
254
296
|
async land(url, start) {
|
|
255
297
|
const path = new URL(url).pathname;
|
|
256
298
|
await pause(250);
|
|
257
|
-
if (this.stalled) return;
|
|
299
|
+
if (this.stalled || this.ended()) return;
|
|
258
300
|
const landed = await waitFor(
|
|
259
301
|
async () =>
|
|
260
|
-
|
|
261
|
-
|
|
302
|
+
this.ended() ||
|
|
303
|
+
((await this.fixture.log()).some((entry) => entry.url.includes(path)) &&
|
|
304
|
+
!this.open(this.client.events.slice(start), (seen) => seen === url).length),
|
|
262
305
|
Math.round(2000 * this.scale),
|
|
263
306
|
50
|
|
264
307
|
);
|
|
@@ -267,7 +310,7 @@ class RedirectProbe {
|
|
|
267
310
|
|
|
268
311
|
async terminated() {
|
|
269
312
|
await waitFor(
|
|
270
|
-
() => !this.open(this.client.events.slice(this.eventsStart)).length,
|
|
313
|
+
() => this.ended() || !this.open(this.client.events.slice(this.eventsStart)).length,
|
|
271
314
|
Math.round(5000 * this.scale),
|
|
272
315
|
50
|
|
273
316
|
);
|
|
@@ -300,13 +343,15 @@ class RedirectProbe {
|
|
|
300
343
|
);
|
|
301
344
|
const deliveries = Object.values(hops).map((entry) => entry.delivery);
|
|
302
345
|
const open = Object.keys(hops).filter((id) => hops[id].delivery === 'unterminated');
|
|
303
|
-
const missing = open.length > 0 && this.side === 'control';
|
|
346
|
+
const missing = (open.length > 0 && (this.side === 'control' || this.end)) || this.cut();
|
|
347
|
+
const who = this.end ? `session ended (${this.end}) with requests` : 'control requests';
|
|
348
|
+
const detail = open.length
|
|
349
|
+
? `${who} ${open.join(', ')} unterminated`
|
|
350
|
+
: `session ended (${this.end}) before every request was issued`;
|
|
304
351
|
return {
|
|
305
352
|
hops,
|
|
306
353
|
requestsTerminated: missing ? 'incomplete' : !open.length,
|
|
307
|
-
...(missing
|
|
308
|
-
? { incomplete: `redirect-secret: control requests ${open.join(', ')} unterminated` }
|
|
309
|
-
: {}),
|
|
354
|
+
...(missing ? { incomplete: `redirect-secret: ${detail}` } : {}),
|
|
310
355
|
hopsRedacted: !deliveries.some((delivery) => ['raw', 'ordinary'].includes(delivery)),
|
|
311
356
|
};
|
|
312
357
|
}
|
|
@@ -388,6 +433,7 @@ class RedirectProbe {
|
|
|
388
433
|
async headers() {
|
|
389
434
|
for (const [index, to] of this.destinations.entries()) {
|
|
390
435
|
for (const kind of ['navigate', 'href', 'fetch', 'xhr']) {
|
|
436
|
+
if (this.ended()) return;
|
|
391
437
|
const code = this.mode === 'mix' ? [301, 303, 307, 308][index] : 302;
|
|
392
438
|
const url = await this.register(`${index}-${kind}`, to, code);
|
|
393
439
|
const start = this.client.events.length;
|
|
@@ -439,9 +485,9 @@ class RedirectProbe {
|
|
|
439
485
|
const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
|
|
440
486
|
const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
|
|
441
487
|
this.responses.push({
|
|
442
|
-
resourceContentAttempted:
|
|
443
|
-
snapshotAttempted:
|
|
444
|
-
domSnapshotAttempted:
|
|
488
|
+
resourceContentAttempted: !resource.ended,
|
|
489
|
+
snapshotAttempted: !snapshot.ended,
|
|
490
|
+
domSnapshotAttempted: !dom.ended,
|
|
445
491
|
ordinaryResource: this.contains(resource),
|
|
446
492
|
ordinarySnapshot: this.contains(snapshot),
|
|
447
493
|
ordinaryDOM: this.contains(dom),
|
|
@@ -455,16 +501,21 @@ class RedirectProbe {
|
|
|
455
501
|
await this.send('Runtime.evaluate', {
|
|
456
502
|
expression: `fetch(${JSON.stringify(this.holdURL)}).catch(()=>{});void 0`,
|
|
457
503
|
});
|
|
458
|
-
|
|
504
|
+
await waitFor(() => Boolean(this.held) || this.ended(), 5000, 10);
|
|
505
|
+
if (!this.held) {
|
|
506
|
+
this.holdURL = undefined;
|
|
507
|
+
if (await this.lost()) return;
|
|
459
508
|
throw new Error('redirect body: Fetch override pause missing');
|
|
509
|
+
}
|
|
460
510
|
const requestId = this.held.params.requestId;
|
|
461
511
|
const out = await this.send('Fetch.continueRequest', { requestId, url });
|
|
512
|
+
this.holdURL = undefined;
|
|
513
|
+
if (out.ended) return;
|
|
462
514
|
this.responses.push({
|
|
463
515
|
overrideMethod: 'Fetch.continueRequest',
|
|
464
516
|
overrideRefused: refused(out),
|
|
465
517
|
overrideWorked: out.ok,
|
|
466
518
|
});
|
|
467
|
-
this.holdURL = undefined;
|
|
468
519
|
if (!out.ok) await this.send('Fetch.continueRequest', { requestId });
|
|
469
520
|
await pause(500);
|
|
470
521
|
}
|
|
@@ -475,6 +526,15 @@ class RedirectProbe {
|
|
|
475
526
|
const tree = await this.send('Page.getFrameTree');
|
|
476
527
|
const frameId = tree.result?.frameTree.frame.id;
|
|
477
528
|
const start = (await this.fixture.log()).length;
|
|
529
|
+
await this.reads(url, to, frameId);
|
|
530
|
+
this.responses.push({
|
|
531
|
+
cookieSent: (await this.fixture.log())
|
|
532
|
+
.slice(start)
|
|
533
|
+
.some((entry) => entry.headers.cookie?.includes(`${this.cookie}=`)),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async reads(url, to, frameId) {
|
|
478
538
|
const resource = await this.send('Network.loadNetworkResource', {
|
|
479
539
|
frameId,
|
|
480
540
|
url,
|
|
@@ -496,18 +556,21 @@ class RedirectProbe {
|
|
|
496
556
|
event.params.type === 'XHR' &&
|
|
497
557
|
event.params.request.url === url
|
|
498
558
|
);
|
|
499
|
-
if (!xhr)
|
|
500
|
-
|
|
501
|
-
|
|
559
|
+
if (!xhr) {
|
|
560
|
+
if (await this.lost()) return;
|
|
561
|
+
throw new Error('redirect body: XHR request was not observed');
|
|
562
|
+
}
|
|
563
|
+
const replay = await this.send('Network.replayXHR', { requestId: xhr.params.requestId });
|
|
564
|
+
this.responses.push({ replayAttempted: !replay.ended });
|
|
502
565
|
await pause(500);
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
566
|
+
for (const step of [
|
|
567
|
+
() => this.snapshots(url, to, frameId),
|
|
568
|
+
() => this.iframe(),
|
|
569
|
+
() => this.override(url),
|
|
570
|
+
]) {
|
|
571
|
+
if (this.ended()) return;
|
|
572
|
+
await step();
|
|
573
|
+
}
|
|
511
574
|
}
|
|
512
575
|
|
|
513
576
|
async iframe() {
|
|
@@ -521,15 +584,15 @@ class RedirectProbe {
|
|
|
521
584
|
const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
|
|
522
585
|
const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
|
|
523
586
|
this.responses.push({
|
|
524
|
-
iframeSnapshotAttempted:
|
|
587
|
+
iframeSnapshotAttempted: !snapshot.ended && !dom.ended,
|
|
525
588
|
ordinaryIframeSnapshot: this.contains(snapshot),
|
|
526
589
|
ordinaryIframeDOM: this.contains(dom),
|
|
527
590
|
});
|
|
528
591
|
}
|
|
529
592
|
|
|
530
593
|
async evidence() {
|
|
531
|
-
await pause(8000);
|
|
532
|
-
this.tap();
|
|
594
|
+
if (!this.ended()) await pause(8000);
|
|
595
|
+
if (!this.ended()) this.tap();
|
|
533
596
|
if (this.side === 'control' && this.controls)
|
|
534
597
|
this.controls.networkEvents = this.learned.network.size > 0;
|
|
535
598
|
await this.send('Fetch.disable');
|
|
@@ -560,20 +623,24 @@ class RedirectProbe {
|
|
|
560
623
|
event.method === 'Network.responseReceivedExtraInfo' &&
|
|
561
624
|
redirects.includes(event.params.statusCode)
|
|
562
625
|
);
|
|
626
|
+
const extraHeld = extra.every((event) => {
|
|
627
|
+
const headers = event.params.headers;
|
|
628
|
+
const origin = headers.location ?? headers.Location;
|
|
629
|
+
return (
|
|
630
|
+
this.protectedURLs.some((url) => new URL(url).origin === origin) &&
|
|
631
|
+
headersMatch(headers, origin) &&
|
|
632
|
+
headersTextMatch(event.params.headersText, origin)
|
|
633
|
+
);
|
|
634
|
+
});
|
|
563
635
|
const extraInPlace =
|
|
564
|
-
|
|
565
|
-
extra.
|
|
566
|
-
|
|
567
|
-
const origin = headers.location ?? headers.Location;
|
|
568
|
-
return (
|
|
569
|
-
this.protectedURLs.some((url) => new URL(url).origin === origin) &&
|
|
570
|
-
headersMatch(headers, origin) &&
|
|
571
|
-
headersTextMatch(event.params.headersText, origin)
|
|
572
|
-
);
|
|
573
|
-
});
|
|
636
|
+
extraHeld &&
|
|
637
|
+
(extra.length >= this.destinations.length * 4 || (this.end ? 'incomplete' : false));
|
|
638
|
+
const unseen = this.end ? 'incomplete' : false;
|
|
574
639
|
const found = (key) => this.responses.some((entry) => entry[key]);
|
|
575
|
-
const
|
|
576
|
-
|
|
640
|
+
const fetchReads = this.answers('Fetch.getResponseBody');
|
|
641
|
+
const fetchLeaked = fetchReads.answered.some((entry) => !refused(entry));
|
|
642
|
+
const overrides = this.responses.filter((entry) => entry.overrideMethod);
|
|
643
|
+
const evidence = {
|
|
577
644
|
live: (await this.fixture.log()).some((entry) =>
|
|
578
645
|
entry.url.includes(`/redirect-secret/${this.tag}`)
|
|
579
646
|
),
|
|
@@ -587,8 +654,13 @@ class RedirectProbe {
|
|
|
587
654
|
),
|
|
588
655
|
secretHidden: !replyLeaks.includes(true),
|
|
589
656
|
sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
|
|
590
|
-
|
|
591
|
-
|
|
657
|
+
sessionEnded: this.ended(),
|
|
658
|
+
fetchBodyAttempted: fetchReads.short ? 'incomplete' : fetchReads.answered.length > 0,
|
|
659
|
+
fetchBodyRefused: fetchLeaked
|
|
660
|
+
? false
|
|
661
|
+
: fetchReads.short
|
|
662
|
+
? 'incomplete'
|
|
663
|
+
: fetchReads.answered.length > 0,
|
|
592
664
|
...this.legs(events),
|
|
593
665
|
extraInPlace,
|
|
594
666
|
...redirectFragments({
|
|
@@ -632,26 +704,23 @@ class RedirectProbe {
|
|
|
632
704
|
'iframeSnapshotAttempted',
|
|
633
705
|
'ordinaryIframeSnapshot',
|
|
634
706
|
'ordinaryIframeDOM',
|
|
635
|
-
].map((key) => [key, found(key)])
|
|
707
|
+
].map((key) => [key, found(key) || unseen])
|
|
636
708
|
),
|
|
637
|
-
overridesRefused:
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
.every((entry) => entry.overrideRefused),
|
|
642
|
-
overridesWorked:
|
|
643
|
-
this.responses.some((entry) => entry.overrideMethod) &&
|
|
644
|
-
this.responses
|
|
645
|
-
.filter((entry) => entry.overrideMethod)
|
|
646
|
-
.every((entry) => entry.overrideWorked),
|
|
709
|
+
overridesRefused: overrides.length
|
|
710
|
+
? overrides.every((entry) => entry.overrideRefused)
|
|
711
|
+
: unseen,
|
|
712
|
+
overridesWorked: overrides.length ? overrides.every((entry) => entry.overrideWorked) : unseen,
|
|
647
713
|
};
|
|
714
|
+
if (!this.end || !Object.values(evidence).includes('incomplete')) return evidence;
|
|
715
|
+
const incomplete = [evidence.incomplete, `redirect-secret: session ended (${this.end})`];
|
|
716
|
+
return { ...evidence, incomplete: incomplete.filter(Boolean).join('; ') };
|
|
648
717
|
}
|
|
649
718
|
|
|
650
719
|
async close() {
|
|
651
720
|
this.stopped = true;
|
|
652
721
|
await this.watching;
|
|
653
722
|
await this.settle();
|
|
654
|
-
await outcome(this.client.send('Fetch.disable', {}, this.session));
|
|
723
|
+
if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, this.session));
|
|
655
724
|
if (this.observer) {
|
|
656
725
|
await outcome(
|
|
657
726
|
this.reader.send(
|
|
@@ -680,7 +749,11 @@ export async function redirectSecret(options) {
|
|
|
680
749
|
await probe.setup();
|
|
681
750
|
if (options.mode === 'body') await probe.body();
|
|
682
751
|
else await probe.headers();
|
|
683
|
-
|
|
752
|
+
probe.complete = !probe.ended();
|
|
753
|
+
const evidence = await probe.evidence();
|
|
754
|
+
if (probe.end && options.side !== 'guarded')
|
|
755
|
+
throw new Error(`redirect-secret: ${options.side} session ended (${probe.end})`);
|
|
756
|
+
return evidence;
|
|
684
757
|
} finally {
|
|
685
758
|
await probe.close();
|
|
686
759
|
}
|
|
@@ -88,7 +88,8 @@
|
|
|
88
88
|
"chromeUrlsAttach",
|
|
89
89
|
"extensionWorld",
|
|
90
90
|
"discovery",
|
|
91
|
-
"clientExit"
|
|
91
|
+
"clientExit",
|
|
92
|
+
"siteIsolationOff"
|
|
92
93
|
],
|
|
93
94
|
"properties": {
|
|
94
95
|
"autoAttach": {
|
|
@@ -109,7 +110,8 @@
|
|
|
109
110
|
"type": "string",
|
|
110
111
|
"minLength": 1
|
|
111
112
|
},
|
|
112
|
-
"clientExit": { "type": "string", "minLength": 1 }
|
|
113
|
+
"clientExit": { "type": "string", "minLength": 1 },
|
|
114
|
+
"siteIsolationOff": { "type": "string", "minLength": 1 }
|
|
113
115
|
}
|
|
114
116
|
},
|
|
115
117
|
"placeholders": {
|
|
@@ -139,7 +141,8 @@
|
|
|
139
141
|
"chromeUrlsAttach",
|
|
140
142
|
"extensionWorld",
|
|
141
143
|
"discovery",
|
|
142
|
-
"clientExit"
|
|
144
|
+
"clientExit",
|
|
145
|
+
"siteIsolationOff"
|
|
143
146
|
]
|
|
144
147
|
},
|
|
145
148
|
"requires": {
|
|
@@ -453,6 +456,18 @@
|
|
|
453
456
|
"as": { "$ref": "#/$defs/name" }
|
|
454
457
|
}
|
|
455
458
|
},
|
|
459
|
+
{
|
|
460
|
+
"type": "object",
|
|
461
|
+
"additionalProperties": false,
|
|
462
|
+
"required": ["frameRead", "from", "as"],
|
|
463
|
+
"properties": {
|
|
464
|
+
"frameRead": { "enum": ["blank", "srcdoc"] },
|
|
465
|
+
"from": { "type": "string", "minLength": 1 },
|
|
466
|
+
"on": { "$ref": "#/$defs/on" },
|
|
467
|
+
"requires": { "$ref": "#/$defs/requires" },
|
|
468
|
+
"as": { "$ref": "#/$defs/name" }
|
|
469
|
+
}
|
|
470
|
+
},
|
|
456
471
|
{
|
|
457
472
|
"type": "object",
|
|
458
473
|
"additionalProperties": false,
|