chat: enforce shared text policy at UI, sign point, and gossip ingress
CI / check (push) Successful in 3m4s

Chat-hardening plan Phase 1. The chat body policy (2,000-char + 8 KiB
ceilings, single-pass control/whitespace normalization) moves from the UI
layer into src/sanitize.rs and is now enforced at every trust boundary:
cap_chat_input bounds the live input (oversized paste), the gossip sign
point re-sanitizes so non-UI callers can't bypass policy, and gossip
ingress rejects oversized raw text before sanitizing (admit_chat_text)
and drops messages with neither visible text nor an attachment. The
incoming chat author label now uses the strict name sanitizer until
Phase 2 roster-binds it. +8 tests (532 lib green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:36:21 -04:00
co-authored by Claude Fable 5
parent 93f4954653
commit 5927148ee4
5 changed files with 285 additions and 59 deletions
+29 -15
View File
@@ -1,6 +1,8 @@
# Chat hardening — ephemeral implementation plan
**Status (2026-07-15):** PLANNED, not started. This is a temporary scope
**Status (2026-07-17):** Phase 1 COMPLETE (shared text policy in `src/sanitize.rs`,
ceilings enforced at UI input, sign point, and gossip ingress; all gates green).
Phases 25 not started. This is a temporary scope
contract for hardening the existing room chat. Update the checkboxes and decision
log as work lands, then delete this file when the work is complete. Do not add
link previews as part of this effort.
@@ -88,39 +90,39 @@ tests. Values are starting points, not a compatibility contract.
**Target:** downstream layers never receive or retain an unexpectedly large or
unsafe chat string.
- [ ] Move chat constants and `sanitize_chat` from `src/app/mod.rs` into
- [x] Move chat constants and `sanitize_chat` from `src/app/mod.rs` into
`src/sanitize.rs` (or a narrowly scoped shared chat-policy module if that keeps
the API clearer).
- [ ] Implement a single-pass sanitizer that:
- [x] Implement a single-pass sanitizer that:
- maps control characters to spaces;
- collapses whitespace and trims ends;
- enforces both the character and UTF-8 byte ceilings without splitting a scalar;
- returns empty for content with no visible text.
- [ ] Add `cap_chat_input` for live editing. It must preserve the user's current
- [x] Add `cap_chat_input` for live editing. It must preserve the user's current
whitespace while enforcing character and byte ceilings; normalization remains a
submit/ingress operation so typing does not visibly jump.
- [ ] Apply `cap_chat_input` in `AppMessage::ChatInputChanged`, covering keyboard,
- [x] Apply `cap_chat_input` in `AppMessage::ChatInputChanged`, covering keyboard,
clipboard, primary-selection, and context-menu paste paths through the controlled
input widget.
- [ ] Sanitize outgoing text immediately before local echo and `CoreCommand` send.
- [ ] Sanitize again before `GossipMessage::Chat` is signed, so a future non-UI
- [x] Sanitize outgoing text immediately before local echo and `CoreCommand` send.
- [x] Sanitize again before `GossipMessage::Chat` is signed, so a future non-UI
caller cannot bypass policy.
- [ ] At gossip ingress, reject raw chat text over the byte ceiling before doing
- [x] At gossip ingress, reject raw chat text over the byte ceiling before doing
downstream sanitization; sanitize accepted text before creating `RoomEvent`.
- [ ] Keep attachment-only messages when the sanitized caption is empty; drop a
- [x] Keep attachment-only messages when the sanitized caption is empty; drop a
chat with neither visible text nor a valid attachment.
- [ ] Stop sanitizing an incoming chat `name` with the body sanitizer. Phase 2
- [x] Stop sanitizing an incoming chat `name` with the body sanitizer. Phase 2
replaces it with the roster-bound name.
### Phase 1 tests
- [ ] ASCII, multibyte Unicode, emoji, whitespace, NUL/CR/LF/TAB/ESC, empty input.
- [ ] Exact character and byte boundaries, including a four-byte scalar at the
- [x] ASCII, multibyte Unicode, emoji, whitespace, NUL/CR/LF/TAB/ESC, empty input.
- [x] Exact character and byte boundaries, including a four-byte scalar at the
cutoff.
- [ ] Oversized paste never makes `state.chat_input` exceed either ceiling.
- [ ] Outgoing, incoming, and direct core/network paths converge on the same
- [x] Oversized paste never makes `state.chat_input` exceed either ceiling.
- [x] Outgoing, incoming, and direct core/network paths converge on the same
normalized result.
- [ ] Empty captions are retained only when a valid attachment remains.
- [x] Empty captions are retained only when a valid attachment remains.
## Phase 2 — Admission, identity binding, replay, and spam control
@@ -383,3 +385,15 @@ The plan is complete when:
privacy-oriented design.
- **2026-07-15:** Initial scope keeps all wire formats stable; hardening is local
admission, validation, resource accounting, and honest UI state.
- **2026-07-17 (Phase 1):** The 8 KiB byte ceiling deliberately cannot bind on
*sanitized* output (2,000 scalars × 4 bytes = 8,000 ≤ 8,192), so inside
`sanitize_chat`/`cap_chat_input` it is defense in depth; its operative role is
the raw-ingress reject in `admit_chat_text`.
- **2026-07-17 (Phase 1):** Interim until Phase 2's roster binding: the incoming
chat `name` now goes through the strict `sanitize_name` label sanitizer at the
UI edge (was the body sanitizer), so author labels already get bidi/zero-width
stripping and the 48-char label cap.
- **2026-07-17 (Phase 1):** `send_chat` at the gossip sign point silently no-ops
(Ok) on an empty-after-sanitize body with no attachment rather than erroring;
the UI already prevents this case, and Phase 5's send-status work is where
send-path feedback gets designed.