@botiverse/porthole 0.1.5 → 0.1.6
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/dist/conformance/served.js +61 -12
- package/package.json +1 -1
- package/src/conformance/served.ts +75 -23
|
@@ -250,27 +250,76 @@ export function servedTests(host, options = {}) {
|
|
|
250
250
|
expect(shown.origin).not.toBe(location.origin);
|
|
251
251
|
}
|
|
252
252
|
});
|
|
253
|
-
define("served.domain.egress", "the CSP in the document's headers closes egress", async ({ page }) => {
|
|
253
|
+
define("served.domain.egress", "the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it", async ({ page }) => {
|
|
254
254
|
const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
|
|
255
255
|
const frame = await display.frame();
|
|
256
|
-
const
|
|
256
|
+
const kinds = ["fetch", "img", "media", "script", "style", "frame"];
|
|
257
|
+
const urls = Object.fromEntries(await Promise.all(kinds.map(async (kind) => [kind, await host.canary(`egress-${kind}`)])));
|
|
258
|
+
// Each kind tried once; the policy that applied is read from the violations it raises.
|
|
259
|
+
const seen = await frame.evaluate(async (each) => {
|
|
260
|
+
const policies = [];
|
|
261
|
+
document.addEventListener("securitypolicyviolation", (event) => policies.push(event.originalPolicy));
|
|
262
|
+
let fetched = "sent";
|
|
257
263
|
try {
|
|
258
|
-
await fetch(
|
|
259
|
-
return "sent";
|
|
264
|
+
await fetch(each.fetch, { mode: "no-cors" });
|
|
260
265
|
}
|
|
261
266
|
catch {
|
|
262
|
-
|
|
267
|
+
fetched = "blocked";
|
|
263
268
|
}
|
|
264
|
-
}, await host.canary("egress-fetch"));
|
|
265
|
-
await frame.evaluate((url) => {
|
|
266
269
|
const image = new Image();
|
|
267
|
-
image.src =
|
|
270
|
+
image.src = each.img;
|
|
268
271
|
document.body.append(image);
|
|
269
|
-
|
|
272
|
+
const audio = document.createElement("audio");
|
|
273
|
+
audio.preload = "auto";
|
|
274
|
+
audio.src = each.media;
|
|
275
|
+
document.body.append(audio);
|
|
276
|
+
const script = document.createElement("script");
|
|
277
|
+
script.src = each.script;
|
|
278
|
+
document.body.append(script);
|
|
279
|
+
const link = document.createElement("link");
|
|
280
|
+
link.rel = "stylesheet";
|
|
281
|
+
link.href = each.style;
|
|
282
|
+
document.head.append(link);
|
|
283
|
+
const nested = document.createElement("iframe");
|
|
284
|
+
nested.src = each.frame;
|
|
285
|
+
document.body.append(nested);
|
|
286
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
287
|
+
return { fetched, policy: policies.find((policy) => /connect-src/u.test(policy)) ?? "" };
|
|
288
|
+
}, urls);
|
|
270
289
|
await page.waitForTimeout(SETTLE_MS);
|
|
271
|
-
expect(fetched).toBe("blocked");
|
|
272
|
-
expect(
|
|
273
|
-
|
|
290
|
+
expect(seen.fetched).toBe("blocked");
|
|
291
|
+
expect(seen.policy, "a CSP applied to the document").not.toBe("");
|
|
292
|
+
const sources = new Map(seen.policy
|
|
293
|
+
.split(";")
|
|
294
|
+
.map((directive) => directive.trim().split(/\s+/u))
|
|
295
|
+
.map(([name = "", ...rest]) => [name, rest]));
|
|
296
|
+
// What runs or talks never opens to any site (P059), whatever the config.
|
|
297
|
+
for (const name of ["script-src", "style-src", "connect-src", "frame-src", "default-src"]) {
|
|
298
|
+
expect(sources.get(name) ?? [], `${name} opens no scheme`).not.toContain("https:");
|
|
299
|
+
}
|
|
300
|
+
for (const kind of ["fetch", "script", "style", "frame"]) {
|
|
301
|
+
expect(await host.canaryHits(`egress-${kind}`), kind).toBe(0);
|
|
302
|
+
}
|
|
303
|
+
// Images and media: closed, unless the config opened them to any https site (P059);
|
|
304
|
+
// then, the canary being an https site, an image reaches it, as the config means.
|
|
305
|
+
const https = (kind) => (sources.get(`${kind}-src`) ?? []).includes("https:") &&
|
|
306
|
+
new URL(urls[kind]).protocol === "https:";
|
|
307
|
+
const opened = ["img", "media"].filter(https);
|
|
308
|
+
if (opened.length > 0) {
|
|
309
|
+
info?.annotations.push({
|
|
310
|
+
type: "expected",
|
|
311
|
+
description: `${opened.join(" and ")} load from any https site under this config (anyHttps, P059): ${opened.includes("img") ? "the image is asserted to reach the outside site" : "measured, not asserted"}; script, style, connect and frames still can't`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (https("img")) {
|
|
315
|
+
expect(await host.canaryHits("egress-img"), "the image, as the config opens it").toBeGreaterThan(0);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
expect(await host.canaryHits("egress-img"), "img").toBe(0);
|
|
319
|
+
}
|
|
320
|
+
if (!https("media")) {
|
|
321
|
+
expect(await host.canaryHits("egress-media"), "media").toBe(0);
|
|
322
|
+
}
|
|
274
323
|
});
|
|
275
324
|
const resourceReady = (html) => ({
|
|
276
325
|
jsonrpc: "2.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botiverse/porthole",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "A host-side sandbox for agent-made HTML: isolation, egress hardening, a themed shell, gated host functions, and the agent manual, from one config.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"files": [
|
|
@@ -380,33 +380,85 @@ export function servedTests(
|
|
|
380
380
|
|
|
381
381
|
define(
|
|
382
382
|
"served.domain.egress",
|
|
383
|
-
"the CSP in the document's headers closes egress",
|
|
383
|
+
"the CSP in the document's headers closes egress; only what the config opens to any https site (images, media) reaches it",
|
|
384
384
|
async ({ page }) => {
|
|
385
385
|
const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
|
|
386
386
|
const frame = await display.frame();
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
387
|
+
const kinds = ["fetch", "img", "media", "script", "style", "frame"] as const;
|
|
388
|
+
const urls = Object.fromEntries(
|
|
389
|
+
await Promise.all(kinds.map(async (kind) => [kind, await host.canary(`egress-${kind}`)])),
|
|
390
|
+
) as Record<(typeof kinds)[number], string>;
|
|
391
|
+
// Each kind tried once; the policy that applied is read from the violations it raises.
|
|
392
|
+
const seen = await frame.evaluate(async (each) => {
|
|
393
|
+
const policies: string[] = [];
|
|
394
|
+
document.addEventListener("securitypolicyviolation", (event) =>
|
|
395
|
+
policies.push(event.originalPolicy),
|
|
396
|
+
);
|
|
397
|
+
let fetched = "sent";
|
|
398
|
+
try {
|
|
399
|
+
await fetch(each.fetch, { mode: "no-cors" });
|
|
400
|
+
} catch {
|
|
401
|
+
fetched = "blocked";
|
|
402
|
+
}
|
|
403
|
+
const image = new Image();
|
|
404
|
+
image.src = each.img;
|
|
405
|
+
document.body.append(image);
|
|
406
|
+
const audio = document.createElement("audio");
|
|
407
|
+
audio.preload = "auto";
|
|
408
|
+
audio.src = each.media;
|
|
409
|
+
document.body.append(audio);
|
|
410
|
+
const script = document.createElement("script");
|
|
411
|
+
script.src = each.script;
|
|
412
|
+
document.body.append(script);
|
|
413
|
+
const link = document.createElement("link");
|
|
414
|
+
link.rel = "stylesheet";
|
|
415
|
+
link.href = each.style;
|
|
416
|
+
document.head.append(link);
|
|
417
|
+
const nested = document.createElement("iframe");
|
|
418
|
+
nested.src = each.frame;
|
|
419
|
+
document.body.append(nested);
|
|
420
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
421
|
+
return { fetched, policy: policies.find((policy) => /connect-src/u.test(policy)) ?? "" };
|
|
422
|
+
}, urls);
|
|
406
423
|
await page.waitForTimeout(SETTLE_MS);
|
|
407
|
-
expect(fetched).toBe("blocked");
|
|
408
|
-
expect(
|
|
409
|
-
|
|
424
|
+
expect(seen.fetched).toBe("blocked");
|
|
425
|
+
expect(seen.policy, "a CSP applied to the document").not.toBe("");
|
|
426
|
+
const sources = new Map(
|
|
427
|
+
seen.policy
|
|
428
|
+
.split(";")
|
|
429
|
+
.map((directive) => directive.trim().split(/\s+/u))
|
|
430
|
+
.map(([name = "", ...rest]) => [name, rest] as const),
|
|
431
|
+
);
|
|
432
|
+
// What runs or talks never opens to any site (P059), whatever the config.
|
|
433
|
+
for (const name of ["script-src", "style-src", "connect-src", "frame-src", "default-src"]) {
|
|
434
|
+
expect(sources.get(name) ?? [], `${name} opens no scheme`).not.toContain("https:");
|
|
435
|
+
}
|
|
436
|
+
for (const kind of ["fetch", "script", "style", "frame"] as const) {
|
|
437
|
+
expect(await host.canaryHits(`egress-${kind}`), kind).toBe(0);
|
|
438
|
+
}
|
|
439
|
+
// Images and media: closed, unless the config opened them to any https site (P059);
|
|
440
|
+
// then, the canary being an https site, an image reaches it, as the config means.
|
|
441
|
+
const https = (kind: "img" | "media") =>
|
|
442
|
+
(sources.get(`${kind}-src`) ?? []).includes("https:") &&
|
|
443
|
+
new URL(urls[kind]).protocol === "https:";
|
|
444
|
+
const opened = (["img", "media"] as const).filter(https);
|
|
445
|
+
if (opened.length > 0) {
|
|
446
|
+
info?.annotations.push({
|
|
447
|
+
type: "expected",
|
|
448
|
+
description: `${opened.join(" and ")} load from any https site under this config (anyHttps, P059): ${opened.includes("img") ? "the image is asserted to reach the outside site" : "measured, not asserted"}; script, style, connect and frames still can't`,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (https("img")) {
|
|
452
|
+
expect(
|
|
453
|
+
await host.canaryHits("egress-img"),
|
|
454
|
+
"the image, as the config opens it",
|
|
455
|
+
).toBeGreaterThan(0);
|
|
456
|
+
} else {
|
|
457
|
+
expect(await host.canaryHits("egress-img"), "img").toBe(0);
|
|
458
|
+
}
|
|
459
|
+
if (!https("media")) {
|
|
460
|
+
expect(await host.canaryHits("egress-media"), "media").toBe(0);
|
|
461
|
+
}
|
|
410
462
|
},
|
|
411
463
|
);
|
|
412
464
|
|