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 -41
View File
@@ -1996,12 +1996,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
attachment,
} => {
// Incoming peer content is untrusted — sanitize name + text.
// Both were already sanitized at gossip ingress; this UI-edge
// pass is defense in depth (both sanitizers are idempotent).
// (The attachment filename was already sanitized in core.)
let text = sanitize_chat(&text);
let text = crate::sanitize::sanitize_chat(&text);
// Keep the message if it has visible text OR an attachment (an
// image with no caption is still a real message).
if !text.is_empty() || attachment.is_some() {
let name = sanitize_chat(&name);
// The author label is a spoof-sensitive short label, not
// body text — use the strict name sanitizer (Phase 2 of
// the chat-hardening plan replaces the wire name with the
// roster-bound name entirely).
let name = crate::sanitize::sanitize_name(&name);
push_chat(
&mut state.chat_messages,
ChatEntry {
@@ -2578,7 +2584,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
.send(CoreCommand::SetRecording(!state.recording));
}
AppMessage::ChatInputChanged(val) => {
state.chat_input = val;
// Cap (never normalize) while editing, so an oversized paste can't
// grow the retained input past the chat ceilings but the visible
// text doesn't jump under the user's cursor.
state.chat_input = crate::sanitize::cap_chat_input(val);
}
AppMessage::DividerDragged(kind, delta) => {
// Apply live; the final position is persisted on drag release (the
@@ -2906,7 +2915,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.playlist_drawer_open = !state.playlist_drawer_open;
}
AppMessage::ChatSubmit => {
let text = sanitize_chat(&state.chat_input);
let text = crate::sanitize::sanitize_chat(&state.chat_input);
if !text.is_empty() {
// Local echo (gossip suppresses our own author, so it won't come back).
push_chat(
@@ -3580,24 +3589,6 @@ fn short_id(id: &str) -> String {
id.chars().take(8).collect()
}
/// Max characters kept for a single chat message after sanitizing.
const CHAT_MSG_MAX_CHARS: usize = 2000;
/// Sanitize a chat string for display, applied to BOTH our outgoing text and
/// incoming text from peers (peer input is untrusted — a buggy/malicious sender
/// could include control characters or an enormous payload). Drops control
/// characters (ANSI escapes, NUL, stray CR/LF/TAB), collapses any whitespace run
/// to a single space, trims the ends, and caps the length. Returns "" for input
/// that is empty after cleaning (the caller skips empty messages).
fn sanitize_chat(input: &str) -> String {
let no_control: String = input
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = no_control.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed.chars().take(CHAT_MSG_MAX_CHARS).collect()
}
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
/// call can't grow the buffer without bound.
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
@@ -9839,27 +9830,24 @@ mod tests {
assert_eq!(clamp_chat_height(window_h, window_h), window_h - 300.0);
}
#[test]
fn sanitize_chat_strips_control_chars_and_collapses_whitespace() {
use super::sanitize_chat;
// Plain text is unchanged.
assert_eq!(sanitize_chat("hello world"), "hello world");
// Leading/trailing whitespace trimmed; interior runs collapsed.
assert_eq!(sanitize_chat(" hi there "), "hi there");
// Control chars (NUL, CR, LF, TAB, ANSI ESC) become spaces, then collapse.
// The ESC of an ANSI sequence is stripped; the printable "[31m" remains inert text.
assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
// An all-control / all-whitespace message sanitizes to empty.
assert_eq!(sanitize_chat("\u{0}\r\n\t "), "");
// Unicode/emoji text is preserved.
assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
}
// (sanitize_chat's own behavior is tested in `crate::sanitize`, where the
// chat text policy now lives.)
#[test]
fn sanitize_chat_caps_length() {
use super::{CHAT_MSG_MAX_CHARS, sanitize_chat};
let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
fn oversized_paste_never_exceeds_chat_ceilings_in_state() {
use crate::sanitize::{CHAT_MSG_MAX_BYTES, CHAT_MSG_MAX_CHARS};
let mut state = AppState::default();
// A paste far past both ceilings, through the real update handler
// (keyboard, clipboard, primary-selection, and context-menu paste all
// land on ChatInputChanged via the controlled input widget).
let huge = "🎮x ".repeat(CHAT_MSG_MAX_CHARS);
let _ = update(&mut state, AppMessage::ChatInputChanged(huge));
assert!(state.chat_input.chars().count() <= CHAT_MSG_MAX_CHARS);
assert!(state.chat_input.len() <= CHAT_MSG_MAX_BYTES);
// An in-bounds draft is stored untouched — no normalization mid-edit.
let draft = " spaced draft \t".to_string();
let _ = update(&mut state, AppMessage::ChatInputChanged(draft.clone()));
assert_eq!(state.chat_input, draft);
}
#[test]