Skip to content

Commit 92c50ca

Browse files
benvinegarclaude
andcommitted
refactor(viewer): parse the openLink url instead of regex-matching its scheme
Address review feedback: rather than regex-checking the raw string and then handing that same raw string to window.open (validate one representation, act on another — a parser-differential smell), parse it once with the URL constructor, validate `protocol`, and open the normalized `href` from the same parse. What we check and what opens are now byte-identical, and a malformed string is rejected outright. The negative test now also covers data: and an unparseable string. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e29318d commit 92c50ca

2 files changed

Lines changed: 30 additions & 15 deletions

File tree

e2e/isolation.spec.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,19 @@ test("a surface send is not delivered to the agent as user feedback", async ({
9494
const openLinkMsg = (url: string) =>
9595
`<script>parent.postMessage({__sideshow:true,type:"open-link",url:${JSON.stringify(url)}},"*")</script>`;
9696

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

104111
let dialogs = 0;
105112
page.on("dialog", (d) => {
@@ -111,7 +118,7 @@ test("openLink ignores a non-http(s) scheme — no prompt, no open", async ({ pa
111118
await expect(page.locator(".card:not(#whatsNew) iframe").first()).toBeVisible();
112119
await page.waitForTimeout(500);
113120

114-
// The scheme check returns before the confirm, so no dialog is ever raised.
121+
// Each is rejected before the confirm, so no dialog is ever raised.
115122
expect(dialogs).toBe(0);
116123
});
117124

viewer/src/App.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -323,14 +323,22 @@ async function onBridgeMessage(ev: MessageEvent) {
323323
});
324324
toast("Added to this surface’s thread");
325325
} else if (d.type === "open-link" && isOwnFrame(ev.source)) {
326-
// Only ever open real external links. The in-frame click handler already
327-
// forwards just http(s) hrefs, but a surface can call openLink() directly
328-
// (or post this message raw) with any scheme — javascript:, data:, file: —
329-
// so re-check here, host-side, where it can't be bypassed. (noopener already
330-
// severs those from the board, but there's no reason to open them at all.)
331-
const url = String(d.url);
332-
if (!/^https?:\/\//i.test(url)) return;
333-
if (confirm(`Open external link?\n\n${url}`)) window.open(url, "_blank", "noopener");
326+
// Only ever open real external links. The in-frame click handler forwards
327+
// just http(s) hrefs, but a surface can call openLink() directly (or post
328+
// this message raw) with any scheme — javascript:, data:, file: — so
329+
// re-check host-side, where it can't be bypassed. Parse once and act on the
330+
// parsed result: validate `protocol` and open the normalized `href` from the
331+
// same parse, so there's no gap between what we check and what window.open
332+
// re-parses (and a malformed string is rejected outright).
333+
let link: URL;
334+
try {
335+
link = new URL(String(d.url));
336+
} catch {
337+
return;
338+
}
339+
if (link.protocol !== "http:" && link.protocol !== "https:") return;
340+
if (confirm(`Open external link?\n\n${link.href}`))
341+
window.open(link.href, "_blank", "noopener");
334342
} else if (d.type === "copy" && isOwnFrame(ev.source)) {
335343
void navigator.clipboard?.writeText(String(d.text)).catch(() => {});
336344
}

0 commit comments

Comments
 (0)