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
+58 -5
View File
@@ -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) {