@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/dist/compile/policy.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type CapabilityName, type Gate } from "../config/capabilities.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
3
3
|
/**
|
|
4
4
|
* The frame's policies, derived from the config: its CSP (in the shell's `<meta>` for opaque
|
|
5
5
|
* frames, in headers for served ones), its `sandbox` and `allow` attributes, and the `frame-src`
|
|
@@ -7,7 +7,9 @@ import type { EgressConfig, PortholeConfig } from "../config/schema.ts";
|
|
|
7
7
|
*/
|
|
8
8
|
/** A CSP directive: its name, then its sources. */
|
|
9
9
|
export type CspDirective = readonly [name: string, ...sources: string[]];
|
|
10
|
-
export type EgressKind =
|
|
10
|
+
export type EgressKind = "connect" | "script" | "style" | "img" | "font" | "media";
|
|
11
|
+
/** Whether `kind` loads from any https site (`EgressConfig.anyHttps`, P059): passive kinds only. */
|
|
12
|
+
export declare function anyHttps(config: PortholeConfig, kind: EgressKind): boolean;
|
|
11
13
|
/** The capabilities a config declares, in vocabulary order. */
|
|
12
14
|
export declare function declaredCapabilities(config: PortholeConfig): CapabilityName[];
|
|
13
15
|
/** The gates a declared capability runs under: its own, else the vocabulary's defaults. */
|
|
@@ -55,3 +57,9 @@ export declare function embedderFrameSources(config: PortholeConfig): string[];
|
|
|
55
57
|
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
56
58
|
*/
|
|
57
59
|
export declare function embedderCsp(configs: readonly PortholeConfig[]): string;
|
|
60
|
+
/**
|
|
61
|
+
* The `frame-src` sources the embedding page must allow for every kind of display it shows, as a
|
|
62
|
+
* list, for a host that writes its own policy around them (empty: it frames nothing of
|
|
63
|
+
* Porthole's but `srcdoc`, which needs none).
|
|
64
|
+
*/
|
|
65
|
+
export declare function embedderFrameSourcesOf(configs: readonly PortholeConfig[]): string[];
|
package/dist/compile/policy.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { CAPABILITIES } from "../config/capabilities.js";
|
|
2
|
+
/** Whether `kind` loads from any https site (`EgressConfig.anyHttps`, P059): passive kinds only. */
|
|
3
|
+
export function anyHttps(config, kind) {
|
|
4
|
+
return (config.egress.anyHttps ?? []).some((each) => each === kind);
|
|
5
|
+
}
|
|
2
6
|
/** The capabilities a config declares, in vocabulary order. */
|
|
3
7
|
export function declaredCapabilities(config) {
|
|
4
8
|
return Object.keys(CAPABILITIES).filter((name) => config.capabilities[name] !== undefined);
|
|
@@ -34,13 +38,15 @@ export function frameCspDirectives(config) {
|
|
|
34
38
|
return [];
|
|
35
39
|
}
|
|
36
40
|
const origins = (kind) => egressOrigins(config, kind);
|
|
41
|
+
// Any https site, for passive kinds a host opened so (P059); never for what runs or talks.
|
|
42
|
+
const https = (kind) => (anyHttps(config, kind) ? ["https:"] : []);
|
|
37
43
|
return [
|
|
38
44
|
["default-src", "'none'"],
|
|
39
45
|
["script-src", "'unsafe-inline'", "'unsafe-eval'", "blob:", ...origins("script")],
|
|
40
46
|
["style-src", "'unsafe-inline'", ...origins("style")],
|
|
41
|
-
["font-src", "data:", ...origins("font")],
|
|
42
|
-
["img-src", "data:", "blob:", ...origins("img")],
|
|
43
|
-
["media-src", "data:", "blob:", ...origins("media")],
|
|
47
|
+
["font-src", "data:", ...https("font"), ...origins("font")],
|
|
48
|
+
["img-src", "data:", "blob:", ...https("img"), ...origins("img")],
|
|
49
|
+
["media-src", "data:", "blob:", ...https("media"), ...origins("media")],
|
|
44
50
|
["connect-src", ...list(origins("connect"))],
|
|
45
51
|
["worker-src", "blob:"],
|
|
46
52
|
["child-src", "blob:"],
|
|
@@ -102,6 +108,14 @@ export function embedderFrameSources(config) {
|
|
|
102
108
|
* engine Porthole supports reads frames from `frame-src`, P038).
|
|
103
109
|
*/
|
|
104
110
|
export function embedderCsp(configs) {
|
|
105
|
-
const sources =
|
|
111
|
+
const sources = embedderFrameSourcesOf(configs);
|
|
106
112
|
return serializeCsp([["frame-src", ...(sources.length === 0 ? ["'none'"] : sources)]]);
|
|
107
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* The `frame-src` sources the embedding page must allow for every kind of display it shows, as a
|
|
116
|
+
* list, for a host that writes its own policy around them (empty: it frames nothing of
|
|
117
|
+
* Porthole's but `srcdoc`, which needs none).
|
|
118
|
+
*/
|
|
119
|
+
export function embedderFrameSourcesOf(configs) {
|
|
120
|
+
return unique(configs.flatMap(embedderFrameSources));
|
|
121
|
+
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -58,7 +58,16 @@ export interface EgressConfig {
|
|
|
58
58
|
readonly img?: readonly string[];
|
|
59
59
|
readonly font?: readonly string[];
|
|
60
60
|
readonly media?: readonly string[];
|
|
61
|
+
/**
|
|
62
|
+
* Passive content that may load from any https site (P059): images, audio and video, fonts, for
|
|
63
|
+
* pages that hotlink them. Never scripts, styles or data. It opens a channel out: a viewer's
|
|
64
|
+
* address and a visit signal reach any site, and data can leave in the URL.
|
|
65
|
+
*/
|
|
66
|
+
readonly anyHttps?: readonly PassiveKind[];
|
|
61
67
|
}
|
|
68
|
+
/** What `EgressConfig.anyHttps` may open: content that runs nothing (P059). */
|
|
69
|
+
export declare const PASSIVE_KINDS: readonly ["img", "media", "font"];
|
|
70
|
+
export type PassiveKind = (typeof PASSIVE_KINDS)[number];
|
|
62
71
|
export interface ThemeConfig {
|
|
63
72
|
/**
|
|
64
73
|
* Every CSS variable the content may read, with its default per scheme. The shell injects
|
|
@@ -157,4 +166,15 @@ export interface PortholeConfig {
|
|
|
157
166
|
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
158
167
|
/** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
|
|
159
168
|
readonly permissions?: readonly UrlPermission[];
|
|
169
|
+
/**
|
|
170
|
+
* Domain isolation: how far a display's storage reaches, for the manual. Storage is per origin,
|
|
171
|
+
* and the host picks each display's origin (`InstanceRef.scope`); a host that gives several
|
|
172
|
+
* displays one scope (all of a conversation's widgets) names it here (`{ shared:
|
|
173
|
+
* "conversation" }`), so the manual says storage is shared across it and keys need a prefix.
|
|
174
|
+
*/
|
|
175
|
+
readonly storage?: StorageConfig;
|
|
176
|
+
}
|
|
177
|
+
export interface StorageConfig {
|
|
178
|
+
/** What the displays sharing one origin have in common, as a noun ("conversation"). */
|
|
179
|
+
readonly shared?: string;
|
|
160
180
|
}
|
package/dist/config/schema.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** What `EgressConfig.anyHttps` may open: content that runs nothing (P059). */
|
|
2
|
+
export const PASSIVE_KINDS = ["img", "media", "font"];
|
|
1
3
|
/**
|
|
2
4
|
* Sandbox tokens a url-mode frame may add to its baseline, by the host's choice (P037): a
|
|
3
5
|
* person's own running app (a dev server's preview) may need popups (an OAuth flow), modals and
|
package/dist/config/validate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CAPABILITY_NAMES, isCapabilityName } from "./capabilities.js";
|
|
2
|
-
import { URL_PERMISSIONS, URL_SANDBOX_EXTRAS } from "./schema.js";
|
|
2
|
+
import { PASSIVE_KINDS, URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./schema.js";
|
|
3
3
|
/**
|
|
4
4
|
* Checks a config before anything is derived from it. Every string that ends up in a CSP, an
|
|
5
5
|
* attribute or the shell is checked here, so a malformed origin can't inject a directive
|
|
@@ -17,6 +17,147 @@ export class ConfigError extends Error {
|
|
|
17
17
|
/** The built-in manual topics; a host's own topics may not reuse these names. */
|
|
18
18
|
export const BUILT_IN_TOPICS = ["widget", "chart", "interactive"];
|
|
19
19
|
const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/u;
|
|
20
|
+
/**
|
|
21
|
+
* Suffixes under which anyone can register or publish a name (a public suffix): a wildcard over
|
|
22
|
+
* one (`https://*.co.uk`, `https://*.github.io`) opens every stranger's site. Single labels (`com`,
|
|
23
|
+
* `dev`) are refused by rule; these are the common multi-label ones, registries' and hosting
|
|
24
|
+
* platforms'. Not the whole Public Suffix List: a wildcard should name the host's own domain.
|
|
25
|
+
*/
|
|
26
|
+
const PUBLIC_SUFFIXES = new Set([
|
|
27
|
+
// Registries' second levels.
|
|
28
|
+
...["co", "org", "ac", "gov", "me", "net", "ltd", "plc", "sch", "nhs", "police"].map((each) => `${each}.uk`),
|
|
29
|
+
...["com", "net", "org", "edu", "gov", "asn", "id"].map((each) => `${each}.au`),
|
|
30
|
+
...["co", "org", "net", "ac", "govt", "geek", "school"].map((each) => `${each}.nz`),
|
|
31
|
+
...["co", "ne", "or", "ac", "go", "ad", "ed", "gr", "lg"].map((each) => `${each}.jp`),
|
|
32
|
+
...["co", "or", "ne", "ac", "go", "re"].map((each) => `${each}.kr`),
|
|
33
|
+
...["com", "net", "org", "gov", "edu"].flatMap((each) => [
|
|
34
|
+
"br",
|
|
35
|
+
"cn",
|
|
36
|
+
"hk",
|
|
37
|
+
"tw",
|
|
38
|
+
"sg",
|
|
39
|
+
"my",
|
|
40
|
+
"mx",
|
|
41
|
+
"ar",
|
|
42
|
+
"tr",
|
|
43
|
+
"ua",
|
|
44
|
+
"pl",
|
|
45
|
+
"ph",
|
|
46
|
+
"pk",
|
|
47
|
+
"ng",
|
|
48
|
+
"eg",
|
|
49
|
+
"sa",
|
|
50
|
+
"vn",
|
|
51
|
+
"co",
|
|
52
|
+
].map((country) => `${each}.${country}`)),
|
|
53
|
+
...["co", "net", "org", "gen", "firm", "ind", "ac", "edu", "gov", "res"].map((each) => `${each}.in`),
|
|
54
|
+
...["co", "org", "ac", "gov", "net", "web"].map((each) => `${each}.za`),
|
|
55
|
+
...["co", "org", "ac", "gov", "net", "muni"].map((each) => `${each}.il`),
|
|
56
|
+
"co.id",
|
|
57
|
+
"or.id",
|
|
58
|
+
"ac.id",
|
|
59
|
+
"co.th",
|
|
60
|
+
"or.th",
|
|
61
|
+
"ac.th",
|
|
62
|
+
"co.ke",
|
|
63
|
+
"co.tz",
|
|
64
|
+
"co.ug",
|
|
65
|
+
"com.de",
|
|
66
|
+
"co.at",
|
|
67
|
+
"or.at",
|
|
68
|
+
"ac.at",
|
|
69
|
+
"co.it",
|
|
70
|
+
"com.es",
|
|
71
|
+
"nom.es",
|
|
72
|
+
"com.fr",
|
|
73
|
+
"asso.fr",
|
|
74
|
+
"eu.org",
|
|
75
|
+
"us.com",
|
|
76
|
+
"uk.com",
|
|
77
|
+
"eu.com",
|
|
78
|
+
"uk.net",
|
|
79
|
+
"gb.net",
|
|
80
|
+
"jpn.com",
|
|
81
|
+
"kr.com",
|
|
82
|
+
// Hosting platforms: anyone gets a name under these.
|
|
83
|
+
"github.io",
|
|
84
|
+
"githubusercontent.com",
|
|
85
|
+
"gitlab.io",
|
|
86
|
+
"bitbucket.io",
|
|
87
|
+
"pages.dev",
|
|
88
|
+
"workers.dev",
|
|
89
|
+
"r2.dev",
|
|
90
|
+
"vercel.app",
|
|
91
|
+
"now.sh",
|
|
92
|
+
"netlify.app",
|
|
93
|
+
"netlify.com",
|
|
94
|
+
"herokuapp.com",
|
|
95
|
+
"herokussl.com",
|
|
96
|
+
"appspot.com",
|
|
97
|
+
"web.app",
|
|
98
|
+
"firebaseapp.com",
|
|
99
|
+
"cloudfunctions.net",
|
|
100
|
+
"run.app",
|
|
101
|
+
"googleusercontent.com",
|
|
102
|
+
"blogspot.com",
|
|
103
|
+
"azurewebsites.net",
|
|
104
|
+
"azurestaticapps.net",
|
|
105
|
+
"azureedge.net",
|
|
106
|
+
"blob.core.windows.net",
|
|
107
|
+
"cloudapp.net",
|
|
108
|
+
"trafficmanager.net",
|
|
109
|
+
"cloudfront.net",
|
|
110
|
+
"amazonaws.com",
|
|
111
|
+
"s3.amazonaws.com",
|
|
112
|
+
"elasticbeanstalk.com",
|
|
113
|
+
"awsapprunner.com",
|
|
114
|
+
"amplifyapp.com",
|
|
115
|
+
"fly.dev",
|
|
116
|
+
"onrender.com",
|
|
117
|
+
"railway.app",
|
|
118
|
+
"up.railway.app",
|
|
119
|
+
"glitch.me",
|
|
120
|
+
"repl.co",
|
|
121
|
+
"replit.app",
|
|
122
|
+
"replit.dev",
|
|
123
|
+
"surge.sh",
|
|
124
|
+
"deno.dev",
|
|
125
|
+
"ngrok.io",
|
|
126
|
+
"ngrok-free.app",
|
|
127
|
+
"ngrok.app",
|
|
128
|
+
"loca.lt",
|
|
129
|
+
"trycloudflare.com",
|
|
130
|
+
"codesandbox.io",
|
|
131
|
+
"csb.app",
|
|
132
|
+
"stackblitz.io",
|
|
133
|
+
"webcontainer.io",
|
|
134
|
+
"neocities.org",
|
|
135
|
+
"wordpress.com",
|
|
136
|
+
"tumblr.com",
|
|
137
|
+
"wixsite.com",
|
|
138
|
+
"squarespace.com",
|
|
139
|
+
"myshopify.com",
|
|
140
|
+
"carrd.co",
|
|
141
|
+
"notion.site",
|
|
142
|
+
"framer.app",
|
|
143
|
+
"webflow.io",
|
|
144
|
+
"readthedocs.io",
|
|
145
|
+
"gitbook.io",
|
|
146
|
+
"sourceforge.net",
|
|
147
|
+
"digitaloceanspaces.com",
|
|
148
|
+
"ondigitalocean.app",
|
|
149
|
+
"b-cdn.net",
|
|
150
|
+
"jsdelivr.net",
|
|
151
|
+
]);
|
|
152
|
+
/** Why a wildcard's base can't be wildcarded, or null: a public suffix, or a single label. */
|
|
153
|
+
function wildcardProblem(base) {
|
|
154
|
+
if (base === "localhost") {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return !base.includes(".") || PUBLIC_SUFFIXES.has(base)
|
|
158
|
+
? `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)`
|
|
159
|
+
: null;
|
|
160
|
+
}
|
|
20
161
|
const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
|
|
21
162
|
/**
|
|
22
163
|
* Whether `value` is a CSP host source Porthole accepts: a scheme, a host (optionally `*.` for
|
|
@@ -42,6 +183,12 @@ export function originSourceProblem(value) {
|
|
|
42
183
|
if (host !== "[::1]" && !HOST.test(host)) {
|
|
43
184
|
return `${JSON.stringify(value)} ${shape}`;
|
|
44
185
|
}
|
|
186
|
+
if (host.startsWith("*.")) {
|
|
187
|
+
const problem = wildcardProblem(host.slice(2));
|
|
188
|
+
if (problem !== null) {
|
|
189
|
+
return `${JSON.stringify(value)} ${problem}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
45
192
|
if ((scheme === "http" || scheme === "ws") && !LOOPBACK.test(host.replace(/^\*\./u, "x."))) {
|
|
46
193
|
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"}`;
|
|
47
194
|
}
|
|
@@ -111,6 +258,7 @@ export function validateConfig(input) {
|
|
|
111
258
|
"embedder",
|
|
112
259
|
"sandbox",
|
|
113
260
|
"permissions",
|
|
261
|
+
"storage",
|
|
114
262
|
], "config");
|
|
115
263
|
if (!MODES.includes(config.mode)) {
|
|
116
264
|
fail("config.mode", `must be one of ${MODES.join(", ")}`);
|
|
@@ -146,10 +294,23 @@ export function validateConfig(input) {
|
|
|
146
294
|
}
|
|
147
295
|
const egress = object(config.egress, "config.egress");
|
|
148
296
|
const kinds = ["connect", "script", "style", "img", "font", "media"];
|
|
149
|
-
only(egress, kinds, "config.egress");
|
|
297
|
+
only(egress, [...kinds, "anyHttps"], "config.egress");
|
|
150
298
|
for (const kind of kinds) {
|
|
151
299
|
sources(egress[kind], `config.egress.${kind}`);
|
|
152
300
|
}
|
|
301
|
+
if (egress.anyHttps !== undefined) {
|
|
302
|
+
if (!Array.isArray(egress.anyHttps)) {
|
|
303
|
+
fail("config.egress.anyHttps", `must be a list of ${PASSIVE_KINDS.join(", ")}`);
|
|
304
|
+
}
|
|
305
|
+
egress.anyHttps.forEach((kind, index) => {
|
|
306
|
+
if (!PASSIVE_KINDS.includes(kind)) {
|
|
307
|
+
fail(`config.egress.anyHttps[${index}]`, `${JSON.stringify(kind)}: only images, media and fonts may load from any https site (P059), never scripts, styles or data`);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
if (config.mode === "url") {
|
|
311
|
+
fail("config.egress.anyHttps", "url mode has no egress control");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
153
314
|
const theme = object(config.theme, "config.theme");
|
|
154
315
|
only(theme, ["variables", "event", "documents"], "config.theme");
|
|
155
316
|
if (theme.documents !== undefined &&
|
|
@@ -258,6 +419,17 @@ export function validateConfig(input) {
|
|
|
258
419
|
}
|
|
259
420
|
});
|
|
260
421
|
}
|
|
422
|
+
if (config.storage !== undefined) {
|
|
423
|
+
const storage = object(config.storage, "config.storage");
|
|
424
|
+
only(storage, ["shared"], "config.storage");
|
|
425
|
+
if (config.isolation !== "domain") {
|
|
426
|
+
fail("config.storage", "only domain isolation keeps storage across displays");
|
|
427
|
+
}
|
|
428
|
+
if (storage.shared !== undefined &&
|
|
429
|
+
(typeof storage.shared !== "string" || !/^[a-z][a-z -]{0,39}$/u.test(storage.shared))) {
|
|
430
|
+
fail("config.storage.shared", 'must be a short lowercase noun ("conversation")');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
261
433
|
if (config.permissions !== undefined) {
|
|
262
434
|
if (config.mode !== "url") {
|
|
263
435
|
fail("config.permissions", "only url mode may be delegated features");
|
|
@@ -156,7 +156,7 @@ export const BEHAVIOR_PROBES = [
|
|
|
156
156
|
{
|
|
157
157
|
id: "11.theme-flip",
|
|
158
158
|
item: 11,
|
|
159
|
-
title: "a theme flip re-themes the running content in place, and tells it once",
|
|
159
|
+
title: "a theme flip re-themes the running content in place, and tells it once; no event without one",
|
|
160
160
|
async run(context) {
|
|
161
161
|
const config = inline({ theme: { event: "app:theme" } });
|
|
162
162
|
const light = resolveTheme(config.theme, "light");
|
|
@@ -169,6 +169,13 @@ export const BEHAVIOR_PROBES = [
|
|
|
169
169
|
const frame = await display.frame();
|
|
170
170
|
await expect(frame.locator("p")).toHaveText("themed");
|
|
171
171
|
expect(await variable(frame, "--color-text-primary")).toBe(light["--color-text-primary"]);
|
|
172
|
+
const flips = () => read(frame, () => window.flips);
|
|
173
|
+
// Mounting, and a host telling it the scheme it already has, are no flip: no event (#3).
|
|
174
|
+
await settle(400);
|
|
175
|
+
expect(await flips(), "no theme event at mount").toEqual([]);
|
|
176
|
+
await display.setTheme("light");
|
|
177
|
+
await settle(400);
|
|
178
|
+
expect(await flips(), "no theme event for the scheme it has").toEqual([]);
|
|
172
179
|
await display.setTheme("dark");
|
|
173
180
|
await expect
|
|
174
181
|
.poll(() => variable(frame, "--color-text-primary"))
|
|
@@ -418,6 +425,12 @@ export const BEHAVIOR_PROBES = [
|
|
|
418
425
|
content: PROMPT_CONTENT,
|
|
419
426
|
});
|
|
420
427
|
await click(display, "#ask");
|
|
428
|
+
// A host that always has a confirm function (a default dialog) asks anyway: what this case
|
|
429
|
+
// proves can't happen there (P058). Its answer is no, so nothing is sent either way.
|
|
430
|
+
const asked = await display.answerNextConfirmation(false, { timeoutMs: 1_500 }).then(() => true, () => false);
|
|
431
|
+
if (asked) {
|
|
432
|
+
context.inapplicable("this host always has a confirm function (a default dialog), so a display without one can't be shown; it asked the person instead");
|
|
433
|
+
}
|
|
421
434
|
expect(await bodyData(display, "result")).toBe("false");
|
|
422
435
|
expect(await refusals(display)).toContain("sendPrompt:no-confirm");
|
|
423
436
|
expect(await display.calls()).toEqual([]);
|
|
@@ -20,6 +20,11 @@ export interface ProbeContext {
|
|
|
20
20
|
requestsTo(origin: string): string[];
|
|
21
21
|
/** Records a measured engine fact in the report. */
|
|
22
22
|
measured(text: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* Ends the probe as inapplicable on this host (reported as skipped, with the reason): what it
|
|
25
|
+
* proves can't be observed here, through no fault of the host's isolation (P058).
|
|
26
|
+
*/
|
|
27
|
+
inapplicable(reason: string): never;
|
|
23
28
|
}
|
|
24
29
|
export interface Probe {
|
|
25
30
|
/** Stable id, `<item>.<channel>`: what `LeakSuiteOptions.skip` names. */
|
package/dist/conformance/kit.js
CHANGED
|
@@ -11,6 +11,17 @@ export function attack(code, markup = "") {
|
|
|
11
11
|
(async () => {
|
|
12
12
|
const r = {};
|
|
13
13
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
// Whether a policy blocks \`origin\` though the frame's own allows it: then it is the page's,
|
|
15
|
+
// which an opaque frame inherits (P058). \`attempt\` loads something from it once more.
|
|
16
|
+
const inheritedBlocks = async (origin, attempt) => {
|
|
17
|
+
let blocked = false;
|
|
18
|
+
const seen = (event) => { if (String(event.blockedURI).indexOf(origin) === 0) blocked = true; };
|
|
19
|
+
document.addEventListener("securitypolicyviolation", seen);
|
|
20
|
+
try { await attempt(); } catch (e) {}
|
|
21
|
+
await wait(300);
|
|
22
|
+
document.removeEventListener("securitypolicyviolation", seen);
|
|
23
|
+
return blocked;
|
|
24
|
+
};
|
|
14
25
|
try {
|
|
15
26
|
${code}
|
|
16
27
|
} catch (error) {
|
|
@@ -255,6 +255,11 @@ const sdkProbe = (preset) => ({
|
|
|
255
255
|
},
|
|
256
256
|
});
|
|
257
257
|
export { attack } from "./kit.js";
|
|
258
|
+
/**
|
|
259
|
+
* Why a positive control can't pass on this host: an opaque (`srcdoc`) frame inherits the
|
|
260
|
+
* embedding page's whole policy, and the page's own closes what the config opens (P058).
|
|
261
|
+
*/
|
|
262
|
+
const INHERITED = (directive, origin) => `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.`;
|
|
258
263
|
export const PROBES = [
|
|
259
264
|
// ── 0: the shell's own wiring, in a real frame ─────────────────────────────────────────────
|
|
260
265
|
{
|
|
@@ -358,10 +363,14 @@ export const PROBES = [
|
|
|
358
363
|
worker.onmessage = (event) => (event.data === "sent" ? resolve() : reject());
|
|
359
364
|
worker.onerror = reject;
|
|
360
365
|
}));
|
|
361
|
-
r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked")
|
|
366
|
+
r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");
|
|
367
|
+
r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => fetch(${js(allowed.origin)} + "/policy").catch(() => {}));`),
|
|
362
368
|
});
|
|
363
369
|
const verdict = await context.result(display, 20_000);
|
|
364
370
|
await settle();
|
|
371
|
+
if (verdict.allowed !== "oracle" && verdict.inherited === true) {
|
|
372
|
+
context.inapplicable(INHERITED("connect-src", allowed.origin));
|
|
373
|
+
}
|
|
365
374
|
expect(verdict.allowed, "the allowed origin (the positive control)").toBe("oracle");
|
|
366
375
|
expect(allowed.requests().map((request) => request.path)).toContain("/allowed");
|
|
367
376
|
expect(canary.requests(), "requests reached the canary").toEqual([]);
|
|
@@ -405,10 +414,14 @@ export const PROBES = [
|
|
|
405
414
|
try { new Worker(${js(`${c}/worker.js`)}); } catch (e) {}
|
|
406
415
|
const ok = document.getElementById("ok");
|
|
407
416
|
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); });
|
|
408
|
-
r.script = typeof globalThis.__portholeOracleScript
|
|
417
|
+
r.script = typeof globalThis.__portholeOracleScript;
|
|
418
|
+
r.inherited = await inheritedBlocks(${js(allowed.origin)}, () => { new Image().src = ${js(`${allowed.origin}/policy.png`)}; });`, markup),
|
|
409
419
|
});
|
|
410
420
|
const verdict = await context.result(display, 20_000);
|
|
411
421
|
await settle();
|
|
422
|
+
if (verdict.allowed !== "loaded" && verdict.inherited === true) {
|
|
423
|
+
context.inapplicable(INHERITED("img-src", allowed.origin));
|
|
424
|
+
}
|
|
412
425
|
expect(verdict.allowed, "the allowed image (the positive control)").toBe("loaded");
|
|
413
426
|
expect(canary.requests(), "resources loaded from the canary").toEqual([]);
|
|
414
427
|
expect(context.requestsTo(canary.origin)).toEqual([]);
|
|
@@ -857,6 +870,10 @@ export function leakTests(host, options = {}) {
|
|
|
857
870
|
},
|
|
858
871
|
requestsTo: (origin) => requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
|
|
859
872
|
measured: (text) => info.annotations.push({ type: "measured", description: text }),
|
|
873
|
+
inapplicable: (why) => {
|
|
874
|
+
info.skip(true, `inapplicable on this host: ${why}`);
|
|
875
|
+
throw new Error(`inapplicable on this host: ${why}`);
|
|
876
|
+
},
|
|
860
877
|
});
|
|
861
878
|
}
|
|
862
879
|
finally {
|
|
@@ -35,8 +35,15 @@ export interface ServedConformanceHost {
|
|
|
35
35
|
* Shows `instance` in `page` as the host does: a fresh ticket from its backend, framed under
|
|
36
36
|
* the config's sandbox, in an embedding page that sends the config's `embedderCsp`. The same page
|
|
37
37
|
* may show several, one after another.
|
|
38
|
+
*
|
|
39
|
+
* For a served shell the suite gives `options.content`, a line of HTML: a host that speaks to
|
|
40
|
+
* the shell (`createHost`, the React binding) sends it as the display's content, since a shell
|
|
41
|
+
* draws nothing (and reports no height) until it has content, and the cases click inside it. A
|
|
42
|
+
* host whose frame has a height of its own may ignore it.
|
|
38
43
|
*/
|
|
39
|
-
show(page: Page, instance: InstanceRef
|
|
44
|
+
show(page: Page, instance: InstanceRef, options?: {
|
|
45
|
+
readonly content?: string;
|
|
46
|
+
}): Promise<ServedDisplay>;
|
|
40
47
|
/** A fresh ticketed location for `instance`, as the host's backend issues it. */
|
|
41
48
|
locate(instance: InstanceRef): Promise<FrameLocation>;
|
|
42
49
|
/** Navigates `page` to a page on an origin that is not one of the host's, framing `url`. */
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { expect } from "@playwright/test";
|
|
15
15
|
import { MCP_APPS_METHODS } from "../protocol/mcp-apps.js";
|
|
16
16
|
const SETTLE_MS = 800;
|
|
17
|
+
/** What a served shell is given to draw (`ServedConformanceHost.show`'s `content`). */
|
|
18
|
+
const SHELL_CONTENT = '<p id="porthole-probe-content">Porthole conformance probe</p>';
|
|
17
19
|
/** What the frame can see of its host: everything here must be closed to it. */
|
|
18
20
|
async function reachIntoHost(frame) {
|
|
19
21
|
return frame.evaluate(() => {
|
|
@@ -109,21 +111,23 @@ export function servedTests(host, options = {}) {
|
|
|
109
111
|
["domain", (scope) => host.shell(scope)],
|
|
110
112
|
["url", (scope) => host.app(scope)],
|
|
111
113
|
];
|
|
114
|
+
// A served shell draws (and has a height to click in) once its host gives it content.
|
|
115
|
+
const showMode = (page, mode, instance) => host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
|
|
112
116
|
for (const [mode, instance] of modes) {
|
|
113
117
|
define(`served.${mode}.origin`, "content runs on its own origin, never the host's", async ({ page }) => {
|
|
114
|
-
const display = await
|
|
118
|
+
const display = await showMode(page, mode, instance(`${mode}-origin`));
|
|
115
119
|
const frame = await display.frame();
|
|
116
120
|
const origin = await frame.evaluate(() => window.origin);
|
|
117
121
|
expect(origin).toBe(display.origin);
|
|
118
122
|
expect(origin).not.toBe(new URL(page.url()).origin);
|
|
119
123
|
});
|
|
120
124
|
define(`served.${mode}.no-reach`, "content can't reach the host's DOM, storage, cookies or URL", async ({ page }) => {
|
|
121
|
-
const display = await
|
|
125
|
+
const display = await showMode(page, mode, instance(`${mode}-reach`));
|
|
122
126
|
await plantHostSecrets(page);
|
|
123
127
|
expect(await reachIntoHost(await display.frame())).toEqual(CLOSED);
|
|
124
128
|
});
|
|
125
129
|
define(`served.${mode}.no-top-navigation`, "content can't navigate the host's page, even after a click", async ({ page }) => {
|
|
126
|
-
const display = await
|
|
130
|
+
const display = await showMode(page, mode, instance(`${mode}-top`));
|
|
127
131
|
const frame = await display.frame();
|
|
128
132
|
const before = page.url();
|
|
129
133
|
const target = await host.canary(`${mode}-top`);
|
|
@@ -153,7 +157,7 @@ export function servedTests(host, options = {}) {
|
|
|
153
157
|
expect(await host.canaryHits(`${mode}-top`)).toBe(0);
|
|
154
158
|
});
|
|
155
159
|
define(`served.${mode}.no-popups`, "content can't open a window, even after a click", async ({ page }) => {
|
|
156
|
-
const display = await
|
|
160
|
+
const display = await showMode(page, mode, instance(`${mode}-popup`));
|
|
157
161
|
const frame = await display.frame();
|
|
158
162
|
const target = await host.canary(`${mode}-popup`);
|
|
159
163
|
const opened = [];
|
|
@@ -186,13 +190,13 @@ export function servedTests(host, options = {}) {
|
|
|
186
190
|
});
|
|
187
191
|
}
|
|
188
192
|
define("served.domain.storage-scope", "storage persists within a scope and is never shared across scopes", async ({ page }) => {
|
|
189
|
-
const first = await host.show(page, host.shell("scope-a"));
|
|
193
|
+
const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
|
|
190
194
|
await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
|
|
191
195
|
await first.destroy();
|
|
192
|
-
const again = await host.show(page, host.shell("scope-a"));
|
|
196
|
+
const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
|
|
193
197
|
expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
|
|
194
198
|
expect(again.origin).toBe(first.origin);
|
|
195
|
-
const other = await host.show(page, host.shell("scope-b"));
|
|
199
|
+
const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
|
|
196
200
|
expect(other.origin).not.toBe(first.origin);
|
|
197
201
|
expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
|
|
198
202
|
});
|
|
@@ -247,7 +251,7 @@ export function servedTests(host, options = {}) {
|
|
|
247
251
|
}
|
|
248
252
|
});
|
|
249
253
|
define("served.domain.egress", "the CSP in the document's headers closes egress", async ({ page }) => {
|
|
250
|
-
const display = await host.show(page, host.shell("egress"));
|
|
254
|
+
const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
|
|
251
255
|
const frame = await display.frame();
|
|
252
256
|
const fetched = await frame.evaluate(async (url) => {
|
|
253
257
|
try {
|
package/dist/guide/guide.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { BUILT_IN_TOPICS } from "../config/validate.ts";
|
|
|
10
10
|
export type BuiltInTopic = (typeof BUILT_IN_TOPICS)[number];
|
|
11
11
|
/** A topic name: a built-in one, or one of the config's own (`guide.topics`). */
|
|
12
12
|
export type GuideTopic = BuiltInTopic | (string & {});
|
|
13
|
+
/** What the manual calls `https:` (any https site), where a config opens passive kinds to it. */
|
|
14
|
+
export declare const ANY_HTTPS_SITE = "any https site";
|
|
13
15
|
/** The manual for `topics`, in the order asked (all built-in topics by default). */
|
|
14
16
|
export declare function generateGuide(config: PortholeConfig, topics?: readonly GuideTopic[]): string;
|
|
15
17
|
/** The description of the host's show tool, for the agent's tool list. */
|
package/dist/guide/guide.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MAX_COPY_CHARS, capabilitySpec, } from "../config/capabilities.js";
|
|
2
2
|
import { BUILT_IN_TOPICS } from "../config/validate.js";
|
|
3
|
-
import { declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
|
|
3
|
+
import { anyHttps, declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
|
|
4
4
|
import { shellPlan } from "../shell/plan.js";
|
|
5
5
|
import { themeDefault } from "../theme/variables.js";
|
|
6
6
|
import { GUIDE_EXAMPLES } from "./examples.js";
|
|
@@ -36,11 +36,18 @@ const KIND_WORDS = {
|
|
|
36
36
|
media: "audio and video",
|
|
37
37
|
connect: "data (fetch, WebSocket)",
|
|
38
38
|
};
|
|
39
|
+
/** What the manual calls `https:` (any https site), where a config opens passive kinds to it. */
|
|
40
|
+
export const ANY_HTTPS_SITE = "any https site";
|
|
41
|
+
/** The sources content may load `kind` from, as the manual names them: origins, any https site. */
|
|
42
|
+
const sourcesFor = (config, kind) => [
|
|
43
|
+
...(anyHttps(config, kind) ? [ANY_HTTPS_SITE] : []),
|
|
44
|
+
...egressOrigins(config, kind),
|
|
45
|
+
];
|
|
39
46
|
/** What content may load, in prose, from the same origins the CSP lists. */
|
|
40
47
|
function network(config) {
|
|
41
48
|
const kinds = ["script", "style", "font", "img", "media", "connect"];
|
|
42
49
|
const open = kinds
|
|
43
|
-
.map((kind) => [kind,
|
|
50
|
+
.map((kind) => [kind, sourcesFor(config, kind)])
|
|
44
51
|
.filter(([, origins]) => origins.length > 0);
|
|
45
52
|
if (open.length === 0) {
|
|
46
53
|
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.";
|
|
@@ -206,7 +213,9 @@ function interactive(c) {
|
|
|
206
213
|
.filter((entry) => entry !== "");
|
|
207
214
|
const state = config.isolation === "opaque"
|
|
208
215
|
? `- 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.`
|
|
209
|
-
:
|
|
216
|
+
: config.storage?.shared === undefined
|
|
217
|
+
? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
|
|
218
|
+
: `- 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.`;
|
|
210
219
|
const none = entries.length === 0
|
|
211
220
|
? `- It can only draw: it has no way to send anything to you or the app.`
|
|
212
221
|
: null;
|
|
@@ -246,9 +255,17 @@ export function generateToolDescription(config) {
|
|
|
246
255
|
const origins = [
|
|
247
256
|
...new Set(["script", "style", "font", "img", "media", "connect"].flatMap((kind) => egressOrigins(config, kind))),
|
|
248
257
|
];
|
|
249
|
-
const
|
|
258
|
+
const passive = ["img", "media", "font"]
|
|
259
|
+
.filter((kind) => anyHttps(config, kind))
|
|
260
|
+
.map((kind) => KIND_WORDS[kind]);
|
|
261
|
+
const fromAnywhere = passive.length === 0
|
|
262
|
+
? ""
|
|
263
|
+
: `${passive.length === 1 ? passive[0] : `${passive.slice(0, -1).join(", ")} and ${passive.at(-1)}`} from ${ANY_HTTPS_SITE}`;
|
|
264
|
+
const net = origins.length === 0 && fromAnywhere === ""
|
|
250
265
|
? "with no network (nothing loads from outside)"
|
|
251
|
-
:
|
|
266
|
+
: origins.length === 0
|
|
267
|
+
? `loading only ${fromAnywhere}`
|
|
268
|
+
: `loading only from ${origins.join(", ")}${fromAnywhere === "" ? "" : `, and ${fromAnywhere}`}`;
|
|
252
269
|
const globals = declaredCapabilities(config).flatMap((name) => {
|
|
253
270
|
const spec = capabilitySpec(name);
|
|
254
271
|
if (spec.global === undefined || !c.global(spec.global)) {
|