fix(security): close S3 (arg-injection), S4 (presence-name), S1 (panic-slice)

Three findings from the first security pass:

- S3 (Medium): the peer-supplied screen-share ticket was passed to pixelpass
  as the first positional CLI arg with no end-of-options guard, so a ticket
  starting with `-`/`--` could be reinterpreted as a flag (argument injection).
  New pure `viewer_args()` puts flags first, then a `--` guard, then the ticket
  positionally; spawn_viewer uses it. +2 tests.

- S4 (Medium): peer presence display-names (gossip `Announce`, untrusted and
  spoofable) were rendered unsanitized/unbounded, unlike the chat path. New
  `sanitize::sanitize_name` strips bidi/zero-width format chars + control chars,
  collapses whitespace, and caps at 48 chars; applied at the gossip ingest point
  so every consumer gets a safe value. +4 tests.

- S1 (Low): `&id[..8]` byte-slices could panic on a short/non-ASCII id. New
  panic-free `short_id()` (char-based take) replaces both slices. +1 test.

158 lib tests (was 151), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:16:24 -04:00
co-authored by Claude Opus 4.8
parent 420535c5d3
commit fe627166d5
5 changed files with 148 additions and 6 deletions
+82
View File
@@ -0,0 +1,82 @@
//! Sanitizers for untrusted peer-supplied strings shared across layers.
//!
//! 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`).
/// 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
/// the roster readable.
pub const NAME_MAX_CHARS: usize = 48;
/// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a
/// rendered name even though they are NOT caught by [`char::is_control`]:
/// bidirectional overrides/isolates (text-direction spoofing) and
/// zero-width / BOM characters (invisible, can hide or fake content). Listed
/// explicitly so the sanitizer stays dependency-free (std exposes no category
/// query). Stripped outright rather than replaced.
fn is_spoofing_format_char(c: char) -> bool {
matches!(c,
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
| '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
| '\u{FEFF}' // BOM / zero-width no-break space
)
}
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
/// zero-width format characters, turns control characters into spaces, collapses
/// any whitespace run to a single space, trims the ends, and caps the length at
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
/// substitute a placeholder such as a short id).
pub fn sanitize_name(input: &str) -> String {
let cleaned: String = input
.chars()
.filter(|c| !is_spoofing_format_char(*c))
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed.chars().take(NAME_MAX_CHARS).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_ordinary_names_including_unicode() {
assert_eq!(sanitize_name("Alice"), "Alice");
assert_eq!(sanitize_name("héllo 世界"), "héllo 世界");
// Emoji are not format/control chars — kept.
assert_eq!(sanitize_name("🎙 Mike"), "🎙 Mike");
}
#[test]
fn strips_control_chars_and_collapses_whitespace() {
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
// collapse; ends trim.
assert_eq!(sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "), "a b c d [31m");
// A name that is only control/whitespace cleans to empty.
assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
}
#[test]
fn strips_bidi_and_zero_width_format_chars() {
// Right-to-left override (classic name-spoof char) is removed entirely.
assert_eq!(sanitize_name("admin\u{202E}txt"), "admintxt");
// Zero-width space / joiner / BOM removed.
assert_eq!(sanitize_name("ab\u{200B}cd"), "abcd");
assert_eq!(sanitize_name("\u{FEFF}name"), "name");
// Bidi isolates removed.
assert_eq!(sanitize_name("x\u{2066}y\u{2069}z"), "xyz");
}
#[test]
fn caps_length() {
let long = "n".repeat(NAME_MAX_CHARS + 500);
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
}
}