Security hardening: log redaction + 5 trust-boundary fixes (S10, T1/T2/T5/T6/T7)
Codex (gpt-5.5) implementer branch, senior-reviewed. - S10 (High): redact capabilities/chat from logs; create log 0600 + chmod existing; rotate at 5 MiB. New short_id/short_bytes_hex/redact_for_log seams. - T1 (P2): friends-ALPN authorizes (handler(from)) before reading any peer bytes; unauthorized conns closed pre-read (DoS relief). - T2 (P2): per-(author,kind) replay gate on state-changing gossip (Announce/ Leave) only; Chat bypasses it, preserving the S2 no-monotonic-ts decision. - T5 (P2): cap inbound Opus datagrams at 4 + MAX_OPUS_PAYLOAD (4000). - T6 (P2): bind friend-Pong room ticket host to the authenticated responder (interpret_pong/probe now thread the remote id) — blocks Join-button redirect/phishing. Non-regressive given the W7 P3 restamp design. - T7 (P3): sanitize_ticket caps/validates PeerState.sharing at gossip ingest so invalid offers never render a Watch button. 302 lib tests pass (was 291), clippy --all-targets clean, release builds. Tests-green only; DoS relief + 2-machine replay/redirect behavior want a field test. W7 P7 (n0 DNS privacy) reviewed read-only — findings to triage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+56
-1
@@ -25,6 +25,10 @@ use tokio::process::{Child, Command};
|
||||
/// points elsewhere.
|
||||
const PIXELPASS_BIN: &str = "pixelpass";
|
||||
|
||||
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
|
||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||
const MAX_TICKET_LEN: usize = 512;
|
||||
|
||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||
@@ -108,6 +112,19 @@ pub fn viewer_args(ticket: &str) -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Sanitize a peer-advertised pixelpass ticket at the gossip boundary. Peerspeak
|
||||
/// intentionally does not depend on pixelpass/iroh-tickets, so this validates the
|
||||
/// stable CLI ticket envelope we consume: bounded ASCII endpoint tickets beginning
|
||||
/// with `endpoint`. Invalid input becomes `None`, which removes the Watch button.
|
||||
pub fn sanitize_ticket(ticket: String) -> Option<String> {
|
||||
let ticket = ticket.trim();
|
||||
let valid_len = !ticket.is_empty() && ticket.len() <= MAX_TICKET_LEN;
|
||||
let valid_shape = ticket.starts_with("endpoint")
|
||||
&& ticket.len() > "endpoint".len()
|
||||
&& ticket.bytes().all(|b| b.is_ascii_alphanumeric());
|
||||
(valid_len && valid_shape).then(|| ticket.to_string())
|
||||
}
|
||||
|
||||
/// Resolve the pixelpass binary: an explicit config override (used only if it
|
||||
/// points at an existing file), otherwise the first `pixelpass` found on
|
||||
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An
|
||||
@@ -267,12 +284,29 @@ where
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||
crate::log_msg(&format!("pixelpass {role}: {ev:?}"));
|
||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn event_for_log(ev: &PixelpassEvent) -> String {
|
||||
match ev {
|
||||
PixelpassEvent::Ticket(ticket) => format!("ticket {}", crate::redact_for_log(ticket)),
|
||||
PixelpassEvent::Connected(_) => "connected".to_string(),
|
||||
PixelpassEvent::ViewerJoined { active, max } => {
|
||||
format!("viewer_joined active={active} max={max}")
|
||||
}
|
||||
PixelpassEvent::ViewerLeft { active, max } => {
|
||||
format!("viewer_left active={active} max={max}")
|
||||
}
|
||||
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
||||
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
||||
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
||||
PixelpassEvent::Other => "other".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
|
||||
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
|
||||
/// background task so it doesn't linger as a zombie when its window closes.
|
||||
@@ -340,6 +374,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
||||
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
|
||||
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None);
|
||||
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_log_redacts_ticket_values() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm".to_string();
|
||||
let log = event_for_log(&PixelpassEvent::Ticket(ticket.clone()));
|
||||
assert!(log.contains("endpoint"));
|
||||
assert!(!log.contains(&ticket["endpoint".len() + 8..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ticket() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user