@botiverse/porthole 0.1.3 → 0.1.5
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/compile/policy.d.ts +10 -2
- package/dist/compile/policy.js +18 -4
- package/dist/config/schema.d.ts +20 -0
- package/dist/config/schema.js +2 -0
- package/dist/config/validate.js +174 -2
- package/dist/conformance/behavior.js +14 -1
- package/dist/conformance/kit.d.ts +5 -0
- package/dist/conformance/kit.js +11 -0
- package/dist/conformance/probes.js +19 -2
- package/dist/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.d.ts +2 -0
- package/dist/guide/guide.js +22 -5
- package/dist/host/create-host.js +24 -7
- package/dist/host/types.d.ts +44 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +5 -0
- package/dist/react/frame.d.ts +34 -4
- package/dist/react/frame.js +91 -22
- package/dist/server/naming.d.ts +6 -0
- package/dist/server/naming.js +38 -0
- package/dist/server/pages.js +2 -0
- package/dist/server/server.js +53 -17
- package/dist/server/types.d.ts +6 -3
- package/dist/shell/annotate.js +31 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +8 -2
- package/dist/shell/runtime.js +47 -2
- package/package.json +1 -1
- package/src/compile/policy.ts +22 -6
- package/src/config/schema.ts +23 -0
- package/src/config/validate.ts +193 -2
- package/src/conformance/behavior.ts +20 -1
- package/src/conformance/kit.ts +16 -0
- package/src/conformance/probes.ts +21 -2
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +25 -4
- package/src/host/create-host.ts +26 -7
- package/src/host/types.ts +39 -2
- package/src/index.ts +4 -0
- package/src/presets/presets.ts +8 -0
- package/src/react/frame.tsx +136 -27
- package/src/server/naming.ts +41 -0
- package/src/server/pages.ts +2 -0
- package/src/server/server.ts +65 -20
- package/src/server/types.ts +6 -3
- package/src/shell/annotate.ts +31 -0
- package/src/shell/document.ts +15 -2
- package/src/shell/runtime.ts +47 -2
package/src/config/validate.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { CAPABILITY_NAMES, isCapabilityName, type Gate } from "./capabilities.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PASSIVE_KINDS,
|
|
4
|
+
URL_PERMISSIONS,
|
|
5
|
+
URL_SANDBOX_EXTRAS,
|
|
6
|
+
type PortholeConfig,
|
|
7
|
+
} from "./schema.ts";
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Checks a config before anything is derived from it. Every string that ends up in a CSP, an
|
|
@@ -22,6 +27,155 @@ export class ConfigError extends Error {
|
|
|
22
27
|
export const BUILT_IN_TOPICS = ["widget", "chart", "interactive"] as const;
|
|
23
28
|
|
|
24
29
|
const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/u;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Suffixes under which anyone can register or publish a name (a public suffix): a wildcard over
|
|
33
|
+
* one (`https://*.co.uk`, `https://*.github.io`) opens every stranger's site. Single labels (`com`,
|
|
34
|
+
* `dev`) are refused by rule; these are the common multi-label ones, registries' and hosting
|
|
35
|
+
* platforms'. Not the whole Public Suffix List: a wildcard should name the host's own domain.
|
|
36
|
+
*/
|
|
37
|
+
const PUBLIC_SUFFIXES = new Set([
|
|
38
|
+
// Registries' second levels.
|
|
39
|
+
...["co", "org", "ac", "gov", "me", "net", "ltd", "plc", "sch", "nhs", "police"].map(
|
|
40
|
+
(each) => `${each}.uk`,
|
|
41
|
+
),
|
|
42
|
+
...["com", "net", "org", "edu", "gov", "asn", "id"].map((each) => `${each}.au`),
|
|
43
|
+
...["co", "org", "net", "ac", "govt", "geek", "school"].map((each) => `${each}.nz`),
|
|
44
|
+
...["co", "ne", "or", "ac", "go", "ad", "ed", "gr", "lg"].map((each) => `${each}.jp`),
|
|
45
|
+
...["co", "or", "ne", "ac", "go", "re"].map((each) => `${each}.kr`),
|
|
46
|
+
...["com", "net", "org", "gov", "edu"].flatMap((each) =>
|
|
47
|
+
[
|
|
48
|
+
"br",
|
|
49
|
+
"cn",
|
|
50
|
+
"hk",
|
|
51
|
+
"tw",
|
|
52
|
+
"sg",
|
|
53
|
+
"my",
|
|
54
|
+
"mx",
|
|
55
|
+
"ar",
|
|
56
|
+
"tr",
|
|
57
|
+
"ua",
|
|
58
|
+
"pl",
|
|
59
|
+
"ph",
|
|
60
|
+
"pk",
|
|
61
|
+
"ng",
|
|
62
|
+
"eg",
|
|
63
|
+
"sa",
|
|
64
|
+
"vn",
|
|
65
|
+
"co",
|
|
66
|
+
].map((country) => `${each}.${country}`),
|
|
67
|
+
),
|
|
68
|
+
...["co", "net", "org", "gen", "firm", "ind", "ac", "edu", "gov", "res"].map(
|
|
69
|
+
(each) => `${each}.in`,
|
|
70
|
+
),
|
|
71
|
+
...["co", "org", "ac", "gov", "net", "web"].map((each) => `${each}.za`),
|
|
72
|
+
...["co", "org", "ac", "gov", "net", "muni"].map((each) => `${each}.il`),
|
|
73
|
+
"co.id",
|
|
74
|
+
"or.id",
|
|
75
|
+
"ac.id",
|
|
76
|
+
"co.th",
|
|
77
|
+
"or.th",
|
|
78
|
+
"ac.th",
|
|
79
|
+
"co.ke",
|
|
80
|
+
"co.tz",
|
|
81
|
+
"co.ug",
|
|
82
|
+
"com.de",
|
|
83
|
+
"co.at",
|
|
84
|
+
"or.at",
|
|
85
|
+
"ac.at",
|
|
86
|
+
"co.it",
|
|
87
|
+
"com.es",
|
|
88
|
+
"nom.es",
|
|
89
|
+
"com.fr",
|
|
90
|
+
"asso.fr",
|
|
91
|
+
"eu.org",
|
|
92
|
+
"us.com",
|
|
93
|
+
"uk.com",
|
|
94
|
+
"eu.com",
|
|
95
|
+
"uk.net",
|
|
96
|
+
"gb.net",
|
|
97
|
+
"jpn.com",
|
|
98
|
+
"kr.com",
|
|
99
|
+
// Hosting platforms: anyone gets a name under these.
|
|
100
|
+
"github.io",
|
|
101
|
+
"githubusercontent.com",
|
|
102
|
+
"gitlab.io",
|
|
103
|
+
"bitbucket.io",
|
|
104
|
+
"pages.dev",
|
|
105
|
+
"workers.dev",
|
|
106
|
+
"r2.dev",
|
|
107
|
+
"vercel.app",
|
|
108
|
+
"now.sh",
|
|
109
|
+
"netlify.app",
|
|
110
|
+
"netlify.com",
|
|
111
|
+
"herokuapp.com",
|
|
112
|
+
"herokussl.com",
|
|
113
|
+
"appspot.com",
|
|
114
|
+
"web.app",
|
|
115
|
+
"firebaseapp.com",
|
|
116
|
+
"cloudfunctions.net",
|
|
117
|
+
"run.app",
|
|
118
|
+
"googleusercontent.com",
|
|
119
|
+
"blogspot.com",
|
|
120
|
+
"azurewebsites.net",
|
|
121
|
+
"azurestaticapps.net",
|
|
122
|
+
"azureedge.net",
|
|
123
|
+
"blob.core.windows.net",
|
|
124
|
+
"cloudapp.net",
|
|
125
|
+
"trafficmanager.net",
|
|
126
|
+
"cloudfront.net",
|
|
127
|
+
"amazonaws.com",
|
|
128
|
+
"s3.amazonaws.com",
|
|
129
|
+
"elasticbeanstalk.com",
|
|
130
|
+
"awsapprunner.com",
|
|
131
|
+
"amplifyapp.com",
|
|
132
|
+
"fly.dev",
|
|
133
|
+
"onrender.com",
|
|
134
|
+
"railway.app",
|
|
135
|
+
"up.railway.app",
|
|
136
|
+
"glitch.me",
|
|
137
|
+
"repl.co",
|
|
138
|
+
"replit.app",
|
|
139
|
+
"replit.dev",
|
|
140
|
+
"surge.sh",
|
|
141
|
+
"deno.dev",
|
|
142
|
+
"ngrok.io",
|
|
143
|
+
"ngrok-free.app",
|
|
144
|
+
"ngrok.app",
|
|
145
|
+
"loca.lt",
|
|
146
|
+
"trycloudflare.com",
|
|
147
|
+
"codesandbox.io",
|
|
148
|
+
"csb.app",
|
|
149
|
+
"stackblitz.io",
|
|
150
|
+
"webcontainer.io",
|
|
151
|
+
"neocities.org",
|
|
152
|
+
"wordpress.com",
|
|
153
|
+
"tumblr.com",
|
|
154
|
+
"wixsite.com",
|
|
155
|
+
"squarespace.com",
|
|
156
|
+
"myshopify.com",
|
|
157
|
+
"carrd.co",
|
|
158
|
+
"notion.site",
|
|
159
|
+
"framer.app",
|
|
160
|
+
"webflow.io",
|
|
161
|
+
"readthedocs.io",
|
|
162
|
+
"gitbook.io",
|
|
163
|
+
"sourceforge.net",
|
|
164
|
+
"digitaloceanspaces.com",
|
|
165
|
+
"ondigitalocean.app",
|
|
166
|
+
"b-cdn.net",
|
|
167
|
+
"jsdelivr.net",
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
/** Why a wildcard's base can't be wildcarded, or null: a public suffix, or a single label. */
|
|
171
|
+
function wildcardProblem(base: string): string | null {
|
|
172
|
+
if (base === "localhost") {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return !base.includes(".") || PUBLIC_SUFFIXES.has(base)
|
|
176
|
+
? `is a wildcard over a public suffix (*.${base}): anyone can serve a site under it; name a domain of your own (https://*.cdn.example.com)`
|
|
177
|
+
: null;
|
|
178
|
+
}
|
|
25
179
|
const HOST =
|
|
26
180
|
/^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
|
|
27
181
|
|
|
@@ -50,6 +204,12 @@ export function originSourceProblem(value: unknown): string | null {
|
|
|
50
204
|
if (host !== "[::1]" && !HOST.test(host)) {
|
|
51
205
|
return `${JSON.stringify(value)} ${shape}`;
|
|
52
206
|
}
|
|
207
|
+
if (host.startsWith("*.")) {
|
|
208
|
+
const problem = wildcardProblem(host.slice(2));
|
|
209
|
+
if (problem !== null) {
|
|
210
|
+
return `${JSON.stringify(value)} ${problem}`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
53
213
|
if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
|
|
54
214
|
return `${JSON.stringify(value)} uses ${scheme} off loopback: ${scheme} is allowed only for localhost, *.localhost, 127.0.0.1 and [::1] (loopback development); use ${scheme === "http" ? "https" : "wss"}`;
|
|
55
215
|
}
|
|
@@ -131,6 +291,7 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
131
291
|
"embedder",
|
|
132
292
|
"sandbox",
|
|
133
293
|
"permissions",
|
|
294
|
+
"storage",
|
|
134
295
|
],
|
|
135
296
|
"config",
|
|
136
297
|
);
|
|
@@ -173,10 +334,26 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
173
334
|
|
|
174
335
|
const egress = object(config.egress, "config.egress");
|
|
175
336
|
const kinds = ["connect", "script", "style", "img", "font", "media"];
|
|
176
|
-
only(egress, kinds, "config.egress");
|
|
337
|
+
only(egress, [...kinds, "anyHttps"], "config.egress");
|
|
177
338
|
for (const kind of kinds) {
|
|
178
339
|
sources(egress[kind], `config.egress.${kind}`);
|
|
179
340
|
}
|
|
341
|
+
if (egress.anyHttps !== undefined) {
|
|
342
|
+
if (!Array.isArray(egress.anyHttps)) {
|
|
343
|
+
fail("config.egress.anyHttps", `must be a list of ${PASSIVE_KINDS.join(", ")}`);
|
|
344
|
+
}
|
|
345
|
+
(egress.anyHttps as unknown[]).forEach((kind, index) => {
|
|
346
|
+
if (!(PASSIVE_KINDS as readonly unknown[]).includes(kind)) {
|
|
347
|
+
fail(
|
|
348
|
+
`config.egress.anyHttps[${index}]`,
|
|
349
|
+
`${JSON.stringify(kind)}: only images, media and fonts may load from any https site (P059), never scripts, styles or data`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
if (config.mode === "url") {
|
|
354
|
+
fail("config.egress.anyHttps", "url mode has no egress control");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
180
357
|
|
|
181
358
|
const theme = object(config.theme, "config.theme");
|
|
182
359
|
only(theme, ["variables", "event", "documents"], "config.theme");
|
|
@@ -315,6 +492,20 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
315
492
|
});
|
|
316
493
|
}
|
|
317
494
|
|
|
495
|
+
if (config.storage !== undefined) {
|
|
496
|
+
const storage = object(config.storage, "config.storage");
|
|
497
|
+
only(storage, ["shared"], "config.storage");
|
|
498
|
+
if (config.isolation !== "domain") {
|
|
499
|
+
fail("config.storage", "only domain isolation keeps storage across displays");
|
|
500
|
+
}
|
|
501
|
+
if (
|
|
502
|
+
storage.shared !== undefined &&
|
|
503
|
+
(typeof storage.shared !== "string" || !/^[a-z][a-z -]{0,39}$/u.test(storage.shared))
|
|
504
|
+
) {
|
|
505
|
+
fail("config.storage.shared", 'must be a short lowercase noun ("conversation")');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
318
509
|
if (config.permissions !== undefined) {
|
|
319
510
|
if (config.mode !== "url") {
|
|
320
511
|
fail("config.permissions", "only url mode may be delegated features");
|
|
@@ -197,7 +197,8 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
|
|
|
197
197
|
{
|
|
198
198
|
id: "11.theme-flip",
|
|
199
199
|
item: 11,
|
|
200
|
-
title:
|
|
200
|
+
title:
|
|
201
|
+
"a theme flip re-themes the running content in place, and tells it once; no event without one",
|
|
201
202
|
async run(context) {
|
|
202
203
|
const config = inline({ theme: { event: "app:theme" } });
|
|
203
204
|
const light = resolveTheme(config.theme, "light");
|
|
@@ -210,6 +211,13 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
|
|
|
210
211
|
const frame = await display.frame();
|
|
211
212
|
await expect(frame.locator("p")).toHaveText("themed");
|
|
212
213
|
expect(await variable(frame, "--color-text-primary")).toBe(light["--color-text-primary"]);
|
|
214
|
+
const flips = () => read(frame, () => (window as unknown as { flips: string[] }).flips);
|
|
215
|
+
// Mounting, and a host telling it the scheme it already has, are no flip: no event (#3).
|
|
216
|
+
await settle(400);
|
|
217
|
+
expect(await flips(), "no theme event at mount").toEqual([]);
|
|
218
|
+
await display.setTheme("light");
|
|
219
|
+
await settle(400);
|
|
220
|
+
expect(await flips(), "no theme event for the scheme it has").toEqual([]);
|
|
213
221
|
await display.setTheme("dark");
|
|
214
222
|
await expect
|
|
215
223
|
.poll(() => variable(frame, "--color-text-primary"))
|
|
@@ -477,6 +485,17 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
|
|
|
477
485
|
content: PROMPT_CONTENT,
|
|
478
486
|
});
|
|
479
487
|
await click(display, "#ask");
|
|
488
|
+
// A host that always has a confirm function (a default dialog) asks anyway: what this case
|
|
489
|
+
// proves can't happen there (P058). Its answer is no, so nothing is sent either way.
|
|
490
|
+
const asked = await display.answerNextConfirmation(false, { timeoutMs: 1_500 }).then(
|
|
491
|
+
() => true,
|
|
492
|
+
() => false,
|
|
493
|
+
);
|
|
494
|
+
if (asked) {
|
|
495
|
+
context.inapplicable(
|
|
496
|
+
"this host always has a confirm function (a default dialog), so a display without one can't be shown; it asked the person instead",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
480
499
|
expect(await bodyData(display, "result")).toBe("false");
|
|
481
500
|
expect(await refusals(display)).toContain("sendPrompt:no-confirm");
|
|
482
501
|
expect(await display.calls()).toEqual([]);
|
package/src/conformance/kit.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface ProbeContext {
|
|
|
22
22
|
requestsTo(origin: string): string[];
|
|
23
23
|
/** Records a measured engine fact in the report. */
|
|
24
24
|
measured(text: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Ends the probe as inapplicable on this host (reported as skipped, with the reason): what it
|
|
27
|
+
* proves can't be observed here, through no fault of the host's isolation (P058).
|
|
28
|
+
*/
|
|
29
|
+
inapplicable(reason: string): never;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface Probe {
|
|
@@ -49,6 +54,17 @@ export function attack(code: string, markup = ""): string {
|
|
|
49
54
|
(async () => {
|
|
50
55
|
const r = {};
|
|
51
56
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
// Whether a policy blocks \`origin\` though the frame's own allows it: then it is the page's,
|
|
58
|
+
// which an opaque frame inherits (P058). \`attempt\` loads something from it once more.
|
|
59
|
+
const inheritedBlocks = async (origin, attempt) => {
|
|
60
|
+
let blocked = false;
|
|
61
|
+
const seen = (event) => { if (String(event.blockedURI).indexOf(origin) === 0) blocked = true; };
|
|
62
|
+
document.addEventListener("securitypolicyviolation", seen);
|
|
63
|
+
try { await attempt(); } catch (e) {}
|
|
64
|
+
await wait(300);
|
|
65
|
+
document.removeEventListener("securitypolicyviolation", seen);
|
|
66
|
+
return blocked;
|
|
67
|
+
};
|
|
52
68
|
try {
|
|
53
69
|
${code}
|
|
54
70
|
} catch (error) {
|
|
@@ -335,6 +335,13 @@ const sdkProbe = (preset: "inline" | "artifact" | "mcp-app"): Probe => ({
|
|
|
335
335
|
export type { Probe, ProbeContext } from "./kit.ts";
|
|
336
336
|
export { attack } from "./kit.ts";
|
|
337
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Why a positive control can't pass on this host: an opaque (`srcdoc`) frame inherits the
|
|
340
|
+
* embedding page's whole policy, and the page's own closes what the config opens (P058).
|
|
341
|
+
*/
|
|
342
|
+
const INHERITED = (directive: string, origin: string) =>
|
|
343
|
+
`the page's own CSP, which an opaque frame inherits, blocks ${origin} (${directive}): content in opaque frames here is as closed as the page, whatever its config opens. Domain mode's frames don't inherit it.`;
|
|
344
|
+
|
|
338
345
|
export const PROBES: readonly Probe[] = [
|
|
339
346
|
// ── 0: the shell's own wiring, in a real frame ─────────────────────────────────────────────
|
|
340
347
|
{
|
|
@@ -440,10 +447,14 @@ export const PROBES: readonly Probe[] = [
|
|
|
440
447
|
worker.onmessage = (event) => (event.data === "sent" ? resolve() : reject());
|
|
441
448
|
worker.onerror = reject;
|
|
442
449
|
}));
|
|
443
|
-
r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked")
|
|
450
|
+
r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");
|
|
451
|
+
r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => fetch(${js(allowed.origin)} + "/policy").catch(() => {}));`),
|
|
444
452
|
});
|
|
445
453
|
const verdict = await context.result(display, 20_000);
|
|
446
454
|
await settle();
|
|
455
|
+
if (verdict.allowed !== "oracle" && verdict.inherited === true) {
|
|
456
|
+
context.inapplicable(INHERITED("connect-src", allowed.origin));
|
|
457
|
+
}
|
|
447
458
|
expect(verdict.allowed, "the allowed origin (the positive control)").toBe("oracle");
|
|
448
459
|
expect(allowed.requests().map((request) => request.path)).toContain("/allowed");
|
|
449
460
|
expect(canary.requests(), "requests reached the canary").toEqual([]);
|
|
@@ -489,12 +500,16 @@ export const PROBES: readonly Probe[] = [
|
|
|
489
500
|
try { new Worker(${js(`${c}/worker.js`)}); } catch (e) {}
|
|
490
501
|
const ok = document.getElementById("ok");
|
|
491
502
|
r.allowed = await new Promise((resolve) => { if (ok.complete && ok.naturalWidth > 0) resolve("loaded"); ok.onload = () => resolve("loaded"); ok.onerror = () => resolve("blocked"); setTimeout(() => resolve("timeout"), 3000); });
|
|
492
|
-
r.script = typeof globalThis.__portholeOracleScript
|
|
503
|
+
r.script = typeof globalThis.__portholeOracleScript;
|
|
504
|
+
r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => { new Image().src = ${js(`${allowed.origin}/policy.png`)}; });`,
|
|
493
505
|
markup,
|
|
494
506
|
),
|
|
495
507
|
});
|
|
496
508
|
const verdict = await context.result(display, 20_000);
|
|
497
509
|
await settle();
|
|
510
|
+
if (verdict.allowed !== "loaded" && verdict.inherited === true) {
|
|
511
|
+
context.inapplicable(INHERITED("img-src", allowed.origin));
|
|
512
|
+
}
|
|
498
513
|
expect(verdict.allowed, "the allowed image (the positive control)").toBe("loaded");
|
|
499
514
|
expect(canary.requests(), "resources loaded from the canary").toEqual([]);
|
|
500
515
|
expect(context.requestsTo(canary.origin)).toEqual([]);
|
|
@@ -1106,6 +1121,10 @@ export function leakTests(
|
|
|
1106
1121
|
requestsTo: (origin) =>
|
|
1107
1122
|
requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
|
|
1108
1123
|
measured: (text) => info.annotations.push({ type: "measured", description: text }),
|
|
1124
|
+
inapplicable: (why) => {
|
|
1125
|
+
info.skip(true, `inapplicable on this host: ${why}`);
|
|
1126
|
+
throw new Error(`inapplicable on this host: ${why}`);
|
|
1127
|
+
},
|
|
1109
1128
|
});
|
|
1110
1129
|
} finally {
|
|
1111
1130
|
await Promise.all(closers.map((close) => close()));
|
|
@@ -38,8 +38,17 @@ export interface ServedConformanceHost {
|
|
|
38
38
|
* Shows `instance` in `page` as the host does: a fresh ticket from its backend, framed under
|
|
39
39
|
* the config's sandbox, in an embedding page that sends the config's `embedderCsp`. The same page
|
|
40
40
|
* may show several, one after another.
|
|
41
|
+
*
|
|
42
|
+
* For a served shell the suite gives `options.content`, a line of HTML: a host that speaks to
|
|
43
|
+
* the shell (`createHost`, the React binding) sends it as the display's content, since a shell
|
|
44
|
+
* draws nothing (and reports no height) until it has content, and the cases click inside it. A
|
|
45
|
+
* host whose frame has a height of its own may ignore it.
|
|
41
46
|
*/
|
|
42
|
-
show(
|
|
47
|
+
show(
|
|
48
|
+
page: Page,
|
|
49
|
+
instance: InstanceRef,
|
|
50
|
+
options?: { readonly content?: string },
|
|
51
|
+
): Promise<ServedDisplay>;
|
|
43
52
|
/** A fresh ticketed location for `instance`, as the host's backend issues it. */
|
|
44
53
|
locate(instance: InstanceRef): Promise<FrameLocation>;
|
|
45
54
|
/** Navigates `page` to a page on an origin that is not one of the host's, framing `url`. */
|
|
@@ -74,6 +83,9 @@ type Fixtures = { page: Page; browserName: string };
|
|
|
74
83
|
|
|
75
84
|
const SETTLE_MS = 800;
|
|
76
85
|
|
|
86
|
+
/** What a served shell is given to draw (`ServedConformanceHost.show`'s `content`). */
|
|
87
|
+
const SHELL_CONTENT = '<p id="porthole-probe-content">Porthole conformance probe</p>';
|
|
88
|
+
|
|
77
89
|
/** What the frame can see of its host: everything here must be closed to it. */
|
|
78
90
|
async function reachIntoHost(frame: Frame): Promise<Record<string, string>> {
|
|
79
91
|
return frame.evaluate(() => {
|
|
@@ -185,13 +197,16 @@ export function servedTests(
|
|
|
185
197
|
["domain", (scope: string) => host.shell(scope)],
|
|
186
198
|
["url", (scope: string) => host.app(scope)],
|
|
187
199
|
] as const;
|
|
200
|
+
// A served shell draws (and has a height to click in) once its host gives it content.
|
|
201
|
+
const showMode = (page: Page, mode: "domain" | "url", instance: InstanceRef) =>
|
|
202
|
+
host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
|
|
188
203
|
|
|
189
204
|
for (const [mode, instance] of modes) {
|
|
190
205
|
define(
|
|
191
206
|
`served.${mode}.origin`,
|
|
192
207
|
"content runs on its own origin, never the host's",
|
|
193
208
|
async ({ page }) => {
|
|
194
|
-
const display = await
|
|
209
|
+
const display = await showMode(page, mode, instance(`${mode}-origin`));
|
|
195
210
|
const frame = await display.frame();
|
|
196
211
|
const origin = await frame.evaluate(() => window.origin);
|
|
197
212
|
expect(origin).toBe(display.origin);
|
|
@@ -203,7 +218,7 @@ export function servedTests(
|
|
|
203
218
|
`served.${mode}.no-reach`,
|
|
204
219
|
"content can't reach the host's DOM, storage, cookies or URL",
|
|
205
220
|
async ({ page }) => {
|
|
206
|
-
const display = await
|
|
221
|
+
const display = await showMode(page, mode, instance(`${mode}-reach`));
|
|
207
222
|
await plantHostSecrets(page);
|
|
208
223
|
expect(await reachIntoHost(await display.frame())).toEqual(CLOSED);
|
|
209
224
|
},
|
|
@@ -213,7 +228,7 @@ export function servedTests(
|
|
|
213
228
|
`served.${mode}.no-top-navigation`,
|
|
214
229
|
"content can't navigate the host's page, even after a click",
|
|
215
230
|
async ({ page }) => {
|
|
216
|
-
const display = await
|
|
231
|
+
const display = await showMode(page, mode, instance(`${mode}-top`));
|
|
217
232
|
const frame = await display.frame();
|
|
218
233
|
const before = page.url();
|
|
219
234
|
const target = await host.canary(`${mode}-top`);
|
|
@@ -246,7 +261,7 @@ export function servedTests(
|
|
|
246
261
|
`served.${mode}.no-popups`,
|
|
247
262
|
"content can't open a window, even after a click",
|
|
248
263
|
async ({ page }) => {
|
|
249
|
-
const display = await
|
|
264
|
+
const display = await showMode(page, mode, instance(`${mode}-popup`));
|
|
250
265
|
const frame = await display.frame();
|
|
251
266
|
const target = await host.canary(`${mode}-popup`);
|
|
252
267
|
const opened: Page[] = [];
|
|
@@ -283,13 +298,13 @@ export function servedTests(
|
|
|
283
298
|
"served.domain.storage-scope",
|
|
284
299
|
"storage persists within a scope and is never shared across scopes",
|
|
285
300
|
async ({ page }) => {
|
|
286
|
-
const first = await host.show(page, host.shell("scope-a"));
|
|
301
|
+
const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
|
|
287
302
|
await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
|
|
288
303
|
await first.destroy();
|
|
289
|
-
const again = await host.show(page, host.shell("scope-a"));
|
|
304
|
+
const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
|
|
290
305
|
expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
|
|
291
306
|
expect(again.origin).toBe(first.origin);
|
|
292
|
-
const other = await host.show(page, host.shell("scope-b"));
|
|
307
|
+
const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
|
|
293
308
|
expect(other.origin).not.toBe(first.origin);
|
|
294
309
|
expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
|
|
295
310
|
},
|
|
@@ -367,7 +382,7 @@ export function servedTests(
|
|
|
367
382
|
"served.domain.egress",
|
|
368
383
|
"the CSP in the document's headers closes egress",
|
|
369
384
|
async ({ page }) => {
|
|
370
|
-
const display = await host.show(page, host.shell("egress"));
|
|
385
|
+
const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
|
|
371
386
|
const frame = await display.frame();
|
|
372
387
|
const fetched = await frame.evaluate(
|
|
373
388
|
async (url) => {
|
package/src/guide/guide.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import type { PortholeConfig } from "../config/schema.ts";
|
|
8
8
|
import { BUILT_IN_TOPICS } from "../config/validate.ts";
|
|
9
9
|
import {
|
|
10
|
+
anyHttps,
|
|
10
11
|
declaredCapabilities,
|
|
11
12
|
effectiveGates,
|
|
12
13
|
egressOrigins,
|
|
@@ -83,11 +84,20 @@ const KIND_WORDS: Readonly<Record<EgressKind, string>> = {
|
|
|
83
84
|
connect: "data (fetch, WebSocket)",
|
|
84
85
|
};
|
|
85
86
|
|
|
87
|
+
/** What the manual calls `https:` (any https site), where a config opens passive kinds to it. */
|
|
88
|
+
export const ANY_HTTPS_SITE = "any https site";
|
|
89
|
+
|
|
90
|
+
/** The sources content may load `kind` from, as the manual names them: origins, any https site. */
|
|
91
|
+
const sourcesFor = (config: PortholeConfig, kind: EgressKind): string[] => [
|
|
92
|
+
...(anyHttps(config, kind) ? [ANY_HTTPS_SITE] : []),
|
|
93
|
+
...egressOrigins(config, kind),
|
|
94
|
+
];
|
|
95
|
+
|
|
86
96
|
/** What content may load, in prose, from the same origins the CSP lists. */
|
|
87
97
|
function network(config: PortholeConfig): string {
|
|
88
98
|
const kinds: EgressKind[] = ["script", "style", "font", "img", "media", "connect"];
|
|
89
99
|
const open = kinds
|
|
90
|
-
.map((kind) => [kind,
|
|
100
|
+
.map((kind) => [kind, sourcesFor(config, kind)] as const)
|
|
91
101
|
.filter(([, origins]) => origins.length > 0);
|
|
92
102
|
if (open.length === 0) {
|
|
93
103
|
return "Network: none. Nothing loads from outside (no scripts, styles, fonts, images or data from any server) and any fetch fails, so write everything inline: SVG and CSS draw charts well.";
|
|
@@ -368,7 +378,9 @@ function interactive(c: Context): string {
|
|
|
368
378
|
const state =
|
|
369
379
|
config.isolation === "opaque"
|
|
370
380
|
? `- State isn't saved: localStorage works but lasts only while the ${noun} is on screen, and a reload or another device starts fresh. Keep what matters in the ${noun}'s code.`
|
|
371
|
-
:
|
|
381
|
+
: config.storage?.shared === undefined
|
|
382
|
+
? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
|
|
383
|
+
: `- localStorage is shared by every ${noun} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${noun}'s own ("budget:filters", not "filters"), or ${noun}s overwrite each other.`;
|
|
372
384
|
const none =
|
|
373
385
|
entries.length === 0
|
|
374
386
|
? `- It can only draw: it has no way to send anything to you or the app.`
|
|
@@ -417,10 +429,19 @@ export function generateToolDescription(config: PortholeConfig): string {
|
|
|
417
429
|
),
|
|
418
430
|
),
|
|
419
431
|
];
|
|
432
|
+
const passive = (["img", "media", "font"] as const)
|
|
433
|
+
.filter((kind) => anyHttps(config, kind))
|
|
434
|
+
.map((kind) => KIND_WORDS[kind]);
|
|
435
|
+
const fromAnywhere =
|
|
436
|
+
passive.length === 0
|
|
437
|
+
? ""
|
|
438
|
+
: `${passive.length === 1 ? passive[0] : `${passive.slice(0, -1).join(", ")} and ${passive.at(-1)}`} from ${ANY_HTTPS_SITE}`;
|
|
420
439
|
const net =
|
|
421
|
-
origins.length === 0
|
|
440
|
+
origins.length === 0 && fromAnywhere === ""
|
|
422
441
|
? "with no network (nothing loads from outside)"
|
|
423
|
-
:
|
|
442
|
+
: origins.length === 0
|
|
443
|
+
? `loading only ${fromAnywhere}`
|
|
444
|
+
: `loading only from ${origins.join(", ")}${fromAnywhere === "" ? "" : `, and ${fromAnywhere}`}`;
|
|
424
445
|
const globals = declaredCapabilities(config).flatMap((name) => {
|
|
425
446
|
const spec = capabilitySpec(name);
|
|
426
447
|
if (spec.global === undefined || !c.global(spec.global)) {
|
package/src/host/create-host.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { shellAdapter } from "../protocol/shell-adapter.ts";
|
|
|
26
26
|
import { resolveTheme } from "../theme/variables.ts";
|
|
27
27
|
import type {
|
|
28
28
|
AnnotationControls,
|
|
29
|
+
HostErrorKind,
|
|
29
30
|
DisplayEvents,
|
|
30
31
|
DisplayHandle,
|
|
31
32
|
HostOptions,
|
|
@@ -96,6 +97,16 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
96
97
|
(listener as (detail: DisplayEvents[E]) => void)(detail);
|
|
97
98
|
}
|
|
98
99
|
};
|
|
100
|
+
// What the content did wrong (the agent's to hear and fix), and what failed on the host's
|
|
101
|
+
// side (its own to handle), each on its own event and on `error` with its source.
|
|
102
|
+
const contentError = (message: string) => {
|
|
103
|
+
emit("contentError", { message });
|
|
104
|
+
emit("error", { message, source: "content" });
|
|
105
|
+
};
|
|
106
|
+
const hostError = (message: string, kind: HostErrorKind) => {
|
|
107
|
+
emit("hostError", { message, kind });
|
|
108
|
+
emit("error", { message, source: "host" });
|
|
109
|
+
};
|
|
99
110
|
|
|
100
111
|
let scheme: ColorScheme = mount.scheme;
|
|
101
112
|
let height = mount.initialHeightPx ?? 0;
|
|
@@ -277,7 +288,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
277
288
|
return;
|
|
278
289
|
}
|
|
279
290
|
case "error":
|
|
280
|
-
|
|
291
|
+
contentError(event.message);
|
|
281
292
|
return;
|
|
282
293
|
case "refused":
|
|
283
294
|
emit("refused", { capability: event.capability, refusal: event.refusal });
|
|
@@ -311,8 +322,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
311
322
|
allow: compiled.allow,
|
|
312
323
|
referrerPolicy: compiled.referrerPolicy,
|
|
313
324
|
colorScheme: scheme,
|
|
314
|
-
// An app reports no size: its frame fills the host's container, at least the minimum
|
|
315
|
-
|
|
325
|
+
// An app reports no size: its frame fills the host's container, at least the minimum;
|
|
326
|
+
// so does any display the host mounts to fill (its content scrolls inside).
|
|
327
|
+
...(speaks && mount.fill !== true
|
|
316
328
|
? { initialHeightPx: height }
|
|
317
329
|
: { fill: true, initialHeightPx: config.limits.minHeightPx }),
|
|
318
330
|
});
|
|
@@ -377,9 +389,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
377
389
|
run();
|
|
378
390
|
} else {
|
|
379
391
|
stop();
|
|
380
|
-
|
|
381
|
-
message: "The content kept navigating its frame away; it was stopped.",
|
|
382
|
-
});
|
|
392
|
+
contentError("The content kept navigating its frame away; it was stopped.");
|
|
383
393
|
}
|
|
384
394
|
});
|
|
385
395
|
};
|
|
@@ -396,6 +406,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
396
406
|
html: compiled.shellDocument({
|
|
397
407
|
origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
|
|
398
408
|
title: mount.title,
|
|
409
|
+
...nonceOf(options.nonce),
|
|
399
410
|
...(mount.document === undefined ? {} : { document: mount.document }),
|
|
400
411
|
}),
|
|
401
412
|
});
|
|
@@ -418,7 +429,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
418
429
|
},
|
|
419
430
|
(error: unknown) => {
|
|
420
431
|
if (mine === generation && !controller.signal.aborted) {
|
|
421
|
-
|
|
432
|
+
// The host's own failure (its backend down, access gone): never the content's.
|
|
433
|
+
hostError(error instanceof Error ? error.message : String(error), "locate");
|
|
434
|
+
emit("outcome", { state: "blocked", reason: "locate" });
|
|
422
435
|
}
|
|
423
436
|
},
|
|
424
437
|
);
|
|
@@ -488,3 +501,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
488
501
|
},
|
|
489
502
|
};
|
|
490
503
|
}
|
|
504
|
+
|
|
505
|
+
/** The host's nonce option, read now (`HostOptions.nonce`). */
|
|
506
|
+
function nonceOf(nonce: HostOptions["nonce"]): { nonce?: string } {
|
|
507
|
+
const value = typeof nonce === "function" ? nonce() : nonce;
|
|
508
|
+
return value === undefined || value === "" ? {} : { nonce: value };
|
|
509
|
+
}
|