@ai-ecoverse/slicc-browsers 1.16.2 → 1.18.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 +19 -4
- package/bfcache-restore.mjs +514 -0
- package/fixture-server.mjs +22 -0
- package/frame-read.mjs +182 -0
- package/guard-runner.mjs +66 -6
- package/guard-vectors.json +810 -2
- package/package.json +8 -6
- package/schema/guard-vectors.schema.json +38 -3
package/fixture-server.mjs
CHANGED
|
@@ -29,6 +29,17 @@ function trigger(p) {
|
|
|
29
29
|
return `<p>trigger</p><button onclick="go()">Go</button>${frame}<script>const to=${JSON.stringify(to)};function go(){const k=${JSON.stringify(kind)};if(k==='href'||k==='deferred')location.href=to;if(k==='replace')location.replace(to);if(k==='popup')window.open(to);if(k==='form'){const u=new URL(to);const f=document.createElement('form');f.action=u.origin+u.pathname;for(const [name,value] of u.searchParams){const i=document.createElement('input');i.type='hidden';i.name=name;i.value=value;f.append(i);}document.body.append(f);f.submit();}return 'went';}${kind === 'deferred' ? `setTimeout(go,${Number(p.get('defer') ?? 3000)});` : ''}</script>`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function nest(p) {
|
|
33
|
+
const n = p.get('n') ?? '';
|
|
34
|
+
const marker = escape(`frame-marker-${n}`);
|
|
35
|
+
const child =
|
|
36
|
+
p.get('kind') === 'srcdoc'
|
|
37
|
+
? `<iframe name="${marker}" srcdoc="${escape(`<p>${marker}</p>`)}"></iframe>`
|
|
38
|
+
: `<iframe name="${marker}"></iframe><script>const f=document.querySelector('iframe');const p=f.contentDocument.createElement('p');p.textContent=f.name;f.contentDocument.body.append(p)</script>`;
|
|
39
|
+
const tag = JSON.stringify(n).replaceAll('<', '\\u003c');
|
|
40
|
+
return `<p>${marker}</p>${child}<script>const n=${tag};history.replaceState(null,'',location.pathname+'/path-'+'secret-'+n+'?query-'+'secret='+n+'#fragment-'+'secret-'+n)</script>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
function route(req, url, send, html, redirects) {
|
|
33
44
|
const secret =
|
|
34
45
|
redirects.get(`${url.hostname}${url.pathname}${url.search ?? ''}`) ??
|
|
@@ -80,6 +91,16 @@ function route(req, url, send, html, redirects) {
|
|
|
80
91
|
`<script>navigator.sendBeacon(${JSON.stringify(pre)})</script><iframe src="${escape(to)}"></iframe>`
|
|
81
92
|
);
|
|
82
93
|
},
|
|
94
|
+
'/bfcache': () => {
|
|
95
|
+
const marker = JSON.stringify(`bfcache-marker-${p.get('n') ?? ''}`).replaceAll(
|
|
96
|
+
'<',
|
|
97
|
+
'\\u003c'
|
|
98
|
+
);
|
|
99
|
+
return html(
|
|
100
|
+
'bfcache',
|
|
101
|
+
`<p id="bfcache-marker"></p><script>globalThis.__bfcacheMarker=${marker};sessionStorage.setItem('bfcache-marker',globalThis.__bfcacheMarker);document.querySelector('p').textContent=globalThis.__bfcacheMarker;globalThis.__bfcachePersisted=false;addEventListener('pageshow',e=>{globalThis.__bfcachePersisted=e.persisted});addEventListener('keydown',()=>{globalThis.__bfcacheKeys=(globalThis.__bfcacheKeys||0)+1})</script>`
|
|
102
|
+
);
|
|
103
|
+
},
|
|
83
104
|
'/doc': () => document(url, html),
|
|
84
105
|
'/csp': () => document(url, html),
|
|
85
106
|
};
|
|
@@ -95,6 +116,7 @@ function route(req, url, send, html, redirects) {
|
|
|
95
116
|
'canary',
|
|
96
117
|
`<p data-canary="${escape(p.get('value') ?? 'planted')}">canary-marker ${escape(url.host)}</p>`
|
|
97
118
|
);
|
|
119
|
+
if (url.pathname.startsWith('/nest')) return html('nest', nest(p));
|
|
98
120
|
const handler = routes[url.pathname];
|
|
99
121
|
if (handler) return handler();
|
|
100
122
|
return send(404, 'missing', 'text/plain');
|
package/frame-read.mjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { outcome, parseURL, waitFor } from './guard-driver.mjs';
|
|
2
|
+
import { createObserverTarget } from './observer-target.mjs';
|
|
3
|
+
|
|
4
|
+
export const frameReads = ['frameTree', 'axFull', 'axPartial', 'document', 'node', 'snapshot'];
|
|
5
|
+
|
|
6
|
+
const unanswered = (out) => !out.ok && /timed out|closed/i.test(out.error?.message ?? '');
|
|
7
|
+
|
|
8
|
+
export function frameReadCalls(layout) {
|
|
9
|
+
return {
|
|
10
|
+
frameTree: [['Page.getFrameTree', {}]],
|
|
11
|
+
axFull: [
|
|
12
|
+
['Accessibility.getFullAXTree', { frameId: layout.frame }],
|
|
13
|
+
['Accessibility.getFullAXTree', { frameId: layout.child }],
|
|
14
|
+
],
|
|
15
|
+
axPartial: [
|
|
16
|
+
['Accessibility.getPartialAXTree', { backendNodeId: layout.marker, fetchRelatives: true }],
|
|
17
|
+
],
|
|
18
|
+
document: [['DOM.getDocument', { depth: -1, pierce: true }]],
|
|
19
|
+
node: [
|
|
20
|
+
['DOM.describeNode', { backendNodeId: layout.childDocument, depth: -1, pierce: true }],
|
|
21
|
+
['DOM.getOuterHTML', { backendNodeId: layout.marker }],
|
|
22
|
+
],
|
|
23
|
+
snapshot: [['DOMSnapshot.captureSnapshot', { computedStyles: [] }]],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function frameNeedles(tag) {
|
|
28
|
+
return [
|
|
29
|
+
`frame-marker-${tag}`,
|
|
30
|
+
`path-secret-${tag}`,
|
|
31
|
+
`query-secret=${tag}`,
|
|
32
|
+
`fragment-secret-${tag}`,
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const shows = (value, needles) => {
|
|
37
|
+
const text = JSON.stringify(value) ?? '';
|
|
38
|
+
return needles.some((needle) => text.includes(needle));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function find(node, test) {
|
|
42
|
+
if (!node) return undefined;
|
|
43
|
+
if (test(node)) return node;
|
|
44
|
+
for (const next of [
|
|
45
|
+
...(node.children ?? []),
|
|
46
|
+
node.contentDocument,
|
|
47
|
+
...(node.shadowRoots ?? []),
|
|
48
|
+
]) {
|
|
49
|
+
const hit = find(next, test);
|
|
50
|
+
if (hit) return hit;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function frameLayout(reader, session, { framed, marker, kind }) {
|
|
56
|
+
const { frameTree } = await reader.send('Page.getFrameTree', {}, session);
|
|
57
|
+
const host = (frameTree.childFrames ?? []).find((node) => {
|
|
58
|
+
const url = parseURL(node.frame.url);
|
|
59
|
+
return url?.origin === parseURL(framed)?.origin && url.pathname.startsWith('/nest');
|
|
60
|
+
});
|
|
61
|
+
const child = (host?.childFrames ?? []).find((node) => node.frame.name === marker);
|
|
62
|
+
if (!child || child.frame.url !== kind) return undefined;
|
|
63
|
+
const { root } = await reader.send('DOM.getDocument', { depth: -1, pierce: true }, session);
|
|
64
|
+
const owner = find(
|
|
65
|
+
root,
|
|
66
|
+
(node) =>
|
|
67
|
+
node.nodeName === 'IFRAME' &&
|
|
68
|
+
node.frameId === child.frame.id &&
|
|
69
|
+
(node.attributes ?? []).includes(marker)
|
|
70
|
+
);
|
|
71
|
+
const text = find(
|
|
72
|
+
owner?.contentDocument,
|
|
73
|
+
(node) => node.nodeType === 3 && node.nodeValue === marker
|
|
74
|
+
);
|
|
75
|
+
const paragraph = find(owner?.contentDocument, (node) => (node.children ?? []).includes(text));
|
|
76
|
+
if (!paragraph) return undefined;
|
|
77
|
+
return {
|
|
78
|
+
frame: host.frame.id,
|
|
79
|
+
child: child.frame.id,
|
|
80
|
+
childDocument: owner.contentDocument.backendNodeId,
|
|
81
|
+
marker: paragraph.backendNodeId,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function read(client, session, calls, needles) {
|
|
86
|
+
let shown = false;
|
|
87
|
+
let silent = false;
|
|
88
|
+
for (const [method, params] of calls) {
|
|
89
|
+
const out = await outcome(client.send(method, params, session));
|
|
90
|
+
shown ||= shows(out, needles);
|
|
91
|
+
silent ||= unanswered(out);
|
|
92
|
+
}
|
|
93
|
+
return shown || (silent ? 'incomplete' : false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function enable(client, session) {
|
|
97
|
+
for (const method of ['Page.enable', 'DOM.enable', 'Accessibility.enable'])
|
|
98
|
+
await outcome(client.send(method, {}, session));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function frameRead({
|
|
102
|
+
client,
|
|
103
|
+
reader,
|
|
104
|
+
top,
|
|
105
|
+
framed,
|
|
106
|
+
tag,
|
|
107
|
+
mode,
|
|
108
|
+
side,
|
|
109
|
+
notes = [],
|
|
110
|
+
budget = 5000,
|
|
111
|
+
}) {
|
|
112
|
+
const kind = mode === 'srcdoc' ? 'about:srcdoc' : 'about:blank';
|
|
113
|
+
const marker = `frame-marker-${tag}`;
|
|
114
|
+
const needles = frameNeedles(tag);
|
|
115
|
+
const nest = `${framed}/nest?n=${encodeURIComponent(tag)}&kind=${mode}`;
|
|
116
|
+
const page = `${top}/framed?tag=${encodeURIComponent(tag)}&url=${encodeURIComponent(nest)}`;
|
|
117
|
+
const result = {};
|
|
118
|
+
const missing = [];
|
|
119
|
+
const done = () =>
|
|
120
|
+
missing.length ? { ...result, incomplete: `frame-read: ${missing.join('; ')}` } : result;
|
|
121
|
+
const opened = await outcome(
|
|
122
|
+
createObserverTarget(reader, { url: 'about:blank', background: true }, notes)
|
|
123
|
+
);
|
|
124
|
+
if (!opened.ok)
|
|
125
|
+
return { incomplete: `frame-read: observer setup failed: ${opened.error.message}` };
|
|
126
|
+
const { targetId } = opened.result;
|
|
127
|
+
let session;
|
|
128
|
+
try {
|
|
129
|
+
const observer = (await reader.send('Target.attachToTarget', { targetId, flatten: true }))
|
|
130
|
+
.sessionId;
|
|
131
|
+
await enable(reader, observer);
|
|
132
|
+
await reader.send('Page.navigate', { url: page }, observer);
|
|
133
|
+
let layout;
|
|
134
|
+
await waitFor(async () => {
|
|
135
|
+
const out = await outcome(frameLayout(reader, observer, { framed, marker, kind }));
|
|
136
|
+
layout = out.result;
|
|
137
|
+
return Boolean(layout);
|
|
138
|
+
}, budget);
|
|
139
|
+
if (!layout) {
|
|
140
|
+
await outcome(reader.send('Target.detachFromTarget', { sessionId: observer }));
|
|
141
|
+
return {
|
|
142
|
+
incomplete: `frame-read: no ${kind} child of ${framed} in the page's renderer within ${budget}ms`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
result.live = true;
|
|
146
|
+
const calls = frameReadCalls(layout);
|
|
147
|
+
const proven = {};
|
|
148
|
+
for (const name of frameReads) {
|
|
149
|
+
proven[name] = (await read(reader, observer, calls[name], needles)) === true;
|
|
150
|
+
if (!proven[name]) missing.push(`observer ${name} did not show the frame's content`);
|
|
151
|
+
}
|
|
152
|
+
await outcome(reader.send('Target.detachFromTarget', { sessionId: observer }));
|
|
153
|
+
const start = client.events.length;
|
|
154
|
+
const attached = await outcome(
|
|
155
|
+
client.send('Target.attachToTarget', { targetId, flatten: true })
|
|
156
|
+
);
|
|
157
|
+
session = attached.result?.sessionId;
|
|
158
|
+
result.attached = Boolean(session);
|
|
159
|
+
if (!session && (unanswered(attached) || side === 'control')) {
|
|
160
|
+
missing.push(`attach ${attached.error?.message ?? 'returned no session'}`);
|
|
161
|
+
return done();
|
|
162
|
+
}
|
|
163
|
+
if (session) await enable(client, session);
|
|
164
|
+
for (const name of frameReads) {
|
|
165
|
+
const value = session ? await read(client, session, calls[name], needles) : false;
|
|
166
|
+
if (value === 'incomplete') missing.push(`${name} unanswered`);
|
|
167
|
+
result[`${name}Shown`] = value === true || proven[name] ? value : 'incomplete';
|
|
168
|
+
}
|
|
169
|
+
result.eventsShown = shows(
|
|
170
|
+
client.events
|
|
171
|
+
.slice(start)
|
|
172
|
+
.filter(
|
|
173
|
+
(event) => session && (event.sessionId === session || event.params?.sessionId === session)
|
|
174
|
+
),
|
|
175
|
+
needles
|
|
176
|
+
);
|
|
177
|
+
return done();
|
|
178
|
+
} finally {
|
|
179
|
+
if (session) await outcome(client.send('Target.detachFromTarget', { sessionId: session }));
|
|
180
|
+
await outcome(reader.send('Target.closeTarget', { targetId }));
|
|
181
|
+
}
|
|
182
|
+
}
|
package/guard-runner.mjs
CHANGED
|
@@ -2,7 +2,9 @@ import { fixtureClient, prepareFixture } from './fixture-client.mjs';
|
|
|
2
2
|
|
|
3
3
|
export { fixtureClient };
|
|
4
4
|
|
|
5
|
+
import { bfcacheRestore } from './bfcache-restore.mjs';
|
|
5
6
|
import { extensionWorld } from './extension-world.mjs';
|
|
7
|
+
import { frameRead } from './frame-read.mjs';
|
|
6
8
|
import {
|
|
7
9
|
beacon,
|
|
8
10
|
canary,
|
|
@@ -669,12 +671,21 @@ class Execution {
|
|
|
669
671
|
);
|
|
670
672
|
if (step.extensionWorld && out.result?.incomplete)
|
|
671
673
|
this.errors.push(`extensionWorld ${step.extensionWorld}: ${out.result.incomplete}`);
|
|
672
|
-
const probe = [
|
|
673
|
-
|
|
674
|
-
|
|
674
|
+
const probe = [
|
|
675
|
+
'redirectSecret',
|
|
676
|
+
'unparseable',
|
|
677
|
+
'errorPage',
|
|
678
|
+
'extensionWorld',
|
|
679
|
+
'frameRead',
|
|
680
|
+
'bfcacheRestore',
|
|
681
|
+
].find((key) => step[key]);
|
|
675
682
|
if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
|
|
676
683
|
if (step.client && !out.ok) this.errors.push(`client exit: ${out.error.message}`);
|
|
677
|
-
if (
|
|
684
|
+
if (
|
|
685
|
+
['errorPage', 'redirectSecret', 'frameRead', 'bfcacheRestore'].includes(probe) &&
|
|
686
|
+
out.ok &&
|
|
687
|
+
out.result?.incomplete
|
|
688
|
+
)
|
|
678
689
|
this.incomplete.push(`${probe}: ${out.result.incomplete}`);
|
|
679
690
|
if (step.as && !out.result?.pending) this.calls[step.as] = out;
|
|
680
691
|
if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
|
|
@@ -767,6 +778,36 @@ class Execution {
|
|
|
767
778
|
budget: this.bound(5000),
|
|
768
779
|
})
|
|
769
780
|
);
|
|
781
|
+
if (step.bfcacheRestore)
|
|
782
|
+
return outcome(
|
|
783
|
+
bfcacheRestore({
|
|
784
|
+
client: this.client,
|
|
785
|
+
reader: this.reader,
|
|
786
|
+
delayClient: this.clients.client,
|
|
787
|
+
ordinary: this.vars.ordinary,
|
|
788
|
+
protectedOrigin: step.origin,
|
|
789
|
+
tag: this.vars.tag,
|
|
790
|
+
mode: step.bfcacheRestore,
|
|
791
|
+
forwardAudit: supported(['forwardAudit'], this.options.capabilities),
|
|
792
|
+
side: this.side,
|
|
793
|
+
notes: this.notes,
|
|
794
|
+
budget: this.bound(5000),
|
|
795
|
+
})
|
|
796
|
+
);
|
|
797
|
+
if (step.frameRead)
|
|
798
|
+
return outcome(
|
|
799
|
+
frameRead({
|
|
800
|
+
client: this.client,
|
|
801
|
+
reader: this.reader,
|
|
802
|
+
top: step.from,
|
|
803
|
+
framed: this.vars.target,
|
|
804
|
+
tag: this.vars.tag,
|
|
805
|
+
mode: step.frameRead,
|
|
806
|
+
side: this.side,
|
|
807
|
+
notes: this.notes,
|
|
808
|
+
budget: this.bound(5000),
|
|
809
|
+
})
|
|
810
|
+
);
|
|
770
811
|
if (step.unparseable)
|
|
771
812
|
return outcome(
|
|
772
813
|
unparseableURLs({
|
|
@@ -1278,6 +1319,11 @@ class Execution {
|
|
|
1278
1319
|
errors: this.errors,
|
|
1279
1320
|
notes: this.notes,
|
|
1280
1321
|
incomplete: this.incomplete,
|
|
1322
|
+
evidence: Object.fromEntries(
|
|
1323
|
+
Object.entries(this.calls)
|
|
1324
|
+
.filter(([, out]) => out?.result?.delay && out.result.probes)
|
|
1325
|
+
.map(([name, out]) => [name, out.result])
|
|
1326
|
+
),
|
|
1281
1327
|
};
|
|
1282
1328
|
} finally {
|
|
1283
1329
|
this.stopped = true;
|
|
@@ -1420,6 +1466,7 @@ function variables(targets, target, side, slot, index) {
|
|
|
1420
1466
|
tag,
|
|
1421
1467
|
cookie: `canary-${tag}`,
|
|
1422
1468
|
};
|
|
1469
|
+
vars.protectedTarget = targets[target];
|
|
1423
1470
|
vars.target = side === 'control' ? vars.plain : targets[target];
|
|
1424
1471
|
vars.targetHost = new URL(vars.target).hostname;
|
|
1425
1472
|
vars.responseOrigin = vars.target === vars.ordinary ? '*' : vars.ordinary;
|
|
@@ -1501,6 +1548,14 @@ async function timedOut(stuck, base, made, work, opts) {
|
|
|
1501
1548
|
return failed;
|
|
1502
1549
|
}
|
|
1503
1550
|
|
|
1551
|
+
function probeEvidence(legs) {
|
|
1552
|
+
return [legs.control.evidence, legs.guarded.evidence].some(
|
|
1553
|
+
(evidence) => Object.keys(evidence ?? {}).length
|
|
1554
|
+
)
|
|
1555
|
+
? { evidence: { control: legs.control.evidence, guarded: legs.guarded.evidence } }
|
|
1556
|
+
: {};
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1504
1559
|
async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
1505
1560
|
const { target, variant } = job;
|
|
1506
1561
|
const calls = new Map();
|
|
@@ -1588,6 +1643,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
|
1588
1643
|
observations: { control: legs.control.observed, guarded: legs.guarded.observed },
|
|
1589
1644
|
errors: [...legs.control.errors, ...legs.guarded.errors],
|
|
1590
1645
|
designDependent: vector.designDependent ?? [],
|
|
1646
|
+
...probeEvidence(legs),
|
|
1591
1647
|
};
|
|
1592
1648
|
const notes = [
|
|
1593
1649
|
...(legs.control.notes ?? []).map((note) => `control ${note}`),
|
|
@@ -1604,6 +1660,11 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
|
|
|
1604
1660
|
}
|
|
1605
1661
|
|
|
1606
1662
|
function observerSkip(vector, opts, made, clients) {
|
|
1663
|
+
if (
|
|
1664
|
+
vector.requires?.includes('inboundDelay') &&
|
|
1665
|
+
typeof clients.client.inboundDelay !== 'function'
|
|
1666
|
+
)
|
|
1667
|
+
return 'not-supported:inboundDelay';
|
|
1607
1668
|
if (needsObserver(vector) && !clients.unguarded) return 'not-supported:unguarded-observer';
|
|
1608
1669
|
if (!exitsClient(vector)) return undefined;
|
|
1609
1670
|
if (!opts.clientFactory) return 'not-supported:clientExit';
|
|
@@ -1687,8 +1748,7 @@ function settleIncomplete(row, vector, base, legs) {
|
|
|
1687
1748
|
live: legs.control.live === true && [true, 'incomplete'].includes(legs.guarded.live),
|
|
1688
1749
|
});
|
|
1689
1750
|
for (const side of ['control', 'guarded'])
|
|
1690
|
-
if (legs[side].incomplete?.length &&
|
|
1691
|
-
row[side] = 'incomplete';
|
|
1751
|
+
if (legs[side].incomplete?.length && filled[side] === 'pass') row[side] = 'incomplete';
|
|
1692
1752
|
if (legs.guarded.live === 'incomplete' && filled.live === 'pass') row.live = 'incomplete';
|
|
1693
1753
|
row.why = [filled.why, `incomplete: ${incomplete.join('; ')}`].filter(Boolean).join('; ');
|
|
1694
1754
|
}
|