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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user