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
+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() {