@botiverse/porthole 0.1.6 → 0.1.7
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/config/schema.d.ts +26 -3
- package/dist/config/validate.js +29 -4
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/kit.d.ts +2 -1
- package/dist/conformance/limits.d.ts +10 -0
- package/dist/conformance/limits.js +508 -0
- package/dist/conformance/probes.js +2 -0
- package/dist/conformance/served.js +13 -0
- package/dist/guide/guide.js +34 -37
- package/dist/guide/limits.d.ts +24 -0
- package/dist/guide/limits.js +152 -0
- package/dist/host/create-host.js +9 -3
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +4 -0
- package/dist/react/frame.js +6 -2
- package/dist/shell/plan.d.ts +1 -1
- package/dist/shell/runtime.js +1 -1
- package/package.json +1 -1
- package/src/config/schema.ts +26 -3
- package/src/config/validate.ts +44 -4
- package/src/conformance/index.ts +2 -0
- package/src/conformance/kit.ts +2 -1
- package/src/conformance/limits.ts +560 -0
- package/src/conformance/probes.ts +2 -0
- package/src/conformance/served.ts +18 -0
- package/src/guide/guide.ts +40 -45
- package/src/guide/limits.ts +224 -0
- package/src/host/create-host.ts +9 -3
- package/src/presets/presets.ts +7 -1
- package/src/react/frame.tsx +6 -2
- package/src/shell/plan.ts +1 -1
- package/src/shell/runtime.ts +1 -1
package/src/conformance/index.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface LaunchSpec {
|
|
|
40
40
|
readonly scheme?: ColorScheme;
|
|
41
41
|
/** The frame's width, px. */
|
|
42
42
|
readonly widthPx?: number;
|
|
43
|
+
/** The height of the box the frame sits in, px: for a layout that fills it (`layout.kind`). */
|
|
44
|
+
readonly heightPx?: number;
|
|
43
45
|
/** The host's live values for theme variables, per scheme (as `HostOptions.theme` answers). */
|
|
44
46
|
readonly hostTheme?: {
|
|
45
47
|
readonly light?: Readonly<Record<string, string>>;
|
package/src/conformance/kit.ts
CHANGED
|
@@ -34,7 +34,8 @@ export interface Probe {
|
|
|
34
34
|
readonly id: string;
|
|
35
35
|
/**
|
|
36
36
|
* The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
|
|
37
|
-
* behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard
|
|
37
|
+
* behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard, 14
|
|
38
|
+
* for the manual's claims about the sandbox (`LIMIT_CHECKS`).
|
|
38
39
|
*/
|
|
39
40
|
readonly item: number;
|
|
40
41
|
readonly title: string;
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { expect, type Page } from "@playwright/test";
|
|
2
|
+
import type { LimitId } from "../guide/limits.ts";
|
|
3
|
+
import type { LaunchedDisplay } from "./index.ts";
|
|
4
|
+
import { presets } from "../presets/presets.ts";
|
|
5
|
+
import { resolveTheme } from "../theme/variables.ts";
|
|
6
|
+
import { attack, inline, js, type Probe } from "./kit.ts";
|
|
7
|
+
import { settle } from "./oracles.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The manual's claims about the sandbox, each proven in a real browser (item 14, P064). Every
|
|
11
|
+
* limit the manual can state (`manualLimits`) names the checks that prove it here (`LIMIT_CHECKS`);
|
|
12
|
+
* a unit test fails if it names none, so a new line can't go untested. Where engines differ, the
|
|
13
|
+
* check holds the safe common behavior the manual states and records what each engine did.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A real click in the display (the user activation a person's click gives). */
|
|
17
|
+
async function click(display: LaunchedDisplay, selector: string): Promise<void> {
|
|
18
|
+
await (await display.frame()).locator(selector).click();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the content recorded in `window.r`, once `done` is set. */
|
|
22
|
+
async function recorded(
|
|
23
|
+
display: LaunchedDisplay,
|
|
24
|
+
timeoutMs = 10_000,
|
|
25
|
+
): Promise<Record<string, unknown>> {
|
|
26
|
+
const frame = await display.frame();
|
|
27
|
+
await expect
|
|
28
|
+
.poll(
|
|
29
|
+
() =>
|
|
30
|
+
frame
|
|
31
|
+
.evaluate(() => (window as unknown as { r?: { done?: boolean } }).r?.done === true)
|
|
32
|
+
.catch(() => false),
|
|
33
|
+
{ timeout: timeoutMs },
|
|
34
|
+
)
|
|
35
|
+
.toBe(true);
|
|
36
|
+
return frame.evaluate(() => (window as unknown as { r: Record<string, unknown> }).r);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Counts what the page shows the person beyond the frame: dialogs, downloads, new windows. */
|
|
40
|
+
function watch(page: Page): { dialogs: string[]; downloads: string[]; windows: string[] } {
|
|
41
|
+
const seen = { dialogs: [] as string[], downloads: [] as string[], windows: [] as string[] };
|
|
42
|
+
page.on("dialog", (dialog) => {
|
|
43
|
+
seen.dialogs.push(dialog.type());
|
|
44
|
+
void dialog.dismiss().catch(() => {});
|
|
45
|
+
});
|
|
46
|
+
page.on("download", (download) => seen.downloads.push(download.suggestedFilename()));
|
|
47
|
+
page.context().on("page", (opened) => seen.windows.push(opened.url()));
|
|
48
|
+
return seen;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A WAV of silence, small enough to inline. */
|
|
52
|
+
const SILENCE =
|
|
53
|
+
"data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YSADAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==";
|
|
54
|
+
|
|
55
|
+
const RECORDER = "<script>window.r = {};</script>";
|
|
56
|
+
|
|
57
|
+
export const LIMIT_PROBES: readonly Probe[] = [
|
|
58
|
+
{
|
|
59
|
+
id: "14.fill",
|
|
60
|
+
item: 14,
|
|
61
|
+
title:
|
|
62
|
+
"a layout that fills: the frame is its box's height, the page scrolls itself, position: fixed stays put",
|
|
63
|
+
async run(context) {
|
|
64
|
+
const display = await context.launch({
|
|
65
|
+
config: presets.artifact({ layout: { kind: "fill" } }),
|
|
66
|
+
heightPx: 400,
|
|
67
|
+
content: `<!doctype html><html><head><title>Tall</title></head><body style="margin:0">
|
|
68
|
+
<header id="bar" style="position:fixed;top:0;left:0;right:0;height:30px">bar</header>
|
|
69
|
+
<div style="height:2000px">tall</div></body></html>`,
|
|
70
|
+
});
|
|
71
|
+
const frame = await display.frame();
|
|
72
|
+
await expect(frame.locator("#bar")).toBeAttached();
|
|
73
|
+
await expect.poll(async () => Math.round((await display.box()).height)).toBe(400);
|
|
74
|
+
await frame.evaluate(() => window.scrollTo(0, 600));
|
|
75
|
+
const seen = await frame.evaluate(() => ({
|
|
76
|
+
scrolled: window.scrollY,
|
|
77
|
+
scrolls: document.documentElement.scrollHeight > window.innerHeight,
|
|
78
|
+
barTop: Math.round(
|
|
79
|
+
(document.getElementById("bar") as HTMLElement).getBoundingClientRect().top,
|
|
80
|
+
),
|
|
81
|
+
}));
|
|
82
|
+
expect(seen).toEqual({ scrolled: 600, scrolls: true, barTop: 0 });
|
|
83
|
+
await settle(500);
|
|
84
|
+
expect(Math.round((await display.box()).height), "it doesn't grow with its content").toBe(
|
|
85
|
+
400,
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "14.theme-never",
|
|
91
|
+
item: 14,
|
|
92
|
+
title:
|
|
93
|
+
"a page the host doesn't theme stays light, whatever the person's scheme, and hears no change",
|
|
94
|
+
async run(context) {
|
|
95
|
+
const config = presets.artifact({ theme: { documents: "never" } });
|
|
96
|
+
const light = resolveTheme(config.theme, "light");
|
|
97
|
+
const display = await context.launch({
|
|
98
|
+
config,
|
|
99
|
+
scheme: "dark",
|
|
100
|
+
content: `<!doctype html><html><head><meta name="color-scheme" content="light dark"><title>Light</title></head><body><p id="p">text</p>
|
|
101
|
+
<script>window.events = 0; addEventListener("porthole:theme", () => (window.events += 1));</script></body></html>`,
|
|
102
|
+
});
|
|
103
|
+
const frame = await display.frame();
|
|
104
|
+
await expect(frame.locator("#p")).toHaveText("text");
|
|
105
|
+
await display.setTheme("dark");
|
|
106
|
+
await settle(500);
|
|
107
|
+
const seen = await frame.evaluate(() => ({
|
|
108
|
+
theme: document.documentElement.dataset.theme ?? null,
|
|
109
|
+
text: getComputedStyle(document.documentElement)
|
|
110
|
+
.getPropertyValue("--color-text-primary")
|
|
111
|
+
.trim(),
|
|
112
|
+
events: (window as unknown as { events: number }).events,
|
|
113
|
+
prefers: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
|
|
114
|
+
}));
|
|
115
|
+
expect(seen.theme).toBeNull();
|
|
116
|
+
expect(seen.text).toBe(light["--color-text-primary"]);
|
|
117
|
+
expect(seen.events).toBe(0);
|
|
118
|
+
const element = await frame.frameElement();
|
|
119
|
+
expect(await element.evaluate((node) => (node as HTMLElement).style.colorScheme)).toBe(
|
|
120
|
+
"light",
|
|
121
|
+
);
|
|
122
|
+
context.measured(`prefers-color-scheme inside, the person on dark: ${seen.prefers}`);
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "14.errors",
|
|
127
|
+
item: 14,
|
|
128
|
+
title:
|
|
129
|
+
"what content throws reaches the host where reportError is declared, and nothing where it isn't",
|
|
130
|
+
async run(context) {
|
|
131
|
+
const thrower = `<p>x</p><script>throw new Error("porthole-thrown")</script>`;
|
|
132
|
+
const reported = await context.launch({
|
|
133
|
+
config: inline({ errors: "host" }),
|
|
134
|
+
content: thrower,
|
|
135
|
+
});
|
|
136
|
+
await expect
|
|
137
|
+
.poll(async () =>
|
|
138
|
+
(await reported.errors()).some(
|
|
139
|
+
(each) => each.includes("porthole-thrown") || each.includes("Script error"),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
.toBe(true);
|
|
143
|
+
const quiet = await context.launch({ config: inline({ errors: "none" }), content: thrower });
|
|
144
|
+
await expect((await quiet.frame()).locator("p")).toHaveText("x");
|
|
145
|
+
await settle(800);
|
|
146
|
+
expect(await quiet.errors()).toEqual([]);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "14.silent",
|
|
151
|
+
item: 14,
|
|
152
|
+
title:
|
|
153
|
+
"what the sandbox blocks fails silently: nothing loads, a fetch rejects, nothing is shown",
|
|
154
|
+
async run(context) {
|
|
155
|
+
const canary = await context.http();
|
|
156
|
+
const seen = watch(context.page);
|
|
157
|
+
const c = canary.origin;
|
|
158
|
+
const display = await context.launch({
|
|
159
|
+
config: inline(),
|
|
160
|
+
content: attack(
|
|
161
|
+
`
|
|
162
|
+
r.fetch = await fetch(${js(`${c}/fetch`)}).then(() => "sent", (e) => "rejected " + e.name);
|
|
163
|
+
const image = document.getElementById("img");
|
|
164
|
+
r.image = await new Promise((resolve) => { if (image.complete) resolve(image.naturalWidth > 0 ? "loaded" : "failed"); image.addEventListener("load", () => resolve("loaded")); image.addEventListener("error", () => resolve("failed")); setTimeout(() => resolve("pending"), 3000); });
|
|
165
|
+
r.script = typeof window.__portholeBlocked;
|
|
166
|
+
r.after = "ran";`,
|
|
167
|
+
`<img id="img" src="${c}/img.png"><link rel="stylesheet" href="${c}/style.css"><script src="${c}/script.js"></script>`,
|
|
168
|
+
),
|
|
169
|
+
});
|
|
170
|
+
const verdict = await context.result(display, 15_000);
|
|
171
|
+
await settle();
|
|
172
|
+
expect(verdict).toMatchObject({
|
|
173
|
+
fetch: "rejected TypeError",
|
|
174
|
+
image: "failed",
|
|
175
|
+
script: "undefined",
|
|
176
|
+
after: "ran",
|
|
177
|
+
});
|
|
178
|
+
expect(canary.requests()).toEqual([]);
|
|
179
|
+
expect(seen.dialogs).toEqual([]);
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "14.inline-assets",
|
|
184
|
+
item: 14,
|
|
185
|
+
title: "assets inline as data: URLs and CSS in a <style> work where nothing outside is open",
|
|
186
|
+
async run(context) {
|
|
187
|
+
const canary = await context.http();
|
|
188
|
+
const pixel =
|
|
189
|
+
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
190
|
+
const display = await context.launch({
|
|
191
|
+
config: inline(),
|
|
192
|
+
content: attack(
|
|
193
|
+
`
|
|
194
|
+
const image = document.getElementById("inline");
|
|
195
|
+
r.inline = await new Promise((resolve) => { if (image.complete && image.naturalWidth > 0) resolve("loaded"); image.addEventListener("load", () => resolve("loaded")); image.addEventListener("error", () => resolve("failed")); setTimeout(() => resolve("pending"), 3000); });
|
|
196
|
+
r.styled = getComputedStyle(document.getElementById("styled")).width;`,
|
|
197
|
+
`<style>#styled { width: 37px }</style><div id="styled"></div><img id="inline" src="${pixel}"><img src="${canary.origin}/outside.png">`,
|
|
198
|
+
),
|
|
199
|
+
});
|
|
200
|
+
const verdict = await context.result(display);
|
|
201
|
+
await settle();
|
|
202
|
+
expect(verdict).toMatchObject({ inline: "loaded", styled: "37px" });
|
|
203
|
+
expect(canary.requests()).toEqual([]);
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: "14.dialogs",
|
|
208
|
+
item: 14,
|
|
209
|
+
title: "alert, confirm and prompt show nothing: confirm returns false, prompt null",
|
|
210
|
+
async run(context) {
|
|
211
|
+
const seen = watch(context.page);
|
|
212
|
+
const display = await context.launch({
|
|
213
|
+
config: inline(),
|
|
214
|
+
content: `<button id="go">Ask</button>${RECORDER}<script>
|
|
215
|
+
document.getElementById("go").addEventListener("click", () => {
|
|
216
|
+
const started = Date.now();
|
|
217
|
+
r.alert = String(alert("Hello"));
|
|
218
|
+
r.confirm = String(confirm("Sure?"));
|
|
219
|
+
r.prompt = String(prompt("Name?", "default"));
|
|
220
|
+
r.ms = Date.now() - started;
|
|
221
|
+
r.done = true;
|
|
222
|
+
});
|
|
223
|
+
</script>`,
|
|
224
|
+
});
|
|
225
|
+
await click(display, "#go");
|
|
226
|
+
const verdict = await recorded(display);
|
|
227
|
+
expect(verdict).toMatchObject({ alert: "undefined", confirm: "false", prompt: "null" });
|
|
228
|
+
expect(verdict.ms as number).toBeLessThan(1_000);
|
|
229
|
+
expect(seen.dialogs).toEqual([]);
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "14.print",
|
|
234
|
+
item: 14,
|
|
235
|
+
title: "window.print() does nothing",
|
|
236
|
+
async run(context) {
|
|
237
|
+
const seen = watch(context.page);
|
|
238
|
+
const display = await context.launch({
|
|
239
|
+
config: inline(),
|
|
240
|
+
content: `<button id="go">Print</button>${RECORDER}<script>
|
|
241
|
+
addEventListener("beforeprint", () => (r.beforeprint = true));
|
|
242
|
+
addEventListener("afterprint", () => (r.afterprint = true));
|
|
243
|
+
document.getElementById("go").addEventListener("click", () => {
|
|
244
|
+
r.print = String(print());
|
|
245
|
+
setTimeout(() => (r.done = true), 800);
|
|
246
|
+
});
|
|
247
|
+
</script>`,
|
|
248
|
+
});
|
|
249
|
+
await click(display, "#go");
|
|
250
|
+
const verdict = await recorded(display);
|
|
251
|
+
expect(verdict.print).toBe("undefined");
|
|
252
|
+
expect(verdict.beforeprint).toBeUndefined();
|
|
253
|
+
expect(verdict.afterprint).toBeUndefined();
|
|
254
|
+
expect(seen.dialogs).toEqual([]);
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "14.downloads",
|
|
259
|
+
item: 14,
|
|
260
|
+
title: "downloads don't work: <a download>, data: and blob: links, saves from script",
|
|
261
|
+
async run(context) {
|
|
262
|
+
const seen = watch(context.page);
|
|
263
|
+
const display = await context.launch({
|
|
264
|
+
config: inline(),
|
|
265
|
+
content: `<a id="data" download="a.txt" href="data:text/plain,hello">data</a>
|
|
266
|
+
<button id="blob">blob</button><button id="script">script</button>
|
|
267
|
+
<script>
|
|
268
|
+
const save = (name) => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob(["hi"], { type: "text/plain" })); a.download = name; document.body.append(a); a.click(); };
|
|
269
|
+
document.getElementById("blob").addEventListener("click", () => save("b.txt"));
|
|
270
|
+
document.getElementById("script").addEventListener("click", () => { try { location.href = URL.createObjectURL(new Blob(["x"], { type: "application/octet-stream" })); } catch (e) {} });
|
|
271
|
+
</script>`,
|
|
272
|
+
});
|
|
273
|
+
for (const selector of ["#data", "#blob", "#script"]) {
|
|
274
|
+
await click(display, selector);
|
|
275
|
+
}
|
|
276
|
+
await settle(1_000);
|
|
277
|
+
expect(seen.downloads).toEqual([]);
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "14.forms",
|
|
282
|
+
item: 14,
|
|
283
|
+
title: "forms never submit anywhere; a button's click handler is how a form works",
|
|
284
|
+
async run(context) {
|
|
285
|
+
const canary = await context.http();
|
|
286
|
+
const display = await context.launch({
|
|
287
|
+
config: inline(),
|
|
288
|
+
content: `<form id="form" action="${canary.origin}/submit"><input name="q" value="secret"><button id="submit">Send</button><button id="button" type="button">Apply</button></form>${RECORDER}<script>
|
|
289
|
+
document.getElementById("form").addEventListener("submit", () => (r.submitEvent = (r.submitEvent || 0) + 1));
|
|
290
|
+
document.getElementById("button").addEventListener("click", () => (r.clicked = true));
|
|
291
|
+
</script>`,
|
|
292
|
+
});
|
|
293
|
+
await click(display, "#submit");
|
|
294
|
+
await click(display, "#button");
|
|
295
|
+
await settle(800);
|
|
296
|
+
const frame = await display.frame();
|
|
297
|
+
const state = await frame.evaluate(() => ({
|
|
298
|
+
r: (window as unknown as { r: Record<string, unknown> }).r,
|
|
299
|
+
form: document.getElementById("form") !== null,
|
|
300
|
+
}));
|
|
301
|
+
expect(state.form, "the frame stayed on its content").toBe(true);
|
|
302
|
+
expect(state.r.clicked).toBe(true);
|
|
303
|
+
expect(canary.requests()).toEqual([]);
|
|
304
|
+
context.measured(`submit event fired: ${JSON.stringify(state.r.submitEvent ?? 0)} time(s)`);
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "14.link-schemes",
|
|
309
|
+
item: 14,
|
|
310
|
+
title: "mailto:, tel: and sms: links open nothing",
|
|
311
|
+
async run(context) {
|
|
312
|
+
const seen = watch(context.page);
|
|
313
|
+
const display = await context.launch({
|
|
314
|
+
config: inline({ capabilities: { openLink: {} } }),
|
|
315
|
+
content: `<a id="mail" href="mailto:someone@example.com">mail</a> <a id="tel" href="tel:+15551234567">tel</a> <a id="sms" href="sms:+15551234567">sms</a><p id="still">still here</p>`,
|
|
316
|
+
});
|
|
317
|
+
for (const selector of ["#mail", "#tel", "#sms"]) {
|
|
318
|
+
await click(display, selector);
|
|
319
|
+
}
|
|
320
|
+
await settle(800);
|
|
321
|
+
await expect((await display.frame()).locator("#still")).toHaveText("still here");
|
|
322
|
+
expect(seen.windows).toEqual([]);
|
|
323
|
+
expect(await display.calls(), "nothing went to the host").toEqual([]);
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: "14.windows",
|
|
328
|
+
item: 14,
|
|
329
|
+
title:
|
|
330
|
+
"window.open and target=_blank open no window; with openLink, a link goes through the app",
|
|
331
|
+
async run(context) {
|
|
332
|
+
const seen = watch(context.page);
|
|
333
|
+
const content = `<a id="blank" href="https://example.com/report" target="_blank">report</a><button id="open">open</button>${RECORDER}<script>
|
|
334
|
+
document.getElementById("open").addEventListener("click", () => {
|
|
335
|
+
try { r.open = window.open("https://example.com/") === null ? "null" : "window"; } catch (e) { r.open = "throws"; }
|
|
336
|
+
r.done = true;
|
|
337
|
+
});
|
|
338
|
+
</script>`;
|
|
339
|
+
const closed = await context.launch({ config: inline(), content });
|
|
340
|
+
await click(closed, "#open");
|
|
341
|
+
const verdict = await recorded(closed);
|
|
342
|
+
expect(["null", "throws"]).toContain(verdict.open);
|
|
343
|
+
context.measured(`window.open: ${String(verdict.open)}`);
|
|
344
|
+
await click(closed, "#blank");
|
|
345
|
+
await settle(800);
|
|
346
|
+
expect(seen.windows).toEqual([]);
|
|
347
|
+
// With openLink declared, the link asks the app instead (the person says no here).
|
|
348
|
+
const linked = await context.launch({
|
|
349
|
+
config: inline({ capabilities: { openLink: {} } }),
|
|
350
|
+
content,
|
|
351
|
+
});
|
|
352
|
+
await click(linked, "#blank");
|
|
353
|
+
const asked = await linked.answerNextConfirmation(false);
|
|
354
|
+
expect(asked).toMatchObject({
|
|
355
|
+
capability: "openLink",
|
|
356
|
+
args: { url: "https://example.com/report" },
|
|
357
|
+
});
|
|
358
|
+
await settle(500);
|
|
359
|
+
expect(seen.windows).toEqual([]);
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "14.devices",
|
|
364
|
+
item: 14,
|
|
365
|
+
title: "camera, microphone, location and sharing are denied without asking; files can be read",
|
|
366
|
+
async run(context) {
|
|
367
|
+
const seen = watch(context.page);
|
|
368
|
+
const display = await context.launch({
|
|
369
|
+
config: inline(),
|
|
370
|
+
content: `<button id="go">Ask</button><input id="file" type="file">${RECORDER}<script>
|
|
371
|
+
const settle = (p) => Promise.race([p, new Promise((resolve) => setTimeout(() => resolve("waiting"), 4000))]);
|
|
372
|
+
document.getElementById("go").addEventListener("click", async () => {
|
|
373
|
+
r.media = navigator.mediaDevices && navigator.mediaDevices.getUserMedia ? await settle(navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(() => "granted", (e) => "rejected " + e.name)) : "absent";
|
|
374
|
+
r.location = navigator.geolocation ? await settle(new Promise((resolve) => navigator.geolocation.getCurrentPosition(() => resolve("granted"), (e) => resolve("denied " + e.code)))) : "absent";
|
|
375
|
+
r.share = typeof navigator.share === "function" ? await settle(navigator.share({ text: "x" }).then(() => "shared", (e) => "rejected " + e.name)) : "absent";
|
|
376
|
+
r.asked = true;
|
|
377
|
+
});
|
|
378
|
+
document.getElementById("file").addEventListener("change", (event) => {
|
|
379
|
+
const reader = new FileReader();
|
|
380
|
+
reader.addEventListener("load", () => { r.file = reader.result; r.done = r.asked === true; });
|
|
381
|
+
reader.readAsText(event.target.files[0]);
|
|
382
|
+
});
|
|
383
|
+
</script>`,
|
|
384
|
+
});
|
|
385
|
+
await click(display, "#go");
|
|
386
|
+
const frame = await display.frame();
|
|
387
|
+
await expect
|
|
388
|
+
.poll(
|
|
389
|
+
() =>
|
|
390
|
+
frame.evaluate(
|
|
391
|
+
() => (window as unknown as { r: { asked?: boolean } }).r.asked === true,
|
|
392
|
+
),
|
|
393
|
+
{
|
|
394
|
+
timeout: 15_000,
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
.toBe(true);
|
|
398
|
+
await frame.locator("#file").setInputFiles({
|
|
399
|
+
name: "notes.txt",
|
|
400
|
+
mimeType: "text/plain",
|
|
401
|
+
buffer: Buffer.from("file text"),
|
|
402
|
+
});
|
|
403
|
+
const verdict = await recorded(display);
|
|
404
|
+
expect(String(verdict.media)).toMatch(/^(rejected|absent)/u);
|
|
405
|
+
expect(String(verdict.location)).toMatch(/^(denied 1|absent)$/u);
|
|
406
|
+
expect(String(verdict.share)).toMatch(/^(rejected|absent)/u);
|
|
407
|
+
expect(verdict.file).toBe("file text");
|
|
408
|
+
expect(seen.dialogs).toEqual([]);
|
|
409
|
+
context.measured(
|
|
410
|
+
`camera/microphone: ${String(verdict.media)}; share: ${String(verdict.share)}`,
|
|
411
|
+
);
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
id: "14.workers",
|
|
416
|
+
item: 14,
|
|
417
|
+
title: "Web Workers from blob: work; service workers can't be registered; WebRTC is gone",
|
|
418
|
+
async run(context) {
|
|
419
|
+
const display = await context.launch({
|
|
420
|
+
config: inline(),
|
|
421
|
+
content: attack(`
|
|
422
|
+
r.blobBlocked = false;
|
|
423
|
+
document.addEventListener("securitypolicyviolation", (event) => { if (String(event.blockedURI).indexOf("blob") === 0) r.blobBlocked = true; });
|
|
424
|
+
r.worker = await new Promise((resolve) => { try { const worker = new Worker(URL.createObjectURL(new Blob(["postMessage(6 * 7)"], { type: "text/javascript" }))); worker.addEventListener("message", (event) => resolve("answered " + event.data)); worker.addEventListener("error", () => resolve("error")); } catch (e) { resolve("throws " + e.name); } setTimeout(() => resolve("silent"), 3000); });
|
|
425
|
+
try {
|
|
426
|
+
r.serviceWorker = !navigator.serviceWorker ? "absent" : await navigator.serviceWorker.register("worker.js").then(() => "registered", (e) => "rejected " + e.name);
|
|
427
|
+
} catch (e) { r.serviceWorker = "throws " + e.name; }
|
|
428
|
+
r.rtc = typeof RTCPeerConnection;`),
|
|
429
|
+
});
|
|
430
|
+
const verdict = await context.result(display);
|
|
431
|
+
// The frame's own policy allows blob: workers: a violation for one is the page's (P058).
|
|
432
|
+
if (verdict.worker !== "answered 42" && verdict.blobBlocked === true) {
|
|
433
|
+
context.inapplicable(
|
|
434
|
+
"the page's own CSP, which an opaque frame inherits, blocks blob: workers (worker-src falls back to its script-src): opaque content here gets no workers, whatever its config",
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
expect(verdict.worker).toBe("answered 42");
|
|
438
|
+
expect(String(verdict.serviceWorker)).toMatch(/^(absent|rejected|throws)/u);
|
|
439
|
+
expect(verdict.rtc).toBe("undefined");
|
|
440
|
+
context.measured(`service worker: ${String(verdict.serviceWorker)}`);
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
id: "14.sound",
|
|
445
|
+
item: 14,
|
|
446
|
+
title: "sound plays from a click",
|
|
447
|
+
async run(context) {
|
|
448
|
+
const display = await context.launch({
|
|
449
|
+
config: inline(),
|
|
450
|
+
content: `<button id="go">Play</button>${RECORDER}<script>
|
|
451
|
+
new Audio(${js(SILENCE)}).play().then(() => (r.unasked = "played"), (e) => (r.unasked = "refused " + e.name));
|
|
452
|
+
document.getElementById("go").addEventListener("click", () => {
|
|
453
|
+
new Audio(${js(SILENCE)}).play().then(() => { r.clicked = "played"; r.done = true; }, (e) => { r.clicked = "refused " + e.name; r.done = true; });
|
|
454
|
+
});
|
|
455
|
+
</script>`,
|
|
456
|
+
});
|
|
457
|
+
await settle(500);
|
|
458
|
+
await click(display, "#go");
|
|
459
|
+
const verdict = await recorded(display);
|
|
460
|
+
expect(verdict.clicked, "play() in a click's handler").toBe("played");
|
|
461
|
+
context.measured(`play() without a click: ${String(verdict.unasked)}`);
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
id: "14.address",
|
|
466
|
+
item: 14,
|
|
467
|
+
title: "an opaque frame's address is about:srcdoc, with no query string or hash",
|
|
468
|
+
async run(context) {
|
|
469
|
+
const display = await context.launch({
|
|
470
|
+
config: inline(),
|
|
471
|
+
content: attack(`
|
|
472
|
+
r.href = location.href;
|
|
473
|
+
r.search = location.search;
|
|
474
|
+
r.hash = location.hash;`),
|
|
475
|
+
});
|
|
476
|
+
expect(await context.result(display)).toMatchObject({
|
|
477
|
+
href: "about:srcdoc",
|
|
478
|
+
search: "",
|
|
479
|
+
hash: "",
|
|
480
|
+
});
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
id: "14.shell",
|
|
485
|
+
item: 14,
|
|
486
|
+
title:
|
|
487
|
+
"document.write and open throw; nested frames are removed; full screen and the clipboard are closed",
|
|
488
|
+
async run(context) {
|
|
489
|
+
const display = await context.launch({
|
|
490
|
+
config: inline(),
|
|
491
|
+
content: `<button id="go">Go</button>${RECORDER}<script>
|
|
492
|
+
document.getElementById("go").addEventListener("click", async () => {
|
|
493
|
+
try { document.write("<p>x</p>"); r.write = "wrote"; } catch (e) { r.write = "throws"; }
|
|
494
|
+
try { document.open(); r.open = "opened"; } catch (e) { r.open = "throws"; }
|
|
495
|
+
const frame = document.createElement("iframe");
|
|
496
|
+
document.body.append(frame);
|
|
497
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
498
|
+
r.frames = document.querySelectorAll("iframe, object, embed").length;
|
|
499
|
+
r.fullscreen = await document.documentElement.requestFullscreen().then(() => "entered", (e) => "rejected");
|
|
500
|
+
r.clipboard = navigator.clipboard ? await navigator.clipboard.writeText("x").then(() => "wrote", () => "rejected") : "absent";
|
|
501
|
+
r.done = true;
|
|
502
|
+
});
|
|
503
|
+
</script>`,
|
|
504
|
+
});
|
|
505
|
+
await click(display, "#go");
|
|
506
|
+
expect(await recorded(display)).toMatchObject({
|
|
507
|
+
write: "throws",
|
|
508
|
+
open: "throws",
|
|
509
|
+
frames: 0,
|
|
510
|
+
fullscreen: "rejected",
|
|
511
|
+
});
|
|
512
|
+
expect(["rejected", "absent"]).toContain((await recorded(display)).clipboard);
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
id: "14.storage",
|
|
517
|
+
item: 14,
|
|
518
|
+
title:
|
|
519
|
+
"storage in an opaque frame works while it is shown and is its own: no other frame reads it",
|
|
520
|
+
async run(context) {
|
|
521
|
+
const first = await context.launch({
|
|
522
|
+
config: inline(),
|
|
523
|
+
content: attack(`
|
|
524
|
+
localStorage.setItem("porthole-limits", "first");
|
|
525
|
+
r.read = localStorage.getItem("porthole-limits");`),
|
|
526
|
+
});
|
|
527
|
+
expect((await context.result(first)).read).toBe("first");
|
|
528
|
+
const second = await context.launch({
|
|
529
|
+
config: inline(),
|
|
530
|
+
content: attack(`r.read = String(localStorage.getItem("porthole-limits"));`),
|
|
531
|
+
});
|
|
532
|
+
expect((await context.result(second)).read, "another frame's storage").toBe("null");
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
];
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* The checks that prove each limit the manual can state: leak-suite probes, and served cases for
|
|
539
|
+
* what holds in domain mode. A unit test fails if a limit names none, or one that doesn't exist.
|
|
540
|
+
*/
|
|
541
|
+
export const LIMIT_CHECKS: { readonly [Id in LimitId]: readonly string[] } = {
|
|
542
|
+
layout: ["11.size", "14.fill"],
|
|
543
|
+
theme: ["11.theme-flip", "11.document-theme", "14.theme-never"],
|
|
544
|
+
errors: ["14.errors"],
|
|
545
|
+
silent: ["14.silent"],
|
|
546
|
+
network: ["1.connect", "2.resources"],
|
|
547
|
+
"inline-assets": ["14.inline-assets"],
|
|
548
|
+
dialogs: ["14.dialogs"],
|
|
549
|
+
print: ["14.print"],
|
|
550
|
+
downloads: ["14.downloads", "6.download"],
|
|
551
|
+
forms: ["14.forms"],
|
|
552
|
+
"link-schemes": ["14.link-schemes"],
|
|
553
|
+
windows: ["14.windows", "6.open"],
|
|
554
|
+
devices: ["14.devices"],
|
|
555
|
+
workers: ["14.workers", "3.webrtc"],
|
|
556
|
+
sound: ["14.sound"],
|
|
557
|
+
address: ["14.address", "served.domain.address"],
|
|
558
|
+
shell: ["14.shell", "4.document-write", "4.light-dom"],
|
|
559
|
+
storage: ["14.storage", "served.domain.storage-scope"],
|
|
560
|
+
};
|
|
@@ -14,6 +14,7 @@ import type { ConformanceHost, LaunchedDisplay, LeakSuiteOptions } from "./index
|
|
|
14
14
|
import { attack, inline, js, type Probe, type ProbeContext } from "./kit.ts";
|
|
15
15
|
import { BEHAVIOR_PROBES } from "./behavior.ts";
|
|
16
16
|
import { CLIPBOARD_PROBES } from "./clipboard.ts";
|
|
17
|
+
import { LIMIT_PROBES } from "./limits.ts";
|
|
17
18
|
import { readFileSync } from "node:fs";
|
|
18
19
|
import { createRequire } from "node:module";
|
|
19
20
|
import {
|
|
@@ -1042,6 +1043,7 @@ export const PROBES: readonly Probe[] = [
|
|
|
1042
1043
|
},
|
|
1043
1044
|
...BEHAVIOR_PROBES,
|
|
1044
1045
|
...CLIPBOARD_PROBES,
|
|
1046
|
+
...LIMIT_PROBES,
|
|
1045
1047
|
];
|
|
1046
1048
|
|
|
1047
1049
|
type AnyTest = TestType<
|
|
@@ -294,6 +294,24 @@ export function servedTests(
|
|
|
294
294
|
);
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
define(
|
|
298
|
+
"served.domain.address",
|
|
299
|
+
"a served frame's address is its own, carrying at most Porthole's ticket: never the app's",
|
|
300
|
+
async ({ page }) => {
|
|
301
|
+
const display = await host.show(page, host.shell("address"), { content: SHELL_CONTENT });
|
|
302
|
+
const frame = await display.frame();
|
|
303
|
+
const seen = await frame.evaluate(() => ({
|
|
304
|
+
origin: location.origin,
|
|
305
|
+
search: location.search,
|
|
306
|
+
hash: location.hash,
|
|
307
|
+
}));
|
|
308
|
+
expect(seen.origin).toBe(display.origin);
|
|
309
|
+
expect(seen.search).toMatch(/^(\?ticket=[^&]+)?$/u);
|
|
310
|
+
expect(seen.hash).toBe("");
|
|
311
|
+
expect(seen.origin).not.toBe(new URL(page.url()).origin);
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
|
|
297
315
|
define(
|
|
298
316
|
"served.domain.storage-scope",
|
|
299
317
|
"storage persists within a scope and is never shared across scopes",
|