feat(security): app gate — authenticate every app port
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>
This commit is contained in:
archipelago
2026-08-03 16:46:23 -04:00
co-authored by Claude Opus 5
parent 63d0183dd2
commit 0de67ca6ae
12 changed files with 1581 additions and 6 deletions
+56
View File
@@ -26,6 +26,29 @@ pub struct DeviceToken {
pub hash: String,
/// Unix seconds at mint time.
pub created: u64,
/// App ids this token may reach through the app gate.
///
/// `None` means node-wide, which is what every companion pairing token
/// is and what tokens minted before scoping existed remain — the field
/// is absent from their stored JSON and deserialises to `None`. A
/// migration that guessed a scope for them would silently revoke access
/// the operator never asked to revoke.
///
/// `Some(list)` restricts the token to exactly those apps, which is the
/// point of scoping: a token handed to Home Assistant so it can poll one
/// app's API should not also open every other app on the node.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<Vec<String>>,
}
impl DeviceToken {
/// Whether this token may reach `app_id`.
pub fn allows_app(&self, app_id: &str) -> bool {
match &self.apps {
None => true,
Some(apps) => apps.iter().any(|a| a == app_id),
}
}
}
fn tokens_path(data_dir: &Path) -> PathBuf {
@@ -61,6 +84,22 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool {
/// replaced, so re-showing the pairing QR never piles up stale entries.
/// Returns the plaintext token — the only time it ever exists outside the QR.
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
create_scoped(data_dir, name, None).await
}
/// Mint a token limited to `apps`, for a machine client that needs one app's
/// HTTP API and nothing else. `None` mints the node-wide token `create` does.
pub async fn create_scoped(
data_dir: &Path,
name: &str,
apps: Option<Vec<String>>,
) -> Result<String> {
// An empty list would be indistinguishable from "no restriction" to a
// careless reader while actually authorising nothing — reject it rather
// than mint a token whose behaviour nobody can predict from its record.
if apps.as_ref().is_some_and(|a| a.is_empty()) {
anyhow::bail!("a scoped device token must name at least one app");
}
// KEY-05: a device token is a bearer credential — its unpredictability is
// the whole of its security — so the source is named and the draw guarded.
let mut token_bytes = [0u8; 32];
@@ -81,6 +120,7 @@ pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
apps,
});
save(data_dir, &tokens).await?;
Ok(token)
@@ -96,6 +136,22 @@ pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
.map(|t| t.name.clone())
}
/// Verify a candidate token **for a specific app**, as the app gate does.
/// Returns the device name when the token is valid *and* in scope.
///
/// Separate from `verify` on purpose: `verify` answers "is this a real
/// token", which is the right question for node login, and would be the
/// wrong question here — a token scoped to one app would otherwise open
/// every app.
pub async fn verify_for_app(data_dir: &Path, candidate: &str, app_id: &str) -> Option<String> {
let candidate_hash = hash_hex(candidate);
load(data_dir)
.await
.iter()
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()) && t.allows_app(app_id))
.map(|t| t.name.clone())
}
/// List stored tokens (hashes only — plaintexts are unrecoverable).
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
load(data_dir).await