fix(security): Tor onions for gated ports forward to the gate, not the app

Tor carries no session cookie, so HiddenServicePort → 127.0.0.1:<port>
reached the app around the gate — the last transport the gate did not
cover. The gate now binds 127.0.0.2 (its own loopback, distinct from the
app's 127.0.0.1, so no app needs a second port), and regenerate_torrc
forwards declared-gated ports there. Undeclared ports keep today's
target: absence of the field is not an instruction.

The 127.0.0.2 claim deliberately does not count toward the unprotected
audit — a port whose only claim is the Tor loopback is still wide open
on the LAN and must keep warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-04 08:13:59 -04:00
co-authored by Claude Fable 5
parent 8210ca0a2a
commit 3760a00ea3
2 changed files with 88 additions and 5 deletions
+30
View File
@@ -44,6 +44,15 @@ use tracing::{debug, info, warn};
/// apps are installed while the daemon runs.
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
/// The gate's own loopback address, distinct from the app's `127.0.0.1`.
///
/// Tor cannot present a session cookie, so `HiddenServicePort → 127.0.0.1`
/// reaches the app around the gate. Instead torrc forwards gated ports to
/// this address (`api/rpc/tor`), where the gate — not the app — listens. A
/// second loopback address rather than a second port number, so no app needs
/// a port it did not declare.
pub const GATE_TOR_UPSTREAM: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 2));
/// A port the gate should own but could not claim, and why.
#[derive(Debug, Clone, serde::Serialize)]
pub struct UnprotectedPort {
@@ -192,6 +201,10 @@ async fn sweep(
let mut claimed_any = false;
let mut blocked = false;
// External addresses first, then the gate's Tor upstream. 127.0.0.2
// deliberately does NOT count toward `claimed_any`: the warning below
// is about external exposure, and a port whose only claim is the Tor
// loopback is still wide open on the LAN.
for &addr in &addresses {
let key = (app.port, addr);
if held.contains_key(&key) {
@@ -214,6 +227,23 @@ async fn sweep(
Err(_) => blocked = true,
}
}
let tor_key = (app.port, GATE_TOR_UPSTREAM);
if held.contains_key(&tor_key) {
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
} else {
match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await {
Ok(listener) => {
held.insert(tor_key, ());
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
info!(
port = app.port, app = %app.app_id,
"app gate claimed the Tor upstream (127.0.0.2)"
);
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
}
Err(_) => blocked = true,
}
}
if blocked && !claimed_any {
warn!(