fix(viewer): validate the openLink scheme host-side#120
Merged
Conversation
A surface can call openLink() directly, or post the open-link bridge message raw, with any scheme — the in-frame click handler's http(s) filter only covers plain <a href> clicks. The host then opened it after a confirm without re-checking, so javascript:/data:/file: URLs were reachable. noopener already kept those from touching the board, but the host now refuses anything that isn't http(s):// outright, matching the documented "external link" contract. Adds e2e coverage for both edges (a non-http(s) scheme raises no prompt; an http(s) url still prompts) against a surface that auto-fires the raw message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
openLinkreaches the host'swindow.open. The in-frame click handler only forwardshttp(s)hrefs:…but
window.openLink(url)is a global a surface can call directly with any string, and it can post theopen-linkbridge message raw — both bypassing that filter. The host then opened it after a confirm without re-checking the scheme:So
javascript:,data:,file:URLs were reachable.Severity: low.
"noopener"opens these in an isolated browsing context that can't reach back into the board (no opener, opaque origin), so they can't read surfaces or act as the user — the genuine residual was confirm-gated phishing. This is contract-consistency / defense-in-depth: there's no reason to open a non-link at all.Fix
Re-validate the scheme host-side, where it can't be bypassed (one line, mirrors the in-frame filter):
Tests
e2e/isolation.spec.tsadds two tests against a surface that auto-fires the raw bridge message (the actual bypass an attacker would use):a
javascript:URL raises no confirm dialog (blocked before the prompt);an
https://URL still prompts (the dialog text contains the URL).npm run typecheck,npm run lint,npm run format:check,npm test(205/205) ✅npx playwright test isolation --project=chromium— 5/5 ✅ (WebKit can't launch in the dev env; CI runs it)Not included (deliberately)
The sibling
copybridge message (navigator.clipboard.writeText) is not changed here. It backs thecodepart's Copy button (viewer/src/CodePart.tsx), so it can't simply be removed, and it's partly browser-mitigated (clipboard writes need focus/activation). Hardening it cleanly hits the same can't-distinguish-a-cross-frame-gesture wall assendPrompt, so it's left as a known low-severity residual rather than degraded here.🤖 Generated with Claude Code