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:
+17
-15
@@ -49,16 +49,17 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
||||
serde_json::from_slice(bytes).context("failed to decode control message")
|
||||
}
|
||||
|
||||
/// Probe `peer` for presence: send a `Ping`, return their `Pong`. An error means
|
||||
/// no usable reply (offline / unreachable / refused / malformed) — the caller
|
||||
/// treats that as "appears offline". `peer` is usually a bare [`EndpointId`]
|
||||
/// (friends store the stable id); a full [`EndpointAddr`] is also accepted (and
|
||||
/// used by hermetic tests).
|
||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<ControlMsg> {
|
||||
/// Probe `peer` for presence: send a `Ping`, return their authenticated id and
|
||||
/// `Pong`. An error means no usable reply (offline / unreachable / refused /
|
||||
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
||||
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
||||
/// also accepted (and used by hermetic tests).
|
||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<(EndpointId, ControlMsg)> {
|
||||
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
|
||||
.await
|
||||
.context("timed out connecting to peer")?
|
||||
.context("failed to connect to peer")?;
|
||||
let from = conn.remote_id();
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?;
|
||||
@@ -77,7 +78,7 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
|
||||
.await
|
||||
.context("timed out awaiting pong")?;
|
||||
conn.close(VarInt::from_u32(0), b"done");
|
||||
result
|
||||
result.map(|msg| (from, msg))
|
||||
}
|
||||
|
||||
/// A reply policy: given the *authenticated* remote id, decide whether and how to
|
||||
@@ -110,6 +111,10 @@ async fn handle(incoming: Incoming, handler: Handler) -> Result<()> {
|
||||
async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> {
|
||||
// The authenticated remote id — NOT anything the peer puts in the payload.
|
||||
let from = conn.remote_id();
|
||||
let Some(reply) = handler(from) else {
|
||||
conn.close(VarInt::from_u32(0), b"not authorized");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
|
||||
@@ -118,13 +123,9 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
|
||||
ControlMsg::Ping => {}
|
||||
other => bail!("expected a ping, got {other:?}"),
|
||||
}
|
||||
// Ask the policy what to send. None -> answer nothing (stranger / invisible):
|
||||
// finish the stream with no bytes so the prober sees an empty (unusable) reply.
|
||||
if let Some(reply) = handler(from) {
|
||||
send.write_all(&encode(&reply)?)
|
||||
.await
|
||||
.context("failed to write pong")?;
|
||||
}
|
||||
send.write_all(&encode(&reply)?)
|
||||
.await
|
||||
.context("failed to write pong")?;
|
||||
send.finish().context("failed to finish reply stream")?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
@@ -220,10 +221,11 @@ mod tests {
|
||||
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
||||
|
||||
// The allowed prober gets a Pong with the room.
|
||||
let pong = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||
.await
|
||||
.expect("probe timed out")
|
||||
.expect("probe failed");
|
||||
assert_eq!(from, server_addr.id);
|
||||
match pong {
|
||||
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
|
||||
other => panic!("expected Pong with a room, got {other:?}"),
|
||||
|
||||
Reference in New Issue
Block a user