Stored XSS: board name/description injected into boards-list card markup via innerHTML #200
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#200
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
Stored XSS in the boards list. A board whose name or description contains HTML executes arbitrary JavaScript in the session of every user who opens the boards page.
Confirmed root cause
In
crates/hero_whiteboard_admin/templates/web/home.html, each board card is built as an HTML string and assigned viacard.innerHTML =(~line 324). The visible title/description go throughescapeHtml(lines 329, 331) — that part is safe. But the Share / Rename / Delete action buttons interpolate the board name and description into inlineonclickattributes usingescapeAttr:(also lines 336 and 339 for Share / Delete).
escapeAttronly escapes'and". It does not escape<,>, or&. Because the result is parsed as HTML byinnerHTML, a board named e.g.</button><img src=x onerror=alert(document.cookie)>terminates the<button>early and injects an executing<img onerror=...>. The JS-string backslash escaping of'is irrelevant at the HTML-parser level.Board names and descriptions are user-controlled (set via
renameBoard/ board creation), so this is persistent/stored XSS that fires for anyone who lists the boards.Expected
Board name/description must be safely encoded everywhere they are placed into the card markup — including inside the action-button attributes — so no markup or script can be injected via a board name or description.
Requirements
board.nameorboard.descriptionin any position (title text, description text, and the Share/Rename/Delete controls).createElement+textContent/addEventListener) or passing identifiers (board id) to the handlers and looking up the name/description from data rather than interpolating user strings into an inlineonclickHTML attribute. If string interpolation is kept, the values must be encoded for BOTH HTML and the JS-string/attribute context (entity-encode& < > " 'at minimum) — escaping only quotes is insufficient.Notes
escapeHtmlalready exists in this file (line 741) and is used correctly for the title/description text — the gap is specifically theescapeAttr-into-onclickpath.onclick="...renameBoard('NAME')..."lives in an HTML attribute AND a JS string literal; correct handling needs both layers (or, better, avoid inline handlers entirely).touch crates/hero_whiteboard_admin/src/assets.rsbeforecargo build --release -p hero_whiteboard_admin, and verify the served HTML changed before testing.Affected files
crates/hero_whiteboard_admin/templates/web/home.html— board-card builder (~324-340) andescapeAttr(~747).Implementation Spec for Issue #200
Objective
Eliminate the stored XSS in the boards list (
crates/hero_whiteboard_admin/templates/web/home.html). A board whose name/description contains markup executes when the list renders, becauserenderBoardCardinterpolatesescapeAttr(board.name)/escapeAttr(board.description)into inlineonclickattributes inside aninnerHTMLstring, andescapeAttr(~747-749) escapes only'and"(not< > &). Fix by building the card/action buttons with DOM APIs so user strings never enter an HTML/attribute parse context, preserving exact behavior and layout.Requirements
escapeHtml, ~329/331 — keep behavior).Share "<name>"; Rename pre-fills name+description; Delete confirmsDelete "<name>"?; Duplicate/preview already id-only.Files to Modify/Create
crates/hero_whiteboard_admin/templates/web/home.html- DOM-basedrenderBoardCard; id-only handler signatures with aboardsByIdlookup; remove unsafeescapeAttrand DOM-ify the share-modal URL sinks.crates/hero_whiteboard_admin/src/assets.rs- no content change;touchto force rust-embed re-embed.Implementation Plan
Step 1: In-memory board lookup by id
Files:
crates/hero_whiteboard_admin/templates/web/home.htmlvar boardsById = {};near the globals (after ~line 207). InloadBoards(~364), right aftervar boards = await rpcCall('board.list', params);(~377), resetboardsById = {};and populateboardsById[String(board.id)] = board;for every board so it covers the empty-list return, the flat single-workspace render, and the grouped render.Dependencies: none
Step 2: Rewrite renderBoardCard (~317-342) with DOM APIs
Files:
crates/hero_whiteboard_admin/templates/web/home.htmlcard.innerHTML = '...'(~324-340) withcreateElementconstruction; keepcardcreation +data-board-id(~318-320) andupdated(~322).board-card-preview) →addEventListener('click', () => openBoard(board.id)); static icon markup only (no user data).titledivboard-card-titletitle.textContent = board.name || 'Untitled Board';metadiv with a<span>textContent = board.description+<br>(only if description) then a<span>textContent = updated.<button class="btn btn-sm">(Share/Rename/Duplicate/Delete) with the SAME titles, icon spans (static<i class="bi ...">, no user data), Deletestyle.color='var(--wb-error)'; each viaaddEventListener('click', e => { e.stopPropagation(); HANDLER(board.id); }).gridEl.appendChild(card). No board field ever enters innerHTML. Rendered DOM identical for normal names.Dependencies: none (Step 3 depends on the id-only call sites this introduces)
Step 3: Make renameBoard / deleteBoard / openShareModal id-only
Files:
crates/hero_whiteboard_admin/templates/web/home.htmlrenameBoard(id, name, desc)→renameBoard(id)(~571):var b = boardsById[String(id)] || {};useb.name||''/b.description||''for the inputs; rest unchanged.deleteBoard(id, name)→deleteBoard(id)(~636): resolveb, setdelete-board-prompttextContent = 'Delete "' + (b.name||'this board') + '"?'; rest unchanged.openShareModal(boardId, name)→openShareModal(boardId)(~766): resolveb,var name = b.name||'this board';keeptitleEl.textContent = 'Share "' + name + '"';; rest keyed by numeric id, unchanged.Dependencies: Steps 1, 2
Step 4: Remove unsafe escapeAttr; DOM-ify share-modal URL sinks
Files:
crates/hero_whiteboard_admin/templates/web/home.htmlescapeAttrsites (~782/784/790/792/806/808) are inopenShareModalrenderRows/renderPresentRowinterpolating share URLs intovalue="..."andonclick="copyLink('...')". Rewrite those tocreateElement('input')+input.readOnly = true+input.value = urlandbutton.addEventListener('click', () => copyLink(url)). Then deleteescapeAttr(~747-749). LeaveescapeHtml(~741-745) intact.escapeAttrwith a correct& < > " 'encoder (encode&first) at all six sites AND still useaddEventListenerfor thecopyLinkbuttons (double HTML+JS-string context). DOM approach preferred; justify if falling back.Dependencies: Step 3
Step 5: Deploy refresh + verify
Files:
crates/hero_whiteboard_admin/src/assets.rstouchit;cargo build --release -p hero_whiteboard_admin; fetch the served home HTML and confirm the DOM-based card builder is present (nocard.innerHTML =withonclick="...escapeAttr...") before browser testing (rust-embed embeds at compile time).Dependencies: Steps 1-4
Acceptance Criteria
</button><img src=x onerror=alert(1)>renders as literal text in the title and does NOT trigger script/onerror.board.id.Share "<name>"for the right board; links + Copy work.Delete "<name>"?; removes the right board.escapeAttrremoved (or fallback: correct encoder + addEventListener for copyLink).Notes
renameBoard(id),deleteBoard(id),openShareModal(boardId),duplicateBoard(id)/openBoard(id)already id-only. Name/desc resolved fromboardsByIdpopulated right afterboard.list. Closure also capturesboard.id; data-attribute approach is also safe but redundant here.escapeAttris the root bug (escapes only'/"). Sites: cardonclick(vulnerable — removed by Step 2); share/view/edit/present URL intovalue=andonclick="copyLink('...')"(server URLs, still unsafe encoder — removed by Step 4). No safe remaining use → deleted.escapeHtmlis correct, kept.home.htmlinnerHTML sinks audited:renderBoardSectionusestextContent(workspace name) + numeric count — safe; workspace<option>builders and modal prompts usetextContent— safe; other innerHTML is static/numeric — safe. Only the board-card builder and the share-modal URL rows needed changes.touch crates/hero_whiteboard_admin/src/assets.rs,cargo build --release -p hero_whiteboard_admin, verify served HTML changed before testing.Test Results
cargo test --workspace --lib: all 4 lib test suites compiled and ran clean (result: ok); no unit tests are defined, no regression
home.html inline script syntax: ok
escapeAttr remaining occurrences: 0 (expected 0)
Note: #200 is a template/JS-only change (DOM-based boards-list card rendering, no user data into innerHTML/attributes). No JS unit harness exists in this repo; the Rust suite is the regression gate and the XSS fix is verified manually in-browser with a hostile board name.
Implementation Summary
Template/JS-only. The boards-list stored XSS is closed by rebuilding the board card and the share-modal URL rows with DOM APIs so no user-controlled value ever enters an innerHTML or HTML-attribute parse. No server/RPC/schema change.
Changes (home.html)
boardsByIdmap populated in loadBoards right after the board.list RPC (covers the empty-list, single-workspace, and grouped All-Workspaces render paths).Behavior after change
Tests