@botiverse/porthole 0.1.4 → 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 +4 -2
- package/dist/compile/policy.js +9 -3
- package/dist/config/schema.d.ts +9 -0
- package/dist/config/schema.js +2 -0
- package/dist/config/validate.js +162 -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/guide/guide.d.ts +2 -0
- package/dist/guide/guide.js +19 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/presets/presets.js +4 -0
- package/dist/react/frame.js +11 -4
- package/dist/server/server.js +4 -0
- package/dist/shell/runtime.js +14 -1
- package/package.json +1 -1
- package/src/compile/policy.ts +12 -5
- package/src/config/schema.ts +11 -0
- package/src/config/validate.ts +178 -2
- package/src/conformance/behavior.ts +20 -1
- package/src/conformance/kit.ts +16 -0
- package/src/conformance/probes.ts +21 -2
- package/src/guide/guide.ts +22 -3
- package/src/index.ts +2 -0
- package/src/presets/presets.ts +4 -0
- package/src/react/frame.tsx +11 -4
- package/src/server/server.ts +4 -0
- package/src/shell/runtime.ts +14 -1
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. */
|
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:"],
|
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
|
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
|
}
|
|
@@ -147,10 +294,23 @@ export function validateConfig(input) {
|
|
|
147
294
|
}
|
|
148
295
|
const egress = object(config.egress, "config.egress");
|
|
149
296
|
const kinds = ["connect", "script", "style", "img", "font", "media"];
|
|
150
|
-
only(egress, kinds, "config.egress");
|
|
297
|
+
only(egress, [...kinds, "anyHttps"], "config.egress");
|
|
151
298
|
for (const kind of kinds) {
|
|
152
299
|
sources(egress[kind], `config.egress.${kind}`);
|
|
153
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
|
+
}
|
|
154
314
|
const theme = object(config.theme, "config.theme");
|
|
155
315
|
only(theme, ["variables", "event", "documents"], "config.theme");
|
|
156
316
|
if (theme.documents !== undefined &&
|
|
@@ -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 {
|
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.";
|
|
@@ -248,9 +255,17 @@ export function generateToolDescription(config) {
|
|
|
248
255
|
const origins = [
|
|
249
256
|
...new Set(["script", "style", "font", "img", "media", "connect"].flatMap((kind) => egressOrigins(config, kind))),
|
|
250
257
|
];
|
|
251
|
-
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 === ""
|
|
252
265
|
? "with no network (nothing loads from outside)"
|
|
253
|
-
:
|
|
266
|
+
: origins.length === 0
|
|
267
|
+
? `loading only ${fromAnywhere}`
|
|
268
|
+
: `loading only from ${origins.join(", ")}${fromAnywhere === "" ? "" : `, and ${fromAnywhere}`}`;
|
|
254
269
|
const globals = declaredCapabilities(config).flatMap((name) => {
|
|
255
270
|
const spec = capabilitySpec(name);
|
|
256
271
|
if (spec.global === undefined || !c.global(spec.global)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
|
|
6
6
|
export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, StorageConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
|
|
7
|
+
export { PASSIVE_KINDS, type PassiveKind } from "./config/schema.ts";
|
|
7
8
|
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
|
|
8
9
|
export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type UrlPermission, type UrlSandboxExtra, } from "./config/schema.ts";
|
|
9
10
|
export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
|
|
10
11
|
export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
|
|
11
12
|
export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
|
|
12
13
|
export { compile, type CompiledConfig } from "./compile/compile.ts";
|
|
13
|
-
export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, type CspDirective, type EgressKind, } from "./compile/policy.ts";
|
|
14
|
+
export { allEgressOrigins, anyHttps, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, type CspDirective, type EgressKind, } from "./compile/policy.ts";
|
|
14
15
|
export { shellPlan, type ShellPlan } from "./shell/plan.ts";
|
|
15
16
|
export type { ShellDocumentOptions } from "./shell/document.ts";
|
|
16
17
|
export { generateGuide, generateToolDescription, type BuiltInTopic, type GuideTopic, } from "./guide/guide.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
// The config and its vocabulary.
|
|
6
6
|
export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
|
|
7
|
+
export { PASSIVE_KINDS } from "./config/schema.js";
|
|
7
8
|
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
|
|
8
9
|
export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./config/schema.js";
|
|
9
10
|
export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
|
|
@@ -12,7 +13,7 @@ export { FRAME_STATUS_METHOD } from "./isolation/types.js";
|
|
|
12
13
|
export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
|
|
13
14
|
// The compiler.
|
|
14
15
|
export { compile } from "./compile/compile.js";
|
|
15
|
-
export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, } from "./compile/policy.js";
|
|
16
|
+
export { allEgressOrigins, anyHttps, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, } from "./compile/policy.js";
|
|
16
17
|
export { shellPlan } from "./shell/plan.js";
|
|
17
18
|
export { generateGuide, generateToolDescription, } from "./guide/guide.js";
|
|
18
19
|
export { GUIDE_EXAMPLES } from "./guide/examples.js";
|
package/dist/presets/presets.js
CHANGED
|
@@ -60,6 +60,10 @@ const mergeEgress = (base, extra) => {
|
|
|
60
60
|
merged[kind] = origins;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
const passive = [...new Set([...(base.anyHttps ?? []), ...(extra?.anyHttps ?? [])])];
|
|
64
|
+
if (passive.length > 0) {
|
|
65
|
+
merged.anyHttps = passive;
|
|
66
|
+
}
|
|
63
67
|
return merged;
|
|
64
68
|
};
|
|
65
69
|
/** The default variables, with the host's changes and additions, less those it dropped (null). */
|
package/dist/react/frame.js
CHANGED
|
@@ -332,15 +332,22 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
332
332
|
attempt(() => display.annotations.focus(id), latest());
|
|
333
333
|
}
|
|
334
334
|
}, [annotates, focusToken]);
|
|
335
|
+
// A flip or a restyle after the display was made: its first scheme and values went with it.
|
|
336
|
+
const [mountScheme] = useState(scheme);
|
|
337
|
+
const flipped = useRef(false);
|
|
335
338
|
useEffect(() => {
|
|
336
|
-
|
|
337
|
-
|
|
339
|
+
if (flipped.current || scheme !== mountScheme) {
|
|
340
|
+
flipped.current = true;
|
|
341
|
+
handle.current?.setTheme(scheme);
|
|
342
|
+
}
|
|
343
|
+
}, [scheme, mountScheme]);
|
|
338
344
|
const { themeKey } = frame;
|
|
345
|
+
const [mountThemeKey] = useState(themeKey);
|
|
339
346
|
useEffect(() => {
|
|
340
|
-
if (themeKey !== undefined) {
|
|
347
|
+
if (themeKey !== undefined && themeKey !== mountThemeKey) {
|
|
341
348
|
handle.current?.refreshTheme();
|
|
342
349
|
}
|
|
343
|
-
}, [themeKey]);
|
|
350
|
+
}, [themeKey, mountThemeKey]);
|
|
344
351
|
return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
|
|
345
352
|
position: "relative",
|
|
346
353
|
// A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
|
package/dist/server/server.js
CHANGED
|
@@ -55,6 +55,10 @@ function mergeEgress(base, extra) {
|
|
|
55
55
|
merged[kind] = origins;
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
// What the host's config opens to any https site stays; a view can't ask for it (P059).
|
|
59
|
+
if (base.anyHttps !== undefined) {
|
|
60
|
+
merged.anyHttps = base.anyHttps;
|
|
61
|
+
}
|
|
58
62
|
return merged;
|
|
59
63
|
}
|
|
60
64
|
/** `instance` with only the fields Porthole reads, each checked: what a ticket and cookie carry. */
|
package/dist/shell/runtime.js
CHANGED
|
@@ -607,12 +607,16 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
607
607
|
const notify = (method, params) => send({ method, params });
|
|
608
608
|
|
|
609
609
|
const themeNames = new Set(plan.themeVariables);
|
|
610
|
+
// What the content was last told of (its scheme and values): the theme it starts with is no
|
|
611
|
+
// change, and a host context that changes neither (a host re-sending it) is none either.
|
|
612
|
+
let announced = null;
|
|
610
613
|
const applyTheme = (context) => {
|
|
611
614
|
if (context === null || typeof context !== "object") {
|
|
612
615
|
return;
|
|
613
616
|
}
|
|
614
617
|
// The spec's variables, and Porthole's own beside them (its own-prefixed field).
|
|
615
618
|
const own = context["porthole/styles"];
|
|
619
|
+
const applied = [];
|
|
616
620
|
for (const variables of [context.styles && context.styles.variables, own && own.variables]) {
|
|
617
621
|
if (variables === null || typeof variables !== "object") {
|
|
618
622
|
continue;
|
|
@@ -621,12 +625,21 @@ export const SHELL_RUNTIME = String.raw `(() => {
|
|
|
621
625
|
const value = variables[name];
|
|
622
626
|
if (themeNames.has(name) && typeof value === "string") {
|
|
623
627
|
document.documentElement.style.setProperty(name, value);
|
|
628
|
+
applied.push(name + ":" + value);
|
|
624
629
|
}
|
|
625
630
|
}
|
|
626
631
|
}
|
|
627
632
|
lastContext = context;
|
|
628
633
|
schemeFollows();
|
|
629
|
-
|
|
634
|
+
const now = JSON.stringify([context.theme, applied.sort()]);
|
|
635
|
+
if (announced === null) {
|
|
636
|
+
announced = now;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (now !== announced) {
|
|
640
|
+
announced = now;
|
|
641
|
+
window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
|
|
642
|
+
}
|
|
630
643
|
};
|
|
631
644
|
|
|
632
645
|
// No size before there is something to size: an empty shell's height would make the host
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botiverse/porthole",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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": [
|
package/src/compile/policy.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CAPABILITIES, type CapabilityName, type Gate } from "../config/capabilities.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The frame's policies, derived from the config: its CSP (in the shell's `<meta>` for opaque
|
|
@@ -10,7 +10,12 @@ import type { EgressConfig, PortholeConfig } from "../config/schema.ts";
|
|
|
10
10
|
/** A CSP directive: its name, then its sources. */
|
|
11
11
|
export type CspDirective = readonly [name: string, ...sources: string[]];
|
|
12
12
|
|
|
13
|
-
export type EgressKind =
|
|
13
|
+
export type EgressKind = "connect" | "script" | "style" | "img" | "font" | "media";
|
|
14
|
+
|
|
15
|
+
/** Whether `kind` loads from any https site (`EgressConfig.anyHttps`, P059): passive kinds only. */
|
|
16
|
+
export function anyHttps(config: PortholeConfig, kind: EgressKind): boolean {
|
|
17
|
+
return (config.egress.anyHttps ?? []).some((each) => each === kind);
|
|
18
|
+
}
|
|
14
19
|
|
|
15
20
|
/** The capabilities a config declares, in vocabulary order. */
|
|
16
21
|
export function declaredCapabilities(config: PortholeConfig): CapabilityName[] {
|
|
@@ -56,13 +61,15 @@ export function frameCspDirectives(config: PortholeConfig): CspDirective[] {
|
|
|
56
61
|
return [];
|
|
57
62
|
}
|
|
58
63
|
const origins = (kind: EgressKind) => egressOrigins(config, kind);
|
|
64
|
+
// Any https site, for passive kinds a host opened so (P059); never for what runs or talks.
|
|
65
|
+
const https = (kind: "img" | "media" | "font") => (anyHttps(config, kind) ? ["https:"] : []);
|
|
59
66
|
return [
|
|
60
67
|
["default-src", "'none'"],
|
|
61
68
|
["script-src", "'unsafe-inline'", "'unsafe-eval'", "blob:", ...origins("script")],
|
|
62
69
|
["style-src", "'unsafe-inline'", ...origins("style")],
|
|
63
|
-
["font-src", "data:", ...origins("font")],
|
|
64
|
-
["img-src", "data:", "blob:", ...origins("img")],
|
|
65
|
-
["media-src", "data:", "blob:", ...origins("media")],
|
|
70
|
+
["font-src", "data:", ...https("font"), ...origins("font")],
|
|
71
|
+
["img-src", "data:", "blob:", ...https("img"), ...origins("img")],
|
|
72
|
+
["media-src", "data:", "blob:", ...https("media"), ...origins("media")],
|
|
66
73
|
["connect-src", ...list(origins("connect"))],
|
|
67
74
|
["worker-src", "blob:"],
|
|
68
75
|
["child-src", "blob:"],
|
package/src/config/schema.ts
CHANGED
|
@@ -61,8 +61,19 @@ export interface EgressConfig {
|
|
|
61
61
|
readonly img?: readonly string[];
|
|
62
62
|
readonly font?: readonly string[];
|
|
63
63
|
readonly media?: readonly string[];
|
|
64
|
+
/**
|
|
65
|
+
* Passive content that may load from any https site (P059): images, audio and video, fonts, for
|
|
66
|
+
* pages that hotlink them. Never scripts, styles or data. It opens a channel out: a viewer's
|
|
67
|
+
* address and a visit signal reach any site, and data can leave in the URL.
|
|
68
|
+
*/
|
|
69
|
+
readonly anyHttps?: readonly PassiveKind[];
|
|
64
70
|
}
|
|
65
71
|
|
|
72
|
+
/** What `EgressConfig.anyHttps` may open: content that runs nothing (P059). */
|
|
73
|
+
export const PASSIVE_KINDS = ["img", "media", "font"] as const;
|
|
74
|
+
|
|
75
|
+
export type PassiveKind = (typeof PASSIVE_KINDS)[number];
|
|
76
|
+
|
|
66
77
|
export interface ThemeConfig {
|
|
67
78
|
/**
|
|
68
79
|
* Every CSS variable the content may read, with its default per scheme. The shell injects
|
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
|
}
|
|
@@ -174,10 +334,26 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
174
334
|
|
|
175
335
|
const egress = object(config.egress, "config.egress");
|
|
176
336
|
const kinds = ["connect", "script", "style", "img", "font", "media"];
|
|
177
|
-
only(egress, kinds, "config.egress");
|
|
337
|
+
only(egress, [...kinds, "anyHttps"], "config.egress");
|
|
178
338
|
for (const kind of kinds) {
|
|
179
339
|
sources(egress[kind], `config.egress.${kind}`);
|
|
180
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
|
+
}
|
|
181
357
|
|
|
182
358
|
const theme = object(config.theme, "config.theme");
|
|
183
359
|
only(theme, ["variables", "event", "documents"], "config.theme");
|
|
@@ -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()));
|
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.";
|
|
@@ -419,10 +429,19 @@ export function generateToolDescription(config: PortholeConfig): string {
|
|
|
419
429
|
),
|
|
420
430
|
),
|
|
421
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}`;
|
|
422
439
|
const net =
|
|
423
|
-
origins.length === 0
|
|
440
|
+
origins.length === 0 && fromAnywhere === ""
|
|
424
441
|
? "with no network (nothing loads from outside)"
|
|
425
|
-
:
|
|
442
|
+
: origins.length === 0
|
|
443
|
+
? `loading only ${fromAnywhere}`
|
|
444
|
+
: `loading only from ${origins.join(", ")}${fromAnywhere === "" ? "" : `, and ${fromAnywhere}`}`;
|
|
426
445
|
const globals = declaredCapabilities(config).flatMap((name) => {
|
|
427
446
|
const spec = capabilitySpec(name);
|
|
428
447
|
if (spec.global === undefined || !c.global(spec.global)) {
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export type {
|
|
|
33
33
|
ThemeConfig,
|
|
34
34
|
ThemeValue,
|
|
35
35
|
} from "./config/schema.ts";
|
|
36
|
+
export { PASSIVE_KINDS, type PassiveKind } from "./config/schema.ts";
|
|
36
37
|
export {
|
|
37
38
|
BUILT_IN_TOPICS,
|
|
38
39
|
ConfigError,
|
|
@@ -62,6 +63,7 @@ export {
|
|
|
62
63
|
export { compile, type CompiledConfig } from "./compile/compile.ts";
|
|
63
64
|
export {
|
|
64
65
|
allEgressOrigins,
|
|
66
|
+
anyHttps,
|
|
65
67
|
declaredCapabilities,
|
|
66
68
|
effectiveGates,
|
|
67
69
|
egressOrigins,
|
package/src/presets/presets.ts
CHANGED
|
@@ -126,6 +126,10 @@ const mergeEgress = (base: EgressConfig, extra: EgressConfig | undefined): Egres
|
|
|
126
126
|
merged[kind] = origins;
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
+
const passive = [...new Set([...(base.anyHttps ?? []), ...(extra?.anyHttps ?? [])])];
|
|
130
|
+
if (passive.length > 0) {
|
|
131
|
+
merged.anyHttps = passive;
|
|
132
|
+
}
|
|
129
133
|
return merged;
|
|
130
134
|
};
|
|
131
135
|
|
package/src/react/frame.tsx
CHANGED
|
@@ -622,15 +622,22 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
622
622
|
attempt(() => display.annotations.focus(id), latest());
|
|
623
623
|
}
|
|
624
624
|
}, [annotates, focusToken]);
|
|
625
|
+
// A flip or a restyle after the display was made: its first scheme and values went with it.
|
|
626
|
+
const [mountScheme] = useState(scheme);
|
|
627
|
+
const flipped = useRef(false);
|
|
625
628
|
useEffect(() => {
|
|
626
|
-
|
|
627
|
-
|
|
629
|
+
if (flipped.current || scheme !== mountScheme) {
|
|
630
|
+
flipped.current = true;
|
|
631
|
+
handle.current?.setTheme(scheme);
|
|
632
|
+
}
|
|
633
|
+
}, [scheme, mountScheme]);
|
|
628
634
|
const { themeKey } = frame;
|
|
635
|
+
const [mountThemeKey] = useState(themeKey);
|
|
629
636
|
useEffect(() => {
|
|
630
|
-
if (themeKey !== undefined) {
|
|
637
|
+
if (themeKey !== undefined && themeKey !== mountThemeKey) {
|
|
631
638
|
handle.current?.refreshTheme();
|
|
632
639
|
}
|
|
633
|
-
}, [themeKey]);
|
|
640
|
+
}, [themeKey, mountThemeKey]);
|
|
634
641
|
|
|
635
642
|
return (
|
|
636
643
|
<div
|
package/src/server/server.ts
CHANGED
|
@@ -92,6 +92,10 @@ function mergeEgress(base: EgressConfig, extra: EgressConfig | undefined): Egres
|
|
|
92
92
|
merged[kind] = origins;
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
// What the host's config opens to any https site stays; a view can't ask for it (P059).
|
|
96
|
+
if (base.anyHttps !== undefined) {
|
|
97
|
+
merged.anyHttps = base.anyHttps;
|
|
98
|
+
}
|
|
95
99
|
return merged;
|
|
96
100
|
}
|
|
97
101
|
|
package/src/shell/runtime.ts
CHANGED
|
@@ -607,12 +607,16 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
607
607
|
const notify = (method, params) => send({ method, params });
|
|
608
608
|
|
|
609
609
|
const themeNames = new Set(plan.themeVariables);
|
|
610
|
+
// What the content was last told of (its scheme and values): the theme it starts with is no
|
|
611
|
+
// change, and a host context that changes neither (a host re-sending it) is none either.
|
|
612
|
+
let announced = null;
|
|
610
613
|
const applyTheme = (context) => {
|
|
611
614
|
if (context === null || typeof context !== "object") {
|
|
612
615
|
return;
|
|
613
616
|
}
|
|
614
617
|
// The spec's variables, and Porthole's own beside them (its own-prefixed field).
|
|
615
618
|
const own = context["porthole/styles"];
|
|
619
|
+
const applied = [];
|
|
616
620
|
for (const variables of [context.styles && context.styles.variables, own && own.variables]) {
|
|
617
621
|
if (variables === null || typeof variables !== "object") {
|
|
618
622
|
continue;
|
|
@@ -621,12 +625,21 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
621
625
|
const value = variables[name];
|
|
622
626
|
if (themeNames.has(name) && typeof value === "string") {
|
|
623
627
|
document.documentElement.style.setProperty(name, value);
|
|
628
|
+
applied.push(name + ":" + value);
|
|
624
629
|
}
|
|
625
630
|
}
|
|
626
631
|
}
|
|
627
632
|
lastContext = context;
|
|
628
633
|
schemeFollows();
|
|
629
|
-
|
|
634
|
+
const now = JSON.stringify([context.theme, applied.sort()]);
|
|
635
|
+
if (announced === null) {
|
|
636
|
+
announced = now;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (now !== announced) {
|
|
640
|
+
announced = now;
|
|
641
|
+
window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
|
|
642
|
+
}
|
|
630
643
|
};
|
|
631
644
|
|
|
632
645
|
// No size before there is something to size: an empty shell's height would make the host
|