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
+22 -2
View File
@@ -871,6 +871,14 @@ fn format_duration(total_secs: u64) -> String {
}
}
/// First 8 characters of an id string for compact display. Panic-free: takes
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
/// render (security finding S1) — ids are long ASCII hex today, but this guards
/// the slice regardless.
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;
@@ -1420,7 +1428,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
container(text("")).padding(0)
},
horizontal_space(),
text(format!("My ID: {}", &state.self_id[..8]))
text(format!("My ID: {}", short_id(&state.self_id)))
.size(14)
.color(color_subtext),
button(
@@ -1596,7 +1604,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
row![
column![
text(&peer.name).size(16).color(color_text),
text(format!("ID: {}", &peer_id.to_string()[..8])).size(11).color(color_subtext)
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
],
horizontal_space(),
share_el,
@@ -2790,6 +2798,18 @@ mod tests {
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
}
#[test]
fn short_id_is_panic_free_on_short_and_unicode_ids() {
use super::short_id;
// Normal long hex id → first 8 chars.
assert_eq!(short_id("abcdef0123456789"), "abcdef01");
// Shorter-than-8 id must not panic (the old `[..8]` slice would).
assert_eq!(short_id("abc"), "abc");
assert_eq!(short_id(""), "");
// Multi-byte chars: take 8 *chars*, never split a byte boundary.
assert_eq!(short_id("héllo 世界 more"), "héllo 世界");
}
#[test]
fn divider_clamps_are_finite_on_a_tiny_window() {
// A window smaller than the reserves must not produce NaN/inverted ranges.