From 3760a00ea36e4038ecf0dcfc9ae036e95829d2bf Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 08:13:59 -0400 Subject: [PATCH] fix(security): Tor onions for gated ports forward to the gate, not the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tor carries no session cookie, so HiddenServicePort → 127.0.0.1: 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 --- core/archipelago/src/api/rpc/tor/mod.rs | 63 ++++++++++++++++++++++-- core/archipelago/src/appgate/listener.rs | 30 +++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/core/archipelago/src/api/rpc/tor/mod.rs b/core/archipelago/src/api/rpc/tor/mod.rs index aad7dcec..7b3cc5af 100644 --- a/core/archipelago/src/api/rpc/tor/mod.rs +++ b/core/archipelago/src/api/rpc/tor/mod.rs @@ -222,6 +222,18 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re lines.push("# ControlPort disabled for security".to_string()); lines.push(String::new()); + // Ports whose manifests declare `auth: gated` forward to the gate's own + // loopback (127.0.0.2, where the app-gate listener binds — see + // `appgate::listener::GATE_TOR_UPSTREAM`) instead of the app's 127.0.0.1. + // Tor carries no session cookie, so an onion pointed at the app is an + // unauthenticated bypass of the gate. Declared-gated ports only: an + // undeclared port keeps today's target, because absence of the field is + // not an instruction (the v1.7.121 incident rule). + let gated_ports: std::collections::HashSet = crate::appgate::identity::build_port_map() + .gated_ports() + .map(|g| g.port) + .collect(); + for svc in &config.services { if !svc.enabled { continue; @@ -240,7 +252,7 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string()); } } else { - lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port)); + lines.push(app_hidden_service_port_line(svc.local_port, &gated_ports)); } lines.push(String::new()); @@ -248,6 +260,24 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re let content = lines.join("\n"); let staging = "/var/lib/archipelago/tor-config/torrc.staged"; + write_staged_torrc(&content, staging).await +} + +/// The `HiddenServicePort` line for an HTTP app onion. Gated ports forward to +/// the gate's Tor upstream; everything else to the app itself. +fn app_hidden_service_port_line( + local_port: u16, + gated_ports: &std::collections::HashSet, +) -> String { + let upstream = if gated_ports.contains(&local_port) { + crate::appgate::listener::GATE_TOR_UPSTREAM.to_string() + } else { + "127.0.0.1".to_string() + }; + format!("HiddenServicePort 80 {}:{}", upstream, local_port) +} + +async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> { let config_dir = Path::new(staging) .parent() .unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config")); @@ -256,14 +286,37 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re .await .context("Failed to write staged torrc")?; - debug!( - "Staged torrc with {} enabled services", - config.services.iter().filter(|s| s.enabled).count() - ); + debug!("Staged torrc ({} bytes)", content.len()); Ok(()) } +#[cfg(test)] +mod torrc_tests { + use super::app_hidden_service_port_line; + use std::collections::HashSet; + + #[test] + fn gated_port_forwards_to_the_gate_not_the_app() { + let gated: HashSet = [8082u16].into_iter().collect(); + assert_eq!( + app_hidden_service_port_line(8082, &gated), + "HiddenServicePort 80 127.0.0.2:8082" + ); + } + + #[test] + fn undeclared_port_keeps_the_app_loopback_target() { + // Absence of `auth: gated` is not an instruction — the onion keeps + // pointing at the app, exactly as before this change. + let gated: HashSet = [8082u16].into_iter().collect(); + assert_eq!( + app_hidden_service_port_line(9100, &gated), + "HiddenServicePort 80 127.0.0.1:9100" + ); + } +} + // ─── Hostname Sync ─────────────────────────────────────────────── pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) { diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs index 10af2946..4e1a198d 100644 --- a/core/archipelago/src/appgate/listener.rs +++ b/core/archipelago/src/appgate/listener.rs @@ -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!(