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:
+40
-21
@@ -110,26 +110,32 @@ pub enum FriendPresence {
|
||||
InRoom { name: String, ticket: String },
|
||||
}
|
||||
|
||||
/// Interpret a peer's reply defensively. Only a `Pong` is a reply (a `Ping` is
|
||||
/// not, so it yields `None`). When the peer reports a room, we **sanitize the
|
||||
/// peer-supplied name** and **only surface it as joinable if the ticket actually
|
||||
/// parses** as a [`crate::network::PeerSpeakTicket`] — a garbage or hostile
|
||||
/// ticket downgrades the friend to plain `Online` rather than offering a dead /
|
||||
/// dangerous Join button. (We still never auto-join; the user clicks.)
|
||||
pub fn interpret_pong(msg: &ControlMsg) -> Option<FriendPresence> {
|
||||
/// Interpret a peer's reply defensively. `from` must be the connection's
|
||||
/// authenticated remote id, not any value carried in the payload. Only a `Pong`
|
||||
/// is a reply (a `Ping` is not, so it yields `None`). When the peer reports a
|
||||
/// room, we **sanitize the peer-supplied name** and **only surface it as joinable
|
||||
/// if the ticket actually parses** as a [`crate::network::PeerSpeakTicket`] and
|
||||
/// points back at the replying friend. A garbage/redirect ticket downgrades the
|
||||
/// friend to plain `Online` rather than offering a dead or attacker-controlled
|
||||
/// Join button. (We still never auto-join; the user clicks.)
|
||||
pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresence> {
|
||||
match msg {
|
||||
ControlMsg::Ping => None,
|
||||
ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
|
||||
ControlMsg::Pong { room: Some(r) } => {
|
||||
if r.ticket.parse::<crate::network::PeerSpeakTicket>().is_ok() {
|
||||
Some(FriendPresence::InRoom {
|
||||
name: crate::sanitize::sanitize_name(&r.name),
|
||||
ticket: r.ticket.clone(),
|
||||
})
|
||||
} else {
|
||||
let Ok(ticket) = r.ticket.parse::<crate::network::PeerSpeakTicket>() else {
|
||||
// Online, but the advertised room is unusable — don't offer Join.
|
||||
Some(FriendPresence::Online)
|
||||
return Some(FriendPresence::Online);
|
||||
};
|
||||
if ticket.host_addr.id != from {
|
||||
// Online, but the advertised room redirects away from the friend
|
||||
// who authenticated this Pong — don't offer a phishing Join.
|
||||
return Some(FriendPresence::Online);
|
||||
}
|
||||
Some(FriendPresence::InRoom {
|
||||
name: crate::sanitize::sanitize_name(&r.name),
|
||||
ticket: r.ticket.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,21 +212,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn interpret_ping_is_not_a_reply() {
|
||||
assert_eq!(interpret_pong(&ControlMsg::Ping), None);
|
||||
assert_eq!(interpret_pong(&ControlMsg::Ping, id()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_online_and_inroom() {
|
||||
let friend = id();
|
||||
// No room -> Online.
|
||||
assert_eq!(
|
||||
interpret_pong(&ControlMsg::Pong { room: None }),
|
||||
interpret_pong(&ControlMsg::Pong { room: None }, friend),
|
||||
Some(FriendPresence::Online)
|
||||
);
|
||||
// Valid ticket -> InRoom with a sanitized name.
|
||||
let t = valid_ticket(id());
|
||||
let t = valid_ticket(friend);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
|
||||
});
|
||||
}, friend);
|
||||
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
|
||||
}
|
||||
|
||||
@@ -230,17 +237,29 @@ mod tests {
|
||||
// Online — no dead/hostile Join button is surfaced.
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
|
||||
});
|
||||
}, id());
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_rejects_ticket_for_a_different_host() {
|
||||
let friend = id();
|
||||
let attacker = id();
|
||||
let t = valid_ticket(attacker);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
|
||||
}, friend);
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
||||
// Control/bidi characters in a peer-supplied name are stripped.
|
||||
let t = valid_ticket(id());
|
||||
let friend = id();
|
||||
let t = valid_ticket(friend);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
|
||||
});
|
||||
}, friend);
|
||||
match got {
|
||||
Some(FriendPresence::InRoom { name, .. }) => {
|
||||
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
||||
|
||||
Reference in New Issue
Block a user