fix(security): FIPS mesh relay must not republish auth: local ports

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-04 10:40:28 -04:00
co-authored by Claude Fable 5
parent d2e4b00789
commit 0f21f598aa
2 changed files with 77 additions and 18 deletions
+50 -5
View File
@@ -57,6 +57,7 @@ pub struct ExemptPort {
pub struct PortMap {
gated: HashMap<u16, GatedPort>,
exempt: Vec<ExemptPort>,
local: std::collections::HashSet<u16>,
}
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<u16> = 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 —
+27 -13
View File
@@ -1153,26 +1153,40 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
tokio::select! {
_ = interval.tick() => {
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<u16> = 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<u16> = 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;
}