chat: enforce shared text policy at UI, sign point, and gossip ingress
CI / check (push) Successful in 3m4s
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:
+29
-41
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user