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:
co-authored by
Claude Fable 5
parent
8210ca0a2a
commit
3760a00ea3
@@ -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<u16> = 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<u16>,
|
||||
) -> 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<u16> = [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<u16> = [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) {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user