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:
co-authored by
Claude Fable 5
parent
d2e4b00789
commit
0f21f598aa
@@ -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 —
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user