Demo images / Build & push demo images (push) Successful in 4m18s
Reproduced again on this node today: with no session cookie, six app ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175 Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so served on every host address. Same bug class as the /lnd-connect-info and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app. LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>, so this is one gate rather than four. It lives in the daemon rather than a per-app sidecar (umbrel's app_proxy model): rootless, no extra container per app, and it can reuse machinery that already exists. It invents no authentication policy. verify_password, TOTP secret decryption, verify_code with used-step replay protection, the session store, and — importantly — the SAME LoginRateLimiter instance as the JSON-RPC path, so an attacker cannot get a fresh budget of password guesses by moving to an app port. Only the transport differs, an HTML form instead of JSON-RPC, because a browser being sent to an app cannot speak JSON-RPC. 2FA comes for free: a session still pending its TOTP step fails validate(), so the gate rejects it without knowing what a second factor is. Details worth keeping: - 401, not a redirect. A redirect to a login page is indistinguishable from the app itself redirecting, and machine clients would follow it and parse HTML as their API response. - Cookie and Authorization are stripped before proxying. The app has no use for the node session and must never be able to log or forward it. - The challenge page names and pictures the app being opened, so the visitor can confirm what they are authenticating to. - device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for machine clients. None = node-wide, which every existing companion token is; migrating them by guessing a scope would silently revoke access nobody asked to revoke. An empty list is rejected rather than minted, since it reads as unrestricted while authorising nothing. The rollout is necessarily per-app and the gate is built to say so. A container publishing 0.0.0.0:<port> claims every host address, so the gate cannot bind that port until the app is pinned to bind: 127.0.0.1 and recreated — gate-first is impossible, and all-at-once would recreate every container on a node simultaneously. Every port it cannot claim is logged at warn each sweep and recorded in GateStatus::unprotected, surfaced by security.app-gate-status. The failure mode being designed against is a gate that binds nothing, logs at debug, and reports success while every app stays exactly as open as before — worse than no gate, because it stops anyone looking. Same reasoning that ruled out an nft drop-in, whose absence is a silent no-op. Not yet done: pinning the 39 gated ports to loopback, repointing HiddenServicePort at the gate, and on-node verification. Tests: 21/21 appgate, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
60 lines
2.2 KiB
Rust
60 lines
2.2 KiB
Rust
//! `security.app-gate-status` — what the app gate is actually enforcing.
|
|
//!
|
|
//! The gate rolls out per app (an app must be pinned to loopback before the
|
|
//! gate can claim its port — see `appgate::listener`), so for a while every
|
|
//! node is partially protected. "Partially" is only safe if it is *visible*:
|
|
//! this is the RPC that lets the UI say which app ports are still reachable
|
|
//! without a credential, instead of the operator having to port-scan their
|
|
//! own node to find out.
|
|
|
|
use anyhow::Result;
|
|
|
|
use super::RpcHandler;
|
|
|
|
impl RpcHandler {
|
|
pub(in crate::api::rpc) async fn handle_app_gate_status(&self) -> Result<serde_json::Value> {
|
|
let status = crate::appgate::listener::shared_status();
|
|
let status = status.read().await.clone();
|
|
let port_map = self.app_gate.port_map().await;
|
|
|
|
// Exemptions are reported alongside, and with their manifest
|
|
// rationale, because "which ports are open and why" is the actual
|
|
// question — a list of unprotected ports without the deliberate ones
|
|
// next to it invites someone to "fix" LND's gRPC port and break every
|
|
// remote wallet.
|
|
let exempt: Vec<serde_json::Value> = port_map
|
|
.exempt_ports()
|
|
.iter()
|
|
.map(|e| {
|
|
serde_json::json!({
|
|
"port": e.port,
|
|
"app_id": e.app_id,
|
|
"protocol": e.protocol,
|
|
"rationale": e.rationale,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let gated: Vec<serde_json::Value> = port_map
|
|
.gated_ports()
|
|
.map(|g| {
|
|
serde_json::json!({
|
|
"port": g.port,
|
|
"app_id": g.app_id,
|
|
"app_name": g.app_name,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(serde_json::json!({
|
|
// The headline. False means this node still has app ports that
|
|
// answer without authentication.
|
|
"fully_enforced": status.is_fully_enforced(),
|
|
"claimed": status.claimed,
|
|
"unprotected": status.unprotected,
|
|
"gated": gated,
|
|
"exempt": exempt,
|
|
}))
|
|
}
|
|
}
|