fix(security): FIPS v6 relay hands gated ports to the app gate

The mesh relay is a raw unauthenticated forward to the app's loopback,
and whether it or the gate owned a fips0 ULA port was decided by a bind
race — the dev box happened to be safe because the gate bound first.
The relay now skips ports declared auth: gated and tears down any
existing bridge for a port that became gated since it was bridged
(catalog refresh), releasing the bind for the gate's next sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-04 08:14:00 -04:00
co-authored by Claude Fable 5
parent 3760a00ea3
commit f08ed79b8a
+26 -5
View File
@@ -1145,16 +1145,37 @@ fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr {
/// without a daemon restart. Each relay binds to the fips0 ULA only and
/// forwards raw TCP to the same port on IPv4 loopback.
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
use std::collections::HashSet;
let mut bridged: HashSet<u16> = HashSet::new();
use std::collections::HashMap;
let mut bridged: HashMap<u16, tokio::task::JoinHandle<()>> = HashMap::new();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
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()
.gated_ports()
.map(|g| g.port)
.collect();
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
if bridged.contains(&port) {
if gated.contains(&port) {
if let Some(handle) = bridged.remove(&port) {
handle.abort();
info!(
port,
"v6 relay released a bridge: port is now gate-owned"
);
}
continue;
}
if bridged.contains_key(&port) {
continue;
}
// ONLY bridge a port that a running app already answers on
@@ -1181,10 +1202,9 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
// EADDRINUSE = fipsd or another process already answers
// on this mesh address/port, so stay out of the way.
let Ok(listener) = bind_v6_only(addr) else { continue };
bridged.insert(port);
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
let mut rx = shutdown_rx.clone();
tokio::spawn(async move {
let handle = tokio::spawn(async move {
loop {
tokio::select! {
accepted = listener.accept() => {
@@ -1205,6 +1225,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
}
}
});
bridged.insert(port, handle);
}
}
_ = shutdown_rx.changed() => return,