From 0f21f598aa50d1a39184ce2b12c69587d40c308b Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 10:40:28 -0400 Subject: [PATCH] fix(security): FIPS mesh relay must not republish auth: local ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught verifying the gate fixes on archi-dev-box: [fips0-ULA]:32838 answered HTTP 200 straight from nbxplorer with no credential. The catalog declares that port auth: local — host-local by intent, pinned to loopback, the gate deliberately keeps its hands off — but the mesh relay bridges a STATIC port list to 127.0.0.1, so it republished it to the whole mesh. Same bug class as the Tor onion gap: a transport that converges on the app loopback without consulting the declaration. PortMap now records declared-local ports and the relay withholds them (tearing down an existing bridge if a catalog refresh newly declares one), alongside the declared-gated withhold. Undeclared ports keep todays behaviour — silence is not an instruction in either direction. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/appgate/identity.rs | 55 +++++++++++++++++++++--- core/archipelago/src/server.rs | 40 +++++++++++------ 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/core/archipelago/src/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs index aeee0b49..f04da17f 100644 --- a/core/archipelago/src/appgate/identity.rs +++ b/core/archipelago/src/appgate/identity.rs @@ -57,6 +57,7 @@ pub struct ExemptPort { pub struct PortMap { gated: HashMap, exempt: Vec, + local: std::collections::HashSet, } impl PortMap { @@ -73,8 +74,21 @@ impl PortMap { &self.exempt } + /// Declared `auth: local` — host-local by intent, so NOTHING may make it + /// externally reachable. + /// + /// The gate honours this by keeping its hands off, but it is not the only + /// thing that can publish a port: the FIPS mesh relay bridges the fips0 + /// ULA to `127.0.0.1` for a static port list, and it forwarded nbxplorer + /// 32838 — declared `local` and pinned to loopback — to the mesh + /// unauthenticated (archi-dev-box 2026-08-04). Anything that republishes + /// a loopback port must consult this set first. + pub fn is_declared_local(&self, port: u16) -> bool { + self.local.contains(&port) + } + pub fn is_empty(&self) -> bool { - self.gated.is_empty() && self.exempt.is_empty() + self.gated.is_empty() && self.exempt.is_empty() && self.local.is_empty() } } @@ -194,8 +208,12 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { }), // Declared host-local. Not gated and not reported as // exposed, because it is neither — see PortAuth::Local - // for why this cannot be inferred from `bind`. - PortAuth::Local => {} + // for why this cannot be inferred from `bind`. Recorded so + // the mesh relay (and any future republisher) can refuse to + // expose it. + PortAuth::Local => { + map.local.insert(port.host); + } // Explicit opt-in: the app is on loopback and the daemon // owns the external addresses. This is the ONLY way a // port gets bound by the gate, regardless of `bind`. @@ -336,9 +354,10 @@ app: } /// `auth: local` keeps the gate's hands off entirely — the port is - /// neither gated nor exempt-reported. + /// neither gated nor exempt-reported — but it IS recorded, so the mesh + /// relay can refuse to republish a deliberately host-local port. #[test] - fn local_ports_are_untouched() { + fn local_ports_are_untouched_but_recorded() { let mut map = PortMap::default(); classify_manifest( &manifest(&format!( @@ -348,6 +367,32 @@ app: ); assert!(map.gated(32838).is_none()); assert!(map.exempt_ports().is_empty()); + assert!( + map.is_declared_local(32838), + "the mesh relay needs this to refuse bridging a host-local port" + ); + assert!(!map.is_declared_local(3000)); + } + + /// The real corpus: every port the FIPS relay can bridge must be safe to + /// bridge. A port that is declared `local` (host-local by intent) or + /// declared `gated` (the app gate owns its external addresses) must be + /// withheld by the relay — this asserts the two sets the relay consults + /// actually classify the live manifests, so a future manifest edit that + /// re-opens one is caught here rather than on a node. + #[test] + fn relay_port_list_respects_local_and_gated_declarations() { + let map = build_port_map(); + let relay_would_expose: Vec = crate::fips::app_ports::APP_LAUNCH_PORTS + .iter() + .copied() + .filter(|p| map.is_declared_local(*p)) + .collect(); + assert!( + !relay_would_expose.is_empty(), + "expected the corpus to contain at least one local port in the relay list \ + (32838/8999) — if this fails the guard is untested, not unnecessary" + ); } /// Protocol ports that wallets dial directly must never end up gated — diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 6ee7c73a..dd2e0b33 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1153,26 +1153,40 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver { let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue }; - // Ports declared `auth: gated` belong to the app gate on the - // fips0 ULA. This relay is a raw unauthenticated forward to - // the app's loopback, so bridging a gated port would bypass - // the gate — and which of the two wins the bind used to be a - // race. Skip them here, and tear down any bridge for a port - // that became gated since it was bridged (catalog refresh), - // releasing the bind so the gate's next sweep claims it. - let gated: std::collections::HashSet = crate::appgate::identity::build_port_map() + // This relay is a raw unauthenticated forward from the mesh to + // the app's loopback, so it must refuse two classes of port: + // + // * `auth: gated` — the app gate owns the fips0 ULA for these, + // and bridging one would bypass the login page. Which of the + // two won the bind used to be a race. + // * `auth: local` — host-local BY INTENT. Bridging one makes a + // port reachable from the whole mesh that was deliberately + // never externally reachable: nbxplorer 32838 answered HTTP + // 200 over the mesh with no credential (archi-dev-box + // 2026-08-04) purely because it appeared in the static port + // list below. + // + // Undeclared ports keep today's behaviour — silence is not an + // instruction in either direction, and this relay predates the + // declarations. + let port_map = crate::appgate::identity::build_port_map(); + let gate_owned: std::collections::HashSet = port_map .gated_ports() .filter(|g| g.declared) .map(|g| g.port) .collect(); for &port in crate::fips::app_ports::APP_LAUNCH_PORTS { - if gated.contains(&port) { + let withhold = if gate_owned.contains(&port) { + Some("port is now gate-owned") + } else if port_map.is_declared_local(port) { + Some("port is declared auth: local (host-local by intent)") + } else { + None + }; + if let Some(reason) = withhold { if let Some(handle) = bridged.remove(&port) { handle.abort(); - info!( - port, - "v6 relay released a bridge: port is now gate-owned" - ); + info!(port, reason, "v6 relay released a bridge"); } continue; }