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]
+23
View File
@@ -819,6 +819,21 @@ impl RoomState for IrohGossipState {
a.name = crate::files::sanitize_filename(&a.name);
Some(a)
});
// Chat text policy at INGRESS: reject raw text
// over the byte ceiling before spending any
// sanitize work on it (a compliant sender
// sanitizes before signing), and drop a message
// with neither visible text nor an attachment.
let Some(text) = crate::sanitize::admit_chat_text(
&text,
attachment.is_some(),
) else {
crate::log_msg(&format!(
"Dropped out-of-policy chat from author={:?} (oversized or empty)",
payload.author
));
continue;
};
let _ = event_tx
.send(RoomEvent::ChatMessage {
from: payload.author,
@@ -966,6 +981,14 @@ impl RoomState for IrohGossipState {
text: String,
attachment: Option<crate::files::ChatAttachment>,
) -> Result<(), NetError> {
// Enforce the chat text policy at the SIGN point, not only in the UI, so
// a future non-UI caller can't sign an out-of-policy body (chat-hardening
// plan Phase 1). Idempotent over the UI's own sanitize pass.
let text = crate::sanitize::sanitize_chat(&text);
if text.is_empty() && attachment.is_none() {
// Nothing visible to send — not an error, just nothing to do.
return Ok(());
}
let name = {
let guard = self.self_state.lock().unwrap();
match guard.as_ref() {
+203 -2
View File
@@ -3,8 +3,9 @@
//! Peer display names ride the gossip presence plane (`PeerState.name`), which is
//! untrusted and spoofable, yet they're rendered directly in the roster. This
//! module cleans a name at the gossip ingest point so every downstream consumer
//! gets a safe value (security finding S4). Chat text has its own sanitizer in
//! the UI layer (`app::sanitize_chat`).
//! gets a safe value (security finding S4). Chat text policy ([`sanitize_chat`],
//! [`cap_chat_input`], [`admit_chat_text`]) also lives here so the UI, the gossip
//! sign point, and the gossip ingress all enforce the same ceilings.
/// Max characters kept for a peer's display name after sanitizing. Names are
/// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps
@@ -73,6 +74,87 @@ pub fn sanitize_game_label(input: &str) -> String {
out
}
/// Max characters kept for a single chat message after sanitizing.
pub const CHAT_MSG_MAX_CHARS: usize = 2000;
/// Max UTF-8 bytes kept for a single chat message, enforced alongside
/// [`CHAT_MSG_MAX_CHARS`] (2,000 four-byte scalars would otherwise reach 8,000
/// bytes). This is also the ingress bound: signed peers never produce more, so
/// raw incoming text above it is rejected outright (see [`admit_chat_text`]).
pub const CHAT_MSG_MAX_BYTES: usize = 8 * 1024;
/// Sanitize a chat message body, applied to BOTH our outgoing text (before local
/// echo, and again at the gossip sign point) and incoming peer text (untrusted —
/// a buggy/malicious sender could include control characters or an enormous
/// payload). Single pass: control characters become spaces, any whitespace run
/// collapses to a single space, the ends are trimmed, and both the character and
/// UTF-8 byte ceilings are enforced without ever splitting a scalar. Message
/// bodies deliberately keep Unicode format characters (ZWJ/ZWNJ etc.) that the
/// short-label sanitizers strip — chat is expressive text, not a label. Returns
/// `""` for input with no visible text (callers drop empty messages). Idempotent,
/// so layered application converges on the same result.
pub fn sanitize_chat(input: &str) -> String {
let mut out = String::new();
let mut chars = 0usize;
let mut pending_space = false;
for c in input.chars() {
let c = if c.is_control() { ' ' } else { c };
if c.is_whitespace() {
// Trim: only mark a separator once visible text exists; a trailing
// run is never emitted because the space lands with the NEXT char.
pending_space = !out.is_empty();
continue;
}
let sep = usize::from(pending_space);
if chars + sep + 1 > CHAT_MSG_MAX_CHARS
|| out.len() + sep + c.len_utf8() > CHAT_MSG_MAX_BYTES
{
break;
}
if pending_space {
out.push(' ');
chars += 1;
pending_space = false;
}
out.push(c);
chars += 1;
}
out
}
/// Cap the LIVE chat-input text (typing, clipboard/primary-selection paste,
/// context-menu paste) at the chat ceilings. Unlike [`sanitize_chat`] this
/// preserves the user's whitespace exactly — normalization stays a submit-time
/// operation so the visible text never jumps while editing — and only truncates,
/// always on a scalar boundary. Returns the input unchanged when within bounds.
pub fn cap_chat_input(input: String) -> String {
if input.len() <= CHAT_MSG_MAX_BYTES && input.chars().count() <= CHAT_MSG_MAX_CHARS {
return input;
}
let mut out = String::new();
for (chars, c) in input.chars().enumerate() {
if chars >= CHAT_MSG_MAX_CHARS || out.len() + c.len_utf8() > CHAT_MSG_MAX_BYTES {
break;
}
out.push(c);
}
out
}
/// Gossip-ingress admission for an untrusted incoming chat body. `None` drops
/// the message: raw text over the byte ceiling is rejected BEFORE any
/// sanitization work (a compliant sender sanitizes before signing, so oversized
/// text is a protocol violation, not something to repair), and a message with
/// neither visible text nor an attachment carries nothing to show. Otherwise
/// yields the sanitized (possibly empty, attachment-only) body to forward.
pub fn admit_chat_text(raw: &str, has_attachment: bool) -> Option<String> {
if raw.len() > CHAT_MSG_MAX_BYTES {
return None;
}
let text = sanitize_chat(raw);
(!text.is_empty() || has_attachment).then_some(text)
}
/// A piece of a chat message after URL detection: literal text or a link.
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Segment {
@@ -215,6 +297,125 @@ mod tests {
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
}
// --- chat body policy ---------------------------------------------------
#[test]
fn chat_keeps_ordinary_text_and_unicode() {
assert_eq!(sanitize_chat("hello world"), "hello world");
assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
// Bodies keep format characters that label sanitizers strip: a ZWJ emoji
// family sequence survives intact.
let family = "👨\u{200D}👩\u{200D}👧";
assert_eq!(sanitize_chat(family), family);
}
#[test]
fn chat_strips_control_chars_and_collapses_whitespace() {
assert_eq!(sanitize_chat(" hi there "), "hi there");
assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
assert_eq!(sanitize_chat("\u{0}\r\n\t "), "");
assert_eq!(sanitize_chat(""), "");
}
#[test]
fn chat_caps_chars_at_exact_boundary_without_trailing_space() {
let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
assert_eq!(
sanitize_chat(&"x".repeat(CHAT_MSG_MAX_CHARS))
.chars()
.count(),
CHAT_MSG_MAX_CHARS
);
// Truncation never leaves a dangling separator: with "word " units the
// cut lands mid-run, and the output still ends on visible text.
let words = "word ".repeat(1000);
let out = sanitize_chat(&words);
assert!(out.chars().count() <= CHAT_MSG_MAX_CHARS);
assert!(!out.ends_with(' '));
}
#[test]
fn chat_ceilings_never_split_a_scalar() {
// Four-byte scalars: the char cap bites first (2,000 × 4 = 8,000 bytes,
// inside the byte ceiling by design) and the last emoji is kept whole.
let emoji = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
let out = sanitize_chat(&emoji);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.len() <= CHAT_MSG_MAX_BYTES);
assert!(out.chars().all(|c| c == '🎮'));
// Three-byte scalars at the char boundary.
let cjk = "".repeat(CHAT_MSG_MAX_CHARS + 1);
let out = sanitize_chat(&cjk);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.is_char_boundary(out.len()));
}
#[test]
fn chat_sanitize_is_idempotent() {
for input in [
"plain text",
" spaced \t out\r\n text ",
"unicode 🎙 世界 👨\u{200D}👩\u{200D}👧",
&"word ".repeat(1000),
&"🎮".repeat(CHAT_MSG_MAX_CHARS + 100),
] {
let once = sanitize_chat(input);
assert_eq!(sanitize_chat(&once), once, "not idempotent for {input:?}");
}
}
#[test]
fn cap_chat_input_preserves_whitespace_within_bounds() {
// In-bounds input comes back byte-identical — no normalization while
// the user is still editing.
let draft = " hello world \t ".to_string();
assert_eq!(cap_chat_input(draft.clone()), draft);
}
#[test]
fn cap_chat_input_truncates_oversized_paste_on_scalar_boundary() {
let paste = "x".repeat(CHAT_MSG_MAX_CHARS + 5000);
let out = cap_chat_input(paste);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
let emoji_paste = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
let out = cap_chat_input(emoji_paste);
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
assert!(out.len() <= CHAT_MSG_MAX_BYTES);
assert!(out.chars().all(|c| c == '🎮'));
}
#[test]
fn admit_rejects_oversized_raw_bytes_before_sanitizing() {
// One byte over the ceiling → rejected outright, attachment or not.
let over = "x".repeat(CHAT_MSG_MAX_BYTES + 1);
assert_eq!(admit_chat_text(&over, false), None);
assert_eq!(admit_chat_text(&over, true), None);
// Exactly at the ceiling → admitted (then sanitized/capped).
let at = "x".repeat(CHAT_MSG_MAX_BYTES);
let admitted = admit_chat_text(&at, false).expect("at-ceiling text admitted");
assert_eq!(admitted.chars().count(), CHAT_MSG_MAX_CHARS);
// Multibyte raw over the ceiling → rejected.
let cjk_over = "".repeat(CHAT_MSG_MAX_BYTES / 3 + 1);
assert!(cjk_over.len() > CHAT_MSG_MAX_BYTES);
assert_eq!(admit_chat_text(&cjk_over, false), None);
}
#[test]
fn admit_keeps_attachment_only_messages_and_drops_truly_empty_ones() {
// No visible text + no attachment → nothing to show, dropped.
assert_eq!(admit_chat_text("", false), None);
assert_eq!(admit_chat_text("\u{0}\r\n\t ", false), None);
// Same bodies WITH an attachment → kept as an empty caption.
assert_eq!(admit_chat_text("", true), Some(String::new()));
assert_eq!(admit_chat_text("\u{0}\r\n\t ", true), Some(String::new()));
// Normal text converges on the same result as direct sanitization.
assert_eq!(
admit_chat_text(" hi there ", false),
Some(sanitize_chat(" hi there "))
);
}
// --- linkify -----------------------------------------------------------
/// Concatenating every segment's inner text must reproduce the input exactly.
+1 -1
View File
@@ -21,7 +21,7 @@ const HIT_SEARCH_STEPS: usize = 24;
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
// offset WITHIN its buffer line — it discards the line number. That equals the
// global byte offset only when the text is a single logical line. Chat bodies
// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n`
// satisfy this because `sanitize::sanitize_chat` turns every control char (incl. `\n`
// and `\r`) into a space and collapses whitespace, so a stored message can never
// contain a newline. If that sanitizer ever starts preserving newlines, this
// widget's per-line offsets would stop being global and selection/copy across