@ai-ecoverse/slicc-browsers 1.22.0 → 1.23.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 +4 -4
- package/close-protected-shape.mjs +563 -0
- package/fixture-server.mjs +52 -0
- package/guard-runner.mjs +653 -125
- package/guard-vectors.json +15040 -12929
- package/package.json +6 -4
- package/protected-worker.mjs +511 -0
- package/schema/guard-vectors.schema.json +95 -0
package/README.md
CHANGED
|
@@ -183,9 +183,9 @@ It also offers `storageKey(key)` and `unparseable(text)`. A Go or Rust implement
|
|
|
183
183
|
|
|
184
184
|
## Guard vectors
|
|
185
185
|
|
|
186
|
-
A guard sits between a CDP client
|
|
186
|
+
A guard sits between a CDP client and the browser and keeps the client away from protected origins. `guard-vectors.json` holds the attacks every guard must pass: navigation, redirects, popups, frames, interception, cookies, storage, privileged and error pages, foreign worlds, debugger tricks and session overrides.
|
|
187
187
|
|
|
188
|
-
Each vector runs twice. The **control** runs
|
|
188
|
+
Each vector runs twice. The **control** runs against an ordinary origin and must work. The **guarded** leg runs through the guard against a protected target and must be refused or have no effect. A **liveness** check outside the guard proves the target was really there. All three must pass.
|
|
189
189
|
|
|
190
190
|
A guard may refuse every client navigation to an `about:` URL that inherits an origin, such as `about:blank`. Refusing an ordinary navigation away from a protected page still fails.
|
|
191
191
|
|
|
@@ -195,7 +195,7 @@ For stock Playwright, Puppeteer and raw CDP clients, a guard that declares `stoc
|
|
|
195
195
|
- `Page.addScriptToEvaluateOnNewDocument` whose `source` matches `^(?:[ \t\n]|//[^\n\r\u2028\u2029]*)*$` on the whole string without the multiline flag (no code), with any `worldName` or `runImmediately`, and its removal;
|
|
196
196
|
- exactly `Emulation.setFocusEmulationEnabled`, `setEmulatedMedia`, `setDeviceMetricsOverride` and `setTouchEmulationEnabled`;
|
|
197
197
|
- `Page.createIsolatedWorld` on a frame the guard already classifies as ordinary: strip `grantUniveralAccess` and `contentSecurityPolicy`, return no `executionContextId` for any other frame; `Runtime.addBinding` scoped to such a world by a non-empty `executionContextName` or its `executionContextId`, with `Runtime.bindingCalled` only from those worlds;
|
|
198
|
-
- `Target.setAutoAttach`:
|
|
198
|
+
- `Target.setAutoAttach`: either narrow the filter to tab targets or hide per target; no protected-origin page, iframe or worker (dedicated, shared or service) may reach the client; pages under the tab are held like pages (same URL redaction in `targetInfo`); a transport that synthesizes it declares `syntheticAutoAttach`.
|
|
199
199
|
|
|
200
200
|
The strip holds whether or not a given Chrome lets that world reach another origin. Code init scripts, main-world bindings and other overrides stay refused or harmless. Admitted display overrides and no-code init scripts must not remain in effect on a protected document — refuse that navigation, or clear them before it runs. Undeclared, they skip, never pass.
|
|
201
201
|
|
|
@@ -239,7 +239,7 @@ You supply:
|
|
|
239
239
|
| `siteIsolationOff`, `inboundDelay`, `forwardAudit` | no site isolation; hold inbound; audit forwarded commands |
|
|
240
240
|
| `secureOrigin` | HTTPS origins (service workers) |
|
|
241
241
|
| `stockSetup`, `syntheticAutoAttach` | admit stock clients' setup calls; synthesize auto-attach |
|
|
242
|
-
| `closeProtected` | close a protected tab |
|
|
242
|
+
| `closeProtected` | close a protected tab, except live vault/seven-UI disguises |
|
|
243
243
|
|
|
244
244
|
### Reading the result
|
|
245
245
|
|
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { outcome, parseURL, waitFor } from './guard-driver.mjs';
|
|
2
|
+
import { createObserverTarget } from './observer-target.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
|
+
|
|
7
|
+
const peel = (text) => {
|
|
8
|
+
let u = parseURL(text);
|
|
9
|
+
for (
|
|
10
|
+
let i = 0;
|
|
11
|
+
u && ['blob', 'filesystem', 'view-source'].includes(u.protocol.slice(0, -1)) && i < 8;
|
|
12
|
+
i++
|
|
13
|
+
)
|
|
14
|
+
u = parseURL(u.href.slice(u.protocol.length));
|
|
15
|
+
return u;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const hostOf = (u) => {
|
|
19
|
+
const host = (u?.hostname ?? '').toLowerCase();
|
|
20
|
+
return host.endsWith('.') ? host.slice(0, -1) : host;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function keptDocument(text) {
|
|
24
|
+
const u = peel(text);
|
|
25
|
+
if (!u || !['http:', 'https:'].includes(u.protocol)) return false;
|
|
26
|
+
const host = hostOf(u);
|
|
27
|
+
if (host === 'vault.sliccy.ai' || (host.startsWith('vault-') && host.endsWith('.sliccy.ai')))
|
|
28
|
+
return true;
|
|
29
|
+
const path = u.pathname || '/';
|
|
30
|
+
return host === 'seven.sliccy.ai' && path.startsWith('/os/') && path !== '/os/callback.html';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function keptSecurityOrigin(url, origin) {
|
|
34
|
+
if (keptDocument(origin)) return true;
|
|
35
|
+
const u = parseURL(url);
|
|
36
|
+
const opaque =
|
|
37
|
+
u?.protocol === 'about:' && ['blank', 'srcdoc'].includes((u.pathname || '').toLowerCase());
|
|
38
|
+
if (!opaque) return false;
|
|
39
|
+
const o = peel(origin);
|
|
40
|
+
if (!o || !['http:', 'https:'].includes(o.protocol)) return false;
|
|
41
|
+
const host = hostOf(o);
|
|
42
|
+
return (
|
|
43
|
+
host === 'seven.sliccy.ai' ||
|
|
44
|
+
host === 'vault.sliccy.ai' ||
|
|
45
|
+
(host.startsWith('vault-') && host.endsWith('.sliccy.ai'))
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function unclosableShape({ url, documentURL, securityOrigin, navigating, navigationURL }) {
|
|
50
|
+
if (navigating && (!navigationURL || keptDocument(navigationURL))) return true;
|
|
51
|
+
const error =
|
|
52
|
+
documentURL === 'chrome-error://chromewebdata/' ||
|
|
53
|
+
(typeof documentURL === 'string' && documentURL.startsWith('chrome-error:'));
|
|
54
|
+
const keptNow =
|
|
55
|
+
keptDocument(url) ||
|
|
56
|
+
keptDocument(documentURL) ||
|
|
57
|
+
keptSecurityOrigin(documentURL ?? url, securityOrigin ?? '');
|
|
58
|
+
if (!keptNow) return false;
|
|
59
|
+
if (!error) return true;
|
|
60
|
+
return keptDocument(documentURL) || keptSecurityOrigin(documentURL, securityOrigin ?? '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function lockChecks({
|
|
64
|
+
client,
|
|
65
|
+
reader,
|
|
66
|
+
session,
|
|
67
|
+
targetId,
|
|
68
|
+
result,
|
|
69
|
+
until,
|
|
70
|
+
closeOnly,
|
|
71
|
+
skipEvaluate,
|
|
72
|
+
attachRefused,
|
|
73
|
+
}) {
|
|
74
|
+
if (!closeOnly) {
|
|
75
|
+
if (session && !skipEvaluate) {
|
|
76
|
+
result.evaluateRefused = refused(
|
|
77
|
+
await outcome(
|
|
78
|
+
client.send(
|
|
79
|
+
'Runtime.evaluate',
|
|
80
|
+
{ expression: 'document.title', returnByValue: true },
|
|
81
|
+
session
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
result.attachRefused = attachRefused;
|
|
87
|
+
}
|
|
88
|
+
await measureClose({ client, reader, targetId, result, until });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function measureClose({ client, reader, targetId, result, until }) {
|
|
92
|
+
const close = await outcome(client.send('Target.closeTarget', { targetId }));
|
|
93
|
+
const gone = await until(
|
|
94
|
+
async () =>
|
|
95
|
+
!(await reader.send('Target.getTargets')).targetInfos.some((i) => i.targetId === targetId)
|
|
96
|
+
);
|
|
97
|
+
result.closed = gone;
|
|
98
|
+
result.closeRefused = refused(close) && !gone;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function attachClient(client, targetId) {
|
|
102
|
+
const attached = await outcome(client.send('Target.attachToTarget', { targetId, flatten: true }));
|
|
103
|
+
const session = attached.result?.sessionId;
|
|
104
|
+
if (session) {
|
|
105
|
+
await outcome(client.send('Page.enable', {}, session));
|
|
106
|
+
await outcome(client.send('Runtime.enable', {}, session));
|
|
107
|
+
}
|
|
108
|
+
return { session, attachRefused: refused(attached) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function observerPage(reader, url, notes, internalSessions) {
|
|
112
|
+
const opened = await outcome(
|
|
113
|
+
createObserverTarget(reader, { url }, notes).then(async (created) => {
|
|
114
|
+
const { sessionId } = await reader.send('Target.attachToTarget', {
|
|
115
|
+
targetId: created.targetId,
|
|
116
|
+
flatten: true,
|
|
117
|
+
});
|
|
118
|
+
await reader.send('Page.enable', {}, sessionId);
|
|
119
|
+
await reader.send('Runtime.enable', {}, sessionId);
|
|
120
|
+
return { targetId: created.targetId, sessionId };
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
if (!opened.ok) return opened;
|
|
124
|
+
internalSessions.add(opened.result.sessionId);
|
|
125
|
+
return opened;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function noteShape(notes, label, evidence) {
|
|
129
|
+
notes.push(`${label}: ${JSON.stringify(evidence)}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function shapeViewSource({ reader, shapedURL, notes, internalSessions, until }) {
|
|
133
|
+
const opened = await observerPage(reader, 'about:blank', notes, internalSessions);
|
|
134
|
+
if (!opened.ok) return opened;
|
|
135
|
+
const { targetId, sessionId } = opened.result;
|
|
136
|
+
await reader.send('Page.navigate', { url: shapedURL }, sessionId);
|
|
137
|
+
let info;
|
|
138
|
+
const live = await until(async () => {
|
|
139
|
+
({ targetInfo: info } = await reader.send('Target.getTargetInfo', { targetId }));
|
|
140
|
+
return info.url === shapedURL || info.url.startsWith('view-source:');
|
|
141
|
+
});
|
|
142
|
+
noteShape(notes, 'view-source', {
|
|
143
|
+
targetId,
|
|
144
|
+
type: info?.type,
|
|
145
|
+
url: info?.url,
|
|
146
|
+
});
|
|
147
|
+
return { ok: true, result: { targetId, sessionId, live, shaped: live } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function shapeBlob({ reader, origin, tag, notes, internalSessions, until }) {
|
|
151
|
+
const page = `${origin}/blob-nav?n=${encodeURIComponent(tag)}`;
|
|
152
|
+
const opened = await observerPage(reader, page, notes, internalSessions);
|
|
153
|
+
if (!opened.ok) return opened;
|
|
154
|
+
const { targetId, sessionId } = opened.result;
|
|
155
|
+
let info;
|
|
156
|
+
const live = await until(async () => {
|
|
157
|
+
({ targetInfo: info } = await reader.send('Target.getTargetInfo', { targetId }));
|
|
158
|
+
return info.url.startsWith('blob:');
|
|
159
|
+
});
|
|
160
|
+
noteShape(notes, 'blob', { targetId, type: info?.type, url: info?.url });
|
|
161
|
+
return { ok: true, result: { targetId, sessionId, live, shaped: live } };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function shapeUserinfo({ reader, shapedURL, notes, internalSessions, until }) {
|
|
165
|
+
const opened = await observerPage(reader, 'about:blank', notes, internalSessions);
|
|
166
|
+
if (!opened.ok) return opened;
|
|
167
|
+
const { targetId, sessionId } = opened.result;
|
|
168
|
+
await reader.send('Page.navigate', { url: shapedURL }, sessionId);
|
|
169
|
+
let info;
|
|
170
|
+
let frame;
|
|
171
|
+
const live = await until(async () => {
|
|
172
|
+
({
|
|
173
|
+
frameTree: { frame },
|
|
174
|
+
} = await reader.send('Page.getFrameTree', {}, sessionId));
|
|
175
|
+
({ targetInfo: info } = await reader.send('Target.getTargetInfo', { targetId }));
|
|
176
|
+
return (
|
|
177
|
+
info.url.includes('@') &&
|
|
178
|
+
info.url.includes('/os/') &&
|
|
179
|
+
frame.url.includes('@') &&
|
|
180
|
+
!frame.unreachableUrl
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
noteShape(notes, 'userinfo', {
|
|
184
|
+
targetId,
|
|
185
|
+
type: info?.type,
|
|
186
|
+
url: info?.url,
|
|
187
|
+
frameUrl: frame?.url,
|
|
188
|
+
hasUserinfo: Boolean(info?.url?.includes('@')),
|
|
189
|
+
});
|
|
190
|
+
if (!live && info?.url?.includes('/os/') && !info.url.includes('@'))
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
result: { incomplete: 'userinfo stripped from top-level target URL' },
|
|
194
|
+
};
|
|
195
|
+
return { ok: true, result: { targetId, sessionId, live, shaped: live } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const raced = (promise, ms) => Promise.race([promise, pause(ms).then(() => undefined)]);
|
|
199
|
+
|
|
200
|
+
async function shapeNavigating({
|
|
201
|
+
reader,
|
|
202
|
+
ordinary,
|
|
203
|
+
destination,
|
|
204
|
+
notes,
|
|
205
|
+
internalSessions,
|
|
206
|
+
hold,
|
|
207
|
+
releaseSlow,
|
|
208
|
+
tag,
|
|
209
|
+
}) {
|
|
210
|
+
const started = Date.now();
|
|
211
|
+
const waitBudget = 8000;
|
|
212
|
+
const start = `${ordinary}/blank?n=close-nav-start`;
|
|
213
|
+
notes.push(`navigating.start: destination=${destination} budgetMs=${waitBudget}`);
|
|
214
|
+
notes.push('navigating.fetch: skipped; fixture /slow holds Document (no Fetch.enable)');
|
|
215
|
+
const opened = await raced(observerPage(reader, start, notes, internalSessions), 3000);
|
|
216
|
+
if (!opened?.ok)
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
result: {
|
|
220
|
+
incomplete: `navigating shape did not appear: observer setup ${opened?.error?.message ?? 'timed out'}`,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
const { targetId, sessionId } = opened.result;
|
|
224
|
+
hold.targetId = targetId;
|
|
225
|
+
const sequence = [];
|
|
226
|
+
let cursor = reader.events.length;
|
|
227
|
+
let stopped = false;
|
|
228
|
+
const elapsed = () => Date.now() - started;
|
|
229
|
+
const readInfo = async () => {
|
|
230
|
+
const info = await raced(reader.send('Target.getTargetInfo', { targetId }), 400);
|
|
231
|
+
const listed = await raced(reader.send('Target.getTargets'), 400);
|
|
232
|
+
const row = listed?.targetInfos?.find((i) => i.targetId === targetId);
|
|
233
|
+
return info?.targetInfo ?? row;
|
|
234
|
+
};
|
|
235
|
+
const snap = async (label) => {
|
|
236
|
+
const targetInfo = await readInfo();
|
|
237
|
+
notes.push(
|
|
238
|
+
`navigating.${label}: ${JSON.stringify({
|
|
239
|
+
atMs: elapsed(),
|
|
240
|
+
targetId,
|
|
241
|
+
type: targetInfo?.type,
|
|
242
|
+
url: targetInfo?.url,
|
|
243
|
+
title: targetInfo?.title,
|
|
244
|
+
attached: targetInfo?.attached,
|
|
245
|
+
openerId: targetInfo?.openerId,
|
|
246
|
+
pendingUrl: destination,
|
|
247
|
+
observerSession: sessionId,
|
|
248
|
+
sequence: sequence.slice(-12),
|
|
249
|
+
})}`
|
|
250
|
+
);
|
|
251
|
+
return targetInfo;
|
|
252
|
+
};
|
|
253
|
+
hold.release = async () => {
|
|
254
|
+
stopped = true;
|
|
255
|
+
releaseSlow?.(tag);
|
|
256
|
+
await pause(20);
|
|
257
|
+
};
|
|
258
|
+
hold.abort = hold.release;
|
|
259
|
+
await snap('0s');
|
|
260
|
+
const pump = (async () => {
|
|
261
|
+
while (!stopped) {
|
|
262
|
+
for (const event of reader.events.slice(cursor)) {
|
|
263
|
+
cursor++;
|
|
264
|
+
if (
|
|
265
|
+
event.method === 'Fetch.requestPaused' ||
|
|
266
|
+
event.method === 'Page.frameStartedNavigating' ||
|
|
267
|
+
event.method === 'Page.frameRequestedNavigation' ||
|
|
268
|
+
event.method === 'Page.frameNavigated'
|
|
269
|
+
) {
|
|
270
|
+
const row = {
|
|
271
|
+
atMs: elapsed(),
|
|
272
|
+
method: event.method,
|
|
273
|
+
sessionId: event.sessionId,
|
|
274
|
+
url: event.params.request?.url ?? event.params.url ?? event.params.frame?.url,
|
|
275
|
+
resourceType: event.params.resourceType,
|
|
276
|
+
requestStage: event.params.requestStage,
|
|
277
|
+
frameId: event.params.frameId ?? event.params.frame?.id,
|
|
278
|
+
};
|
|
279
|
+
sequence.push(row);
|
|
280
|
+
notes.push(`navigating.event: ${JSON.stringify(row)}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
await pause(5);
|
|
284
|
+
}
|
|
285
|
+
})();
|
|
286
|
+
await raced(reader.send('Page.enable', {}, sessionId), 500);
|
|
287
|
+
notes.push(`navigating.navigate: not-awaited url=${destination} session=${sessionId}`);
|
|
288
|
+
reader
|
|
289
|
+
.send('Page.navigate', { url: destination }, sessionId)
|
|
290
|
+
.then((result) => {
|
|
291
|
+
notes.push(
|
|
292
|
+
`navigating.navigateResult: ${JSON.stringify({
|
|
293
|
+
atMs: elapsed(),
|
|
294
|
+
frameId: result?.frameId,
|
|
295
|
+
loaderId: result?.loaderId,
|
|
296
|
+
errorText: result?.errorText,
|
|
297
|
+
})}`
|
|
298
|
+
);
|
|
299
|
+
})
|
|
300
|
+
.catch((error) => {
|
|
301
|
+
notes.push(`navigating.navigateSettled: ${error?.message ?? error}`);
|
|
302
|
+
});
|
|
303
|
+
const marks = [1000, 2000, 5000];
|
|
304
|
+
let mark = 0;
|
|
305
|
+
let targetInfo;
|
|
306
|
+
let pending = false;
|
|
307
|
+
const deadline = started + waitBudget;
|
|
308
|
+
while (Date.now() < deadline && !pending) {
|
|
309
|
+
pending = sequence.some(
|
|
310
|
+
(event) =>
|
|
311
|
+
(event.method === 'Page.frameStartedNavigating' ||
|
|
312
|
+
event.method === 'Page.frameRequestedNavigation' ||
|
|
313
|
+
event.method === 'Fetch.requestPaused') &&
|
|
314
|
+
(event.url === destination || String(event.url ?? '').includes('/slow'))
|
|
315
|
+
);
|
|
316
|
+
if (!pending) {
|
|
317
|
+
targetInfo = await readInfo();
|
|
318
|
+
pending = targetInfo?.url === destination || String(targetInfo?.url ?? '').includes('/slow');
|
|
319
|
+
}
|
|
320
|
+
while (mark < marks.length && elapsed() >= marks[mark]) {
|
|
321
|
+
await snap(`${marks[mark] / 1000}s`);
|
|
322
|
+
mark++;
|
|
323
|
+
}
|
|
324
|
+
if (!pending) await pause(25);
|
|
325
|
+
}
|
|
326
|
+
stopped = true;
|
|
327
|
+
await Promise.race([pump, pause(20)]);
|
|
328
|
+
targetInfo = targetInfo ?? (await readInfo());
|
|
329
|
+
const shaped = pending;
|
|
330
|
+
notes.push(
|
|
331
|
+
`navigating.done: ${JSON.stringify({
|
|
332
|
+
atMs: elapsed(),
|
|
333
|
+
shaped,
|
|
334
|
+
url: targetInfo?.url,
|
|
335
|
+
title: targetInfo?.title,
|
|
336
|
+
attached: targetInfo?.attached,
|
|
337
|
+
pendingUrl: destination,
|
|
338
|
+
sequence: sequence.slice(-20),
|
|
339
|
+
})}`
|
|
340
|
+
);
|
|
341
|
+
if (!shaped) {
|
|
342
|
+
notes.push(`navigating.giveUp: no pending /slow within ${waitBudget}ms`);
|
|
343
|
+
return {
|
|
344
|
+
ok: true,
|
|
345
|
+
result: {
|
|
346
|
+
incomplete: `navigating shape did not appear: no pending /slow within ${waitBudget}ms`,
|
|
347
|
+
targetId,
|
|
348
|
+
sessionId,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
result: {
|
|
355
|
+
targetId,
|
|
356
|
+
sessionId,
|
|
357
|
+
live: true,
|
|
358
|
+
shaped: true,
|
|
359
|
+
navigating: true,
|
|
360
|
+
navigationURL: destination,
|
|
361
|
+
closeOnly: true,
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function shapeBlankInherited({ reader, origin, tag, notes, internalSessions, until }) {
|
|
367
|
+
const page = `${origin}/blank-open?n=${encodeURIComponent(tag)}`;
|
|
368
|
+
const opened = await observerPage(reader, page, notes, internalSessions);
|
|
369
|
+
if (!opened.ok) return opened;
|
|
370
|
+
const parent = opened.result;
|
|
371
|
+
const before = new Set(
|
|
372
|
+
(await reader.send('Target.getTargets')).targetInfos.map((info) => info.targetId)
|
|
373
|
+
);
|
|
374
|
+
const openedBlank = await outcome(
|
|
375
|
+
reader.send(
|
|
376
|
+
'Runtime.evaluate',
|
|
377
|
+
{ expression: "window.open(''); undefined", returnByValue: true, userGesture: true },
|
|
378
|
+
parent.sessionId
|
|
379
|
+
)
|
|
380
|
+
);
|
|
381
|
+
if (!openedBlank.ok)
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
result: {
|
|
385
|
+
incomplete: `window.open failed: ${openedBlank.error?.message ?? openedBlank.error}`,
|
|
386
|
+
parentId: parent.targetId,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
let child;
|
|
390
|
+
const found = await until(async () => {
|
|
391
|
+
const listed = await reader.send('Target.getTargets');
|
|
392
|
+
child = listed.targetInfos.find(
|
|
393
|
+
(info) =>
|
|
394
|
+
!before.has(info.targetId) &&
|
|
395
|
+
info.type === 'page' &&
|
|
396
|
+
(info.url === 'about:blank' || info.url === '') &&
|
|
397
|
+
info.openerId === parent.targetId
|
|
398
|
+
);
|
|
399
|
+
return Boolean(child);
|
|
400
|
+
});
|
|
401
|
+
if (!found) {
|
|
402
|
+
noteShape(notes, 'blank-inherited', {
|
|
403
|
+
parentId: parent.targetId,
|
|
404
|
+
popup: null,
|
|
405
|
+
reason: 'no about:blank page target with opener',
|
|
406
|
+
});
|
|
407
|
+
return {
|
|
408
|
+
ok: true,
|
|
409
|
+
result: {
|
|
410
|
+
incomplete: 'about:blank popup with opener was not created',
|
|
411
|
+
parentId: parent.targetId,
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const { sessionId } = await reader.send('Target.attachToTarget', {
|
|
416
|
+
targetId: child.targetId,
|
|
417
|
+
flatten: true,
|
|
418
|
+
});
|
|
419
|
+
internalSessions.add(sessionId);
|
|
420
|
+
await reader.send('Page.enable', {}, sessionId);
|
|
421
|
+
let securityOrigin;
|
|
422
|
+
try {
|
|
423
|
+
const { frameTree } = await reader.send('Page.getFrameTree', {}, sessionId);
|
|
424
|
+
securityOrigin = frameTree.frame.securityOrigin;
|
|
425
|
+
} catch {
|
|
426
|
+
securityOrigin = undefined;
|
|
427
|
+
}
|
|
428
|
+
noteShape(notes, 'blank-inherited', {
|
|
429
|
+
targetId: child.targetId,
|
|
430
|
+
type: child.type,
|
|
431
|
+
url: child.url,
|
|
432
|
+
openerId: child.openerId,
|
|
433
|
+
parentId: parent.targetId,
|
|
434
|
+
securityOrigin,
|
|
435
|
+
});
|
|
436
|
+
return {
|
|
437
|
+
ok: true,
|
|
438
|
+
result: {
|
|
439
|
+
targetId: child.targetId,
|
|
440
|
+
sessionId,
|
|
441
|
+
parentId: parent.targetId,
|
|
442
|
+
live: true,
|
|
443
|
+
shaped: true,
|
|
444
|
+
securityOrigin,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function closeProtectedShape({
|
|
450
|
+
client,
|
|
451
|
+
reader,
|
|
452
|
+
ordinary,
|
|
453
|
+
protectedOrigin,
|
|
454
|
+
shape,
|
|
455
|
+
tag,
|
|
456
|
+
internalSessions = new Set(),
|
|
457
|
+
notes = [],
|
|
458
|
+
budget = 5000,
|
|
459
|
+
releaseSlow,
|
|
460
|
+
}) {
|
|
461
|
+
const until = (check) => waitFor(check, budget);
|
|
462
|
+
const result = {};
|
|
463
|
+
const hold = {};
|
|
464
|
+
let shaped;
|
|
465
|
+
const missing = async (reason) => {
|
|
466
|
+
hold.abort?.();
|
|
467
|
+
await hold.release?.();
|
|
468
|
+
const targetId = shaped?.result?.targetId ?? hold.targetId;
|
|
469
|
+
const parentId = shaped?.result?.parentId ?? hold.parentId;
|
|
470
|
+
if (targetId) await outcome(reader.send('Target.closeTarget', { targetId }));
|
|
471
|
+
if (parentId) await outcome(reader.send('Target.closeTarget', { targetId: parentId }));
|
|
472
|
+
return { ...result, incomplete: `close-protected-shape: ${reason}` };
|
|
473
|
+
};
|
|
474
|
+
if (!reader) return missing('missing unguarded reader');
|
|
475
|
+
const origin = protectedOrigin;
|
|
476
|
+
if (shape === 'view-source') {
|
|
477
|
+
shaped = await shapeViewSource({
|
|
478
|
+
reader,
|
|
479
|
+
shapedURL: `view-source:${origin}/doc?n=${encodeURIComponent(tag)}`,
|
|
480
|
+
notes,
|
|
481
|
+
internalSessions,
|
|
482
|
+
until,
|
|
483
|
+
});
|
|
484
|
+
} else if (shape === 'blob') {
|
|
485
|
+
shaped = await shapeBlob({ reader, origin, tag, notes, internalSessions, until });
|
|
486
|
+
} else if (shape === 'userinfo') {
|
|
487
|
+
const host = parseURL(origin)?.host;
|
|
488
|
+
const shapedURL = `${parseURL(origin)?.protocol}//user:pass@${host}/os/index.html?n=${encodeURIComponent(tag)}`;
|
|
489
|
+
shaped = await shapeUserinfo({ reader, shapedURL, notes, internalSessions, until });
|
|
490
|
+
} else if (shape === 'navigating') {
|
|
491
|
+
const navigating = shapeNavigating({
|
|
492
|
+
reader,
|
|
493
|
+
ordinary,
|
|
494
|
+
destination: `${origin}/slow?n=${encodeURIComponent(tag)}&ms=15000`,
|
|
495
|
+
notes,
|
|
496
|
+
internalSessions,
|
|
497
|
+
hold,
|
|
498
|
+
releaseSlow,
|
|
499
|
+
tag,
|
|
500
|
+
}).then((value) => ({ timedOut: false, value }));
|
|
501
|
+
let hardTimer;
|
|
502
|
+
const hard = new Promise((resolve) => {
|
|
503
|
+
hardTimer = setTimeout(() => resolve({ timedOut: true }), 9000);
|
|
504
|
+
});
|
|
505
|
+
const winner = await Promise.race([navigating, hard]);
|
|
506
|
+
clearTimeout(hardTimer);
|
|
507
|
+
if (winner.timedOut) {
|
|
508
|
+
notes.push('navigating.giveUp: hard 9s shape budget');
|
|
509
|
+
hold.abort?.();
|
|
510
|
+
await hold.release?.();
|
|
511
|
+
shaped = {
|
|
512
|
+
ok: true,
|
|
513
|
+
result: {
|
|
514
|
+
incomplete: 'navigating shape did not appear: hard 9s shape budget',
|
|
515
|
+
targetId: hold.targetId,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
} else shaped = winner.value;
|
|
519
|
+
} else if (shape === 'blank-inherited') {
|
|
520
|
+
shaped = await shapeBlankInherited({ reader, origin, tag, notes, internalSessions, until });
|
|
521
|
+
} else {
|
|
522
|
+
return await missing(`unknown shape ${shape}`);
|
|
523
|
+
}
|
|
524
|
+
if (!shaped.ok)
|
|
525
|
+
return await missing(`observer setup failed: ${shaped.error?.message ?? shaped.error}`);
|
|
526
|
+
if (shaped.result.incomplete) return await missing(shaped.result.incomplete);
|
|
527
|
+
if (!shaped.result.live) return await missing(`shape ${shape} did not appear`);
|
|
528
|
+
Object.assign(result, {
|
|
529
|
+
shaped: shaped.result.shaped,
|
|
530
|
+
live: shaped.result.live,
|
|
531
|
+
});
|
|
532
|
+
const { targetId } = shaped.result;
|
|
533
|
+
const closeOnly = shaped.result.closeOnly === true;
|
|
534
|
+
const { session, attachRefused } = await attachClient(client, targetId);
|
|
535
|
+
if (!session) {
|
|
536
|
+
if (!closeOnly) result.attachRefused = attachRefused;
|
|
537
|
+
await measureClose({ client, reader, targetId, result, until });
|
|
538
|
+
await hold.release?.();
|
|
539
|
+
await outcome(reader.send('Target.closeTarget', { targetId }));
|
|
540
|
+
if (shaped.result.parentId)
|
|
541
|
+
await outcome(reader.send('Target.closeTarget', { targetId: shaped.result.parentId }));
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
await lockChecks({
|
|
546
|
+
client,
|
|
547
|
+
reader,
|
|
548
|
+
session,
|
|
549
|
+
targetId,
|
|
550
|
+
result,
|
|
551
|
+
until,
|
|
552
|
+
closeOnly,
|
|
553
|
+
skipEvaluate: true,
|
|
554
|
+
attachRefused,
|
|
555
|
+
});
|
|
556
|
+
return result;
|
|
557
|
+
} finally {
|
|
558
|
+
await hold.release?.();
|
|
559
|
+
await outcome(reader.send('Target.closeTarget', { targetId }));
|
|
560
|
+
if (shaped.result.parentId)
|
|
561
|
+
await outcome(reader.send('Target.closeTarget', { targetId: shaped.result.parentId }));
|
|
562
|
+
}
|
|
563
|
+
}
|
package/fixture-server.mjs
CHANGED
|
@@ -76,6 +76,16 @@ function route(req, url, send, html, redirects) {
|
|
|
76
76
|
{ sourcemap: '/listener.js.map' }
|
|
77
77
|
),
|
|
78
78
|
'/page': () => html('trigger', trigger(p)),
|
|
79
|
+
'/blob-nav': () =>
|
|
80
|
+
html(
|
|
81
|
+
'blob-nav',
|
|
82
|
+
`<p>blob-nav ${escape(p.get('n') ?? '')}</p><script>const b=URL.createObjectURL(new Blob(['<!doctype html><title>blob</title><p>blob ${escape(p.get('n') ?? '')}</p>'],{type:'text/html'}));location.replace(b);</script>`
|
|
83
|
+
),
|
|
84
|
+
'/blank': () => html('blank', `<p>blank ${escape(p.get('n') ?? '')}</p>`),
|
|
85
|
+
'/blank-open': () => html('blank-open', `<p>blank-open ${escape(p.get('n') ?? '')}</p>`),
|
|
86
|
+
'/os/': () => html('os', `<p>os ${escape(p.get('n') ?? '')}</p>`),
|
|
87
|
+
'/os/index.html': () => html('os', `<p>os ${escape(p.get('n') ?? '')}</p>`),
|
|
88
|
+
'/os/callback.html': () => html('os-callback', `<p>callback ${escape(p.get('n') ?? '')}</p>`),
|
|
79
89
|
'/api/cookies': () => send(200, req.headers.cookie ?? 'none', 'text/plain'),
|
|
80
90
|
'/plant': () =>
|
|
81
91
|
html('plant', '<script>localStorage.setItem("canary","planted")</script>', {
|
|
@@ -105,6 +115,13 @@ function route(req, url, send, html, redirects) {
|
|
|
105
115
|
'/csp': () => document(url, html),
|
|
106
116
|
'/sw.js': () =>
|
|
107
117
|
send(200, "self.addEventListener('install',()=>self.skipWaiting())", 'text/javascript'),
|
|
118
|
+
'/worker.js': () => send(200, 'onmessage=e=>postMessage(e.data)', 'text/javascript'),
|
|
119
|
+
'/shared-worker.js': () =>
|
|
120
|
+
send(
|
|
121
|
+
200,
|
|
122
|
+
'onconnect=e=>{e.ports[0].onmessage=m=>e.ports[0].postMessage(m.data)}',
|
|
123
|
+
'text/javascript'
|
|
124
|
+
),
|
|
108
125
|
};
|
|
109
126
|
if (req.method === 'OPTIONS') return send(204, '');
|
|
110
127
|
if (url.pathname.startsWith('/auth')) {
|
|
@@ -140,10 +157,31 @@ function requestURL(req, scheme) {
|
|
|
140
157
|
};
|
|
141
158
|
}
|
|
142
159
|
|
|
160
|
+
async function holdSlow({ req, res, url, p, html, entries, history, slows }) {
|
|
161
|
+
const n = p.get('n') ?? '';
|
|
162
|
+
const ms = Math.min(Math.max(Number(p.get('ms') ?? 15000) || 15000, 1), 60000);
|
|
163
|
+
const entry = {
|
|
164
|
+
time: Date.now(),
|
|
165
|
+
url: url.href,
|
|
166
|
+
method: req.method,
|
|
167
|
+
headers: { ...req.headers },
|
|
168
|
+
};
|
|
169
|
+
entries.push(entry);
|
|
170
|
+
history.push(entry);
|
|
171
|
+
await new Promise((resolve) => {
|
|
172
|
+
const timer = setTimeout(resolve, ms);
|
|
173
|
+
slows.set(n, { resolve, timer });
|
|
174
|
+
});
|
|
175
|
+
slows.delete(n);
|
|
176
|
+
if (res.writableEnded) return;
|
|
177
|
+
return html('slow', `<p>slow ${escape(n)}</p>`);
|
|
178
|
+
}
|
|
179
|
+
|
|
143
180
|
export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
|
|
144
181
|
let entries = [];
|
|
145
182
|
const history = [];
|
|
146
183
|
const redirects = new Map();
|
|
184
|
+
const slows = new Map();
|
|
147
185
|
const redirectSecret = async ({ path, host = '', search = '', remove, ...value }) => {
|
|
148
186
|
if (
|
|
149
187
|
typeof path !== 'string' ||
|
|
@@ -155,6 +193,14 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
|
|
|
155
193
|
if (remove) redirects.delete(key);
|
|
156
194
|
else redirects.set(key, value);
|
|
157
195
|
};
|
|
196
|
+
const releaseSlow = (n) => {
|
|
197
|
+
const entry = slows.get(String(n ?? ''));
|
|
198
|
+
if (!entry) return false;
|
|
199
|
+
clearTimeout(entry.timer);
|
|
200
|
+
entry.resolve();
|
|
201
|
+
slows.delete(String(n ?? ''));
|
|
202
|
+
return true;
|
|
203
|
+
};
|
|
158
204
|
const scheme = tls ? 'https' : 'http';
|
|
159
205
|
const handler = async (req, res) => {
|
|
160
206
|
const url = requestURL(req, scheme);
|
|
@@ -176,6 +222,10 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
|
|
|
176
222
|
'text/html',
|
|
177
223
|
headers
|
|
178
224
|
);
|
|
225
|
+
if (req.method === 'GET' && url.pathname === '/slow') {
|
|
226
|
+
await holdSlow({ req, res, url, p, html, entries, history, slows });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
179
229
|
if (req.method === 'GET' && url.pathname === '/log') {
|
|
180
230
|
const from = Number(p.get('since') ?? 0);
|
|
181
231
|
if (!Number.isInteger(from) || from < 0 || from > history.length)
|
|
@@ -213,6 +263,7 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
|
|
|
213
263
|
return {
|
|
214
264
|
redirects,
|
|
215
265
|
redirectSecret,
|
|
266
|
+
releaseSlow,
|
|
216
267
|
port: address.port,
|
|
217
268
|
origin: `${scheme}://${host}:${address.port}`,
|
|
218
269
|
log: () => structuredClone(entries),
|
|
@@ -221,6 +272,7 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
|
|
|
221
272
|
},
|
|
222
273
|
close: () =>
|
|
223
274
|
new Promise((resolve, reject) => {
|
|
275
|
+
for (const n of [...slows.keys()]) releaseSlow(n);
|
|
224
276
|
server.closeAllConnections();
|
|
225
277
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
226
278
|
}),
|