feat(security): app gate — authenticate every app port
Demo images / Build & push demo images (push) Successful in 4m18s
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:
co-authored by
Claude Opus 5
parent
63d0183dd2
commit
0de67ca6ae
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user