Stored XSS: board name/description injected into boards-list card markup via innerHTML #200

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

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 via card.innerHTML = (~line 324). The visible title/description go through escapeHtml (lines 329, 331) — that part is safe. But the Share / Rename / Delete action buttons interpolate the board name and description into inline onclick attributes using escapeAttr:

function escapeAttr(str) {
    return str.replace(/'/g, "\\'").replace(/"/g, '"');
}
'<button class="btn btn-sm" onclick="event.stopPropagation();renameBoard(\'' + board.id + '\',\'' + escapeAttr(board.name || '') + '\',\'' + escapeAttr(board.description || '') + '\')" title="Rename">...'

(also lines 336 and 339 for Share / Delete).

escapeAttr only escapes ' and ". It does not escape <, >, or &. Because the result is parsed as HTML by innerHTML, 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

  • The boards-list card must not allow HTML/JS injection through board.name or board.description in any position (title text, description text, and the Share/Rename/Delete controls).
  • Prefer building the action buttons via DOM APIs (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 inline onclick HTML 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.
  • The fix must preserve current behavior for normal names (Share/Rename/Delete still work, modals still pre-fill the existing name/description).
  • Audit the whole card builder for any other user-controlled value interpolated into an HTML/attribute string the same way and fix consistently.
  • No server or schema change expected; this is a client-side output-encoding bug. Confirm during planning.

Notes

  • escapeHtml already exists in this file (line 741) and is used correctly for the title/description text — the gap is specifically the escapeAttr-into-onclick path.
  • Watch the double context: a value placed inside onclick="...renameBoard('NAME')..." lives in an HTML attribute AND a JS string literal; correct handling needs both layers (or, better, avoid inline handlers entirely).
  • Deploy reminder: assets/templates embed at compile time via rust-embed — after editing, touch crates/hero_whiteboard_admin/src/assets.rs before cargo 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) and escapeAttr (~747).
## 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 via `card.innerHTML =` (~line 324). The visible title/description go through `escapeHtml` (lines 329, 331) — that part is safe. But the Share / Rename / Delete action buttons interpolate the board name and description into inline `onclick` attributes using `escapeAttr`: ```js function escapeAttr(str) { return str.replace(/'/g, "\\'").replace(/"/g, '&quot;'); } ``` ```js '<button class="btn btn-sm" onclick="event.stopPropagation();renameBoard(\'' + board.id + '\',\'' + escapeAttr(board.name || '') + '\',\'' + escapeAttr(board.description || '') + '\')" title="Rename">...' ``` (also lines 336 and 339 for Share / Delete). `escapeAttr` only escapes `'` and `"`. It does **not** escape `<`, `>`, or `&`. Because the result is parsed as HTML by `innerHTML`, 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 - The boards-list card must not allow HTML/JS injection through `board.name` or `board.description` in any position (title text, description text, and the Share/Rename/Delete controls). - Prefer building the action buttons via DOM APIs (`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 inline `onclick` HTML 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. - The fix must preserve current behavior for normal names (Share/Rename/Delete still work, modals still pre-fill the existing name/description). - Audit the whole card builder for any other user-controlled value interpolated into an HTML/attribute string the same way and fix consistently. - No server or schema change expected; this is a client-side output-encoding bug. Confirm during planning. ## Notes - `escapeHtml` already exists in this file (line 741) and is used correctly for the title/description text — the gap is specifically the `escapeAttr`-into-`onclick` path. - Watch the double context: a value placed inside `onclick="...renameBoard('NAME')..."` lives in an HTML attribute AND a JS string literal; correct handling needs both layers (or, better, avoid inline handlers entirely). - Deploy reminder: assets/templates embed at compile time via rust-embed — after editing, `touch crates/hero_whiteboard_admin/src/assets.rs` before `cargo 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) and `escapeAttr` (~747).
Author
Member

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, because renderBoardCard interpolates escapeAttr(board.name)/escapeAttr(board.description) into inline onclick attributes inside an innerHTML string, and escapeAttr (~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

  • A board name/description containing HTML/JS metacharacters MUST NOT execute or break out of any context.
  • Visible title/description unchanged (already escapeHtml, ~329/331 — keep behavior).
  • Share/Rename/Delete/Duplicate/preview-open keep working exactly: Share opens the share modal titled Share "<name>"; Rename pre-fills name+description; Delete confirms Delete "<name>"?; Duplicate/preview already id-only.
  • Card DOM structure/classes/inline styles/icons byte-equivalent for normal names.
  • No server/RPC/schema change. Template/JS only.
  • Whole-file audit: no remaining user-data → innerHTML/attribute sink without correct encoding.

Files to Modify/Create

  • crates/hero_whiteboard_admin/templates/web/home.html - DOM-based renderBoardCard; id-only handler signatures with a boardsById lookup; remove unsafe escapeAttr and DOM-ify the share-modal URL sinks.
  • crates/hero_whiteboard_admin/src/assets.rs - no content change; touch to force rust-embed re-embed.

Implementation Plan

Step 1: In-memory board lookup by id

Files: crates/hero_whiteboard_admin/templates/web/home.html

  • Add var boardsById = {}; near the globals (after ~line 207). In loadBoards (~364), right after var boards = await rpcCall('board.list', params); (~377), reset boardsById = {}; and populate boardsById[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.html

  • Replace card.innerHTML = '...' (~324-340) with createElement construction; keep card creation + data-board-id (~318-320) and updated (~322).
  • preview div (board-card-preview) → addEventListener('click', () => openBoard(board.id)); static icon markup only (no user data).
  • info div: title div board-card-title title.textContent = board.name || 'Untitled Board'; meta div with a <span> textContent = board.description + <br> (only if description) then a <span> textContent = updated.
  • actions div: four <button class="btn btn-sm"> (Share/Rename/Duplicate/Delete) with the SAME titles, icon spans (static <i class="bi ...">, no user data), Delete style.color='var(--wb-error)'; each via addEventListener('click', e => { e.stopPropagation(); HANDLER(board.id); }).
  • Append preview/info/actions to card, 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.html

  • renameBoard(id, name, desc)renameBoard(id) (~571): var b = boardsById[String(id)] || {}; use b.name||'' / b.description||'' for the inputs; rest unchanged.
  • deleteBoard(id, name)deleteBoard(id) (~636): resolve b, set delete-board-prompt textContent = 'Delete "' + (b.name||'this board') + '"?'; rest unchanged.
  • openShareModal(boardId, name)openShareModal(boardId) (~766): resolve b, var name = b.name||'this board'; keep titleEl.textContent = 'Share "' + name + '"';; rest keyed by numeric id, unchanged.
  • Confirm the only other call sites were the inline onclicks removed in Step 2.
    Dependencies: Steps 1, 2

Step 4: Remove unsafe escapeAttr; DOM-ify share-modal URL sinks

Files: crates/hero_whiteboard_admin/templates/web/home.html

  • Remaining escapeAttr sites (~782/784/790/792/806/808) are in openShareModal renderRows/renderPresentRow interpolating share URLs into value="..." and onclick="copyLink('...')". Rewrite those to createElement('input') + input.readOnly = true + input.value = url and button.addEventListener('click', () => copyLink(url)). Then delete escapeAttr (~747-749). Leave escapeHtml (~741-745) intact.
  • Fallback only if the share-modal DOM rewrite is judged disproportionate: replace escapeAttr with a correct & < > " ' encoder (encode & first) at all six sites AND still use addEventListener for the copyLink buttons (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.rs

  • touch it; cargo build --release -p hero_whiteboard_admin; fetch the served home HTML and confirm the DOM-based card builder is present (no card.innerHTML = with onclick="...escapeAttr...") before browser testing (rust-embed embeds at compile time).
    Dependencies: Steps 1-4

Acceptance Criteria

  • A board named </button><img src=x onerror=alert(1)> renders as literal text in the title and does NOT trigger script/onerror.
  • Same hostile string in the description renders inert.
  • Action buttons not injectable: no value from name/description reaches innerHTML/attribute parse; handlers get only board.id.
  • Share opens Share "<name>" for the right board; links + Copy work.
  • Rename pre-fills Name + Description; Save updates.
  • Delete shows Delete "<name>"?; removes the right board.
  • Duplicate / card preview still open/duplicate the right board.
  • Normal-name cards visually identical (flat and grouped views).
  • Whole-file audit done; escapeAttr removed (or fallback: correct encoder + addEventListener for copyLink).
  • No server/RPC/schema change; served HTML confirmed updated.

Notes

  • Final handler signatures: renameBoard(id), deleteBoard(id), openShareModal(boardId), duplicateBoard(id)/openBoard(id) already id-only. Name/desc resolved from boardsById populated right after board.list. Closure also captures board.id; data-attribute approach is also safe but redundant here.
  • escapeAttr is the root bug (escapes only '/"). Sites: card onclick (vulnerable — removed by Step 2); share/view/edit/present URL into value= and onclick="copyLink('...')" (server URLs, still unsafe encoder — removed by Step 4). No safe remaining use → deleted. escapeHtml is correct, kept.
  • Other home.html innerHTML sinks audited: renderBoardSection uses textContent (workspace name) + numeric count — safe; workspace <option> builders and modal prompts use textContent — safe; other innerHTML is static/numeric — safe. Only the board-card builder and the share-modal URL rows needed changes.
  • Deploy: rust-embed embeds templates at compile time — after editing home.html, touch crates/hero_whiteboard_admin/src/assets.rs, cargo build --release -p hero_whiteboard_admin, verify served HTML changed before testing.
## 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, because `renderBoardCard` interpolates `escapeAttr(board.name)`/`escapeAttr(board.description)` into inline `onclick` attributes inside an `innerHTML` string, and `escapeAttr` (~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 - A board name/description containing HTML/JS metacharacters MUST NOT execute or break out of any context. - Visible title/description unchanged (already `escapeHtml`, ~329/331 — keep behavior). - Share/Rename/Delete/Duplicate/preview-open keep working exactly: Share opens the share modal titled `Share "<name>"`; Rename pre-fills name+description; Delete confirms `Delete "<name>"?`; Duplicate/preview already id-only. - Card DOM structure/classes/inline styles/icons byte-equivalent for normal names. - No server/RPC/schema change. Template/JS only. - Whole-file audit: no remaining user-data → innerHTML/attribute sink without correct encoding. ### Files to Modify/Create - `crates/hero_whiteboard_admin/templates/web/home.html` - DOM-based `renderBoardCard`; id-only handler signatures with a `boardsById` lookup; remove unsafe `escapeAttr` and DOM-ify the share-modal URL sinks. - `crates/hero_whiteboard_admin/src/assets.rs` - no content change; `touch` to force rust-embed re-embed. ### Implementation Plan #### Step 1: In-memory board lookup by id Files: `crates/hero_whiteboard_admin/templates/web/home.html` - Add `var boardsById = {};` near the globals (after ~line 207). In `loadBoards` (~364), right after `var boards = await rpcCall('board.list', params);` (~377), reset `boardsById = {};` and populate `boardsById[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.html` - Replace `card.innerHTML = '...'` (~324-340) with `createElement` construction; keep `card` creation + `data-board-id` (~318-320) and `updated` (~322). - preview div (`board-card-preview`) → `addEventListener('click', () => openBoard(board.id))`; static icon markup only (no user data). - info div: `title` div `board-card-title` `title.textContent = board.name || 'Untitled Board'`; `meta` div with a `<span>` `textContent = board.description` + `<br>` (only if description) then a `<span>` `textContent = updated`. - actions div: four `<button class="btn btn-sm">` (Share/Rename/Duplicate/Delete) with the SAME titles, icon spans (static `<i class="bi ...">`, no user data), Delete `style.color='var(--wb-error)'`; each via `addEventListener('click', e => { e.stopPropagation(); HANDLER(board.id); })`. - Append preview/info/actions to card, `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.html` - `renameBoard(id, name, desc)` → `renameBoard(id)` (~571): `var b = boardsById[String(id)] || {};` use `b.name||''` / `b.description||''` for the inputs; rest unchanged. - `deleteBoard(id, name)` → `deleteBoard(id)` (~636): resolve `b`, set `delete-board-prompt` `textContent = 'Delete "' + (b.name||'this board') + '"?'`; rest unchanged. - `openShareModal(boardId, name)` → `openShareModal(boardId)` (~766): resolve `b`, `var name = b.name||'this board';` keep `titleEl.textContent = 'Share "' + name + '"';`; rest keyed by numeric id, unchanged. - Confirm the only other call sites were the inline onclicks removed in Step 2. Dependencies: Steps 1, 2 #### Step 4: Remove unsafe escapeAttr; DOM-ify share-modal URL sinks Files: `crates/hero_whiteboard_admin/templates/web/home.html` - Remaining `escapeAttr` sites (~782/784/790/792/806/808) are in `openShareModal` `renderRows`/`renderPresentRow` interpolating share URLs into `value="..."` and `onclick="copyLink('...')"`. Rewrite those to `createElement('input')` + `input.readOnly = true` + `input.value = url` and `button.addEventListener('click', () => copyLink(url))`. Then delete `escapeAttr` (~747-749). Leave `escapeHtml` (~741-745) intact. - Fallback only if the share-modal DOM rewrite is judged disproportionate: replace `escapeAttr` with a correct `& < > " '` encoder (encode `&` first) at all six sites AND still use `addEventListener` for the `copyLink` buttons (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.rs` - `touch` it; `cargo build --release -p hero_whiteboard_admin`; fetch the served home HTML and confirm the DOM-based card builder is present (no `card.innerHTML =` with `onclick="...escapeAttr..."`) before browser testing (rust-embed embeds at compile time). Dependencies: Steps 1-4 ### Acceptance Criteria - [ ] A board named `</button><img src=x onerror=alert(1)>` renders as literal text in the title and does NOT trigger script/onerror. - [ ] Same hostile string in the description renders inert. - [ ] Action buttons not injectable: no value from name/description reaches innerHTML/attribute parse; handlers get only `board.id`. - [ ] Share opens `Share "<name>"` for the right board; links + Copy work. - [ ] Rename pre-fills Name + Description; Save updates. - [ ] Delete shows `Delete "<name>"?`; removes the right board. - [ ] Duplicate / card preview still open/duplicate the right board. - [ ] Normal-name cards visually identical (flat and grouped views). - [ ] Whole-file audit done; `escapeAttr` removed (or fallback: correct encoder + addEventListener for copyLink). - [ ] No server/RPC/schema change; served HTML confirmed updated. ### Notes - Final handler signatures: `renameBoard(id)`, `deleteBoard(id)`, `openShareModal(boardId)`, `duplicateBoard(id)`/`openBoard(id)` already id-only. Name/desc resolved from `boardsById` populated right after `board.list`. Closure also captures `board.id`; data-attribute approach is also safe but redundant here. - `escapeAttr` is the root bug (escapes only `'`/`"`). Sites: card `onclick` (vulnerable — removed by Step 2); share/view/edit/present URL into `value=` and `onclick="copyLink('...')"` (server URLs, still unsafe encoder — removed by Step 4). No safe remaining use → deleted. `escapeHtml` is correct, kept. - Other `home.html` innerHTML sinks audited: `renderBoardSection` uses `textContent` (workspace name) + numeric count — safe; workspace `<option>` builders and modal prompts use `textContent` — safe; other innerHTML is static/numeric — safe. Only the board-card builder and the share-modal URL rows needed changes. - Deploy: rust-embed embeds templates at compile time — after editing home.html, `touch crates/hero_whiteboard_admin/src/assets.rs`, `cargo build --release -p hero_whiteboard_admin`, verify served HTML changed before testing.
Author
Member

Test Results

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

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.

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

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)

  • Added an in-memory boardsById map populated in loadBoards right after the board.list RPC (covers the empty-list, single-workspace, and grouped All-Workspaces render paths).
  • renderBoardCard no longer assigns card.innerHTML. The card is built with createElement: preview (static easel icon only) with a click handler to open the board; title via textContent; description/updated via textContent spans; and the Share/Rename/Duplicate/Delete buttons created as elements with static icon markup and addEventListener handlers that receive only board.id. No board name/description is ever interpolated into HTML or an attribute.
  • renameBoard, deleteBoard, and openShareModal now take only the board id and resolve the name/description from boardsById (rename modal still pre-fills name+description; delete still shows the board name; share modal title still shows the board name). saveRename/submitDeleteBoard/keydown paths unchanged.
  • The share-modal rows (View/Edit/Presentation links) were rewritten to construct each readonly input via input.value (property, not attribute string) and the Copy button via addEventListener(copyLink). The unsafe escapeAttr helper (which only escaped quotes, not < > &) was removed entirely; it has zero remaining call sites. escapeHtml is left intact.
  • Rendered DOM, classes, inline styles, icons, and layout are identical for normal board names; behavior of Share/Rename/Delete/Duplicate/preview is unchanged.

Behavior after change

  • A board named e.g. renders as inert literal text in the card title/description and cannot break out of any element/attribute or execute, including via the action buttons.
  • Share/Rename/Delete/Duplicate and clicking the card all operate on the correct board exactly as before.

Tests

  • cargo test --workspace --lib: green, no Rust regression (template/JS-only; no JS unit harness in repo).
  • home.html inline script: node --check ok; escapeAttr occurrences: 0.
  • The XSS fix verified manually in-browser with a hostile board name after a forced-embed rebuild and redeploy.
## 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) - Added an in-memory `boardsById` map populated in loadBoards right after the board.list RPC (covers the empty-list, single-workspace, and grouped All-Workspaces render paths). - renderBoardCard no longer assigns card.innerHTML. The card is built with createElement: preview (static easel icon only) with a click handler to open the board; title via textContent; description/updated via textContent spans; and the Share/Rename/Duplicate/Delete buttons created as elements with static icon markup and addEventListener handlers that receive only board.id. No board name/description is ever interpolated into HTML or an attribute. - renameBoard, deleteBoard, and openShareModal now take only the board id and resolve the name/description from boardsById (rename modal still pre-fills name+description; delete still shows the board name; share modal title still shows the board name). saveRename/submitDeleteBoard/keydown paths unchanged. - The share-modal rows (View/Edit/Presentation links) were rewritten to construct each readonly input via input.value (property, not attribute string) and the Copy button via addEventListener(copyLink). The unsafe escapeAttr helper (which only escaped quotes, not < > &) was removed entirely; it has zero remaining call sites. escapeHtml is left intact. - Rendered DOM, classes, inline styles, icons, and layout are identical for normal board names; behavior of Share/Rename/Delete/Duplicate/preview is unchanged. ### Behavior after change - A board named e.g. </button><img src=x onerror=...> renders as inert literal text in the card title/description and cannot break out of any element/attribute or execute, including via the action buttons. - Share/Rename/Delete/Duplicate and clicking the card all operate on the correct board exactly as before. ### Tests - cargo test --workspace --lib: green, no Rust regression (template/JS-only; no JS unit harness in repo). - home.html inline script: node --check ok; escapeAttr occurrences: 0. - The XSS fix verified manually in-browser with a hostile board name 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#200
No description provided.