Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/openlink-scheme-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"sideshow": patch
---

Validate the `openLink` scheme host-side so a surface can't ask the viewer to
open a non-http(s) URL. The in-frame click handler only forwards `http(s)`
hrefs, but a surface script can call `openLink()` directly — or post the bridge
message raw — with any scheme (`javascript:`, `data:`, `file:`), and the host
opened it after a confirm without re-checking. `noopener` already kept those
from reaching the board, but the host now refuses anything that isn't
`http(s)://` outright, matching the documented "external link" contract.
55 changes: 55 additions & 0 deletions e2e/isolation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,58 @@ test("a surface send is not delivered to the agent as user feedback", async ({
).json();
expect(feedback.comments).toHaveLength(0);
});

// openLink reaches the host's window.open. The in-frame click handler forwards
// only http(s) hrefs, but a surface can post the raw bridge message with any
// scheme, so the host must re-validate. These pin both edges against a surface
// that auto-fires the raw message on load — the bypass an attacker would use.
const openLinkMsg = (url: string) =>
`<script>parent.postMessage({__sideshow:true,type:"open-link",url:${JSON.stringify(url)}},"*")</script>`;

test("openLink ignores non-http(s) and malformed urls — no prompt, no open", async ({
page,
server,
}) => {
// A scheme that parses (javascript:), another (data:), and a string that
// fails to parse at all — all must be refused before the confirm.
const bad = ["javascript:alert(1)", "data:text/html,<b>x</b>", "::: not a url :::"];
const fire = `<script>${bad
.map(
(u) => `parent.postMessage({__sideshow:true,type:"open-link",url:${JSON.stringify(u)}},"*");`,
)
.join("")}</script>`;
await publish(server.url, { html: fire, title: "bad", agent: "e2e" });

let dialogs = 0;
page.on("dialog", (d) => {
dialogs += 1;
void d.dismiss();
});

await page.goto(server.url);
await expect(page.locator(".card:not(#whatsNew) iframe").first()).toBeVisible();
await page.waitForTimeout(500);

// Each is rejected before the confirm, so no dialog is ever raised.
expect(dialogs).toBe(0);
});

test("openLink still prompts for an http(s) url", async ({ page, server }) => {
await publish(server.url, {
html: openLinkMsg("https://example.com/"),
title: "good",
agent: "e2e",
});

let dialogMsg = "";
page.on("dialog", (d) => {
dialogMsg = d.message();
void d.dismiss(); // dismiss so nothing actually navigates
});

await page.goto(server.url);
await expect(page.locator(".card:not(#whatsNew) iframe").first()).toBeVisible();
await page.waitForTimeout(500);

expect(dialogMsg).toContain("https://example.com/");
});
17 changes: 16 additions & 1 deletion viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,22 @@ async function onBridgeMessage(ev: MessageEvent) {
});
toast("Added to this surface’s thread");
} else if (d.type === "open-link" && isOwnFrame(ev.source)) {
if (confirm(`Open external link?\n\n${d.url}`)) window.open(d.url, "_blank", "noopener");
// Only ever open real external links. The in-frame click handler forwards
// just http(s) hrefs, but a surface can call openLink() directly (or post
// this message raw) with any scheme — javascript:, data:, file: — so
// re-check host-side, where it can't be bypassed. Parse once and act on the
// parsed result: validate `protocol` and open the normalized `href` from the
// same parse, so there's no gap between what we check and what window.open
// re-parses (and a malformed string is rejected outright).
let link: URL;
try {
link = new URL(String(d.url));
} catch {
return;
}
if (link.protocol !== "http:" && link.protocol !== "https:") return;
if (confirm(`Open external link?\n\n${link.href}`))
window.open(link.href, "_blank", "noopener");
} else if (d.type === "copy" && isOwnFrame(ev.source)) {
void navigator.clipboard?.writeText(String(d.text)).catch(() => {});
}
Expand Down
Loading