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
@@ -156,13 +156,86 @@ The gate is mostly assembly, not invention:
|
||||
So the new code is: the listener/redirect, the app-identification step (which app is this port?),
|
||||
the login page render (app name + icon), and per-app scoping on `device_tokens`.
|
||||
|
||||
#### Research — StartOS: **NOT YET VERIFIED**
|
||||
#### Research — StartOS: **DROPPED** (operator, 2026-08-03)
|
||||
|
||||
Their public docs cover the *addressing* model (per-service `.onion` and `.local`
|
||||
addresses, an explicit "make public" opt-in for clearnet) but do not state whether a
|
||||
universal auth layer sits in front of service interfaces, and the source could not be
|
||||
read from this box (`gh` is not installed, and raw GitHub paths 404'd). **Do not assume
|
||||
they delegate auth to each service — read `Start9Labs/start-os` before designing.**
|
||||
"don't need the startOS research we decided on a approach already." The umbrelOS read
|
||||
plus the design decision above settled it; no further prior-art work.
|
||||
|
||||
### 1b. Manifest declaration of unauthenticated ports — **DONE** (`0c4826f8`, pushed)
|
||||
|
||||
`PortMapping` grew `auth` (`session` | `none`, defaulting to **`session`**) and
|
||||
`auth_rationale`. The default is the protected one, so exposure is now something a
|
||||
manifest has to ask for rather than something it gets by saying nothing.
|
||||
|
||||
Validation is two-sided: `auth: none` without a rationale is rejected, **and** a
|
||||
rationale without `auth: none` is rejected — that combination means the author wrote an
|
||||
exemption and did not get one, and shipping it silently would leave them believing
|
||||
otherwise.
|
||||
|
||||
**17 ports across 12 apps are exempt**, each with its reason: Lightning p2p (BOLT-8
|
||||
noise), LND gRPC 10009 / REST 18080 and CLN gRPC 9835 (macaroon / mutual TLS — this is
|
||||
what keeps Zeus and remote wallets working), Bitcoin p2p 8333, electrum 50001, the three
|
||||
Wyoming voice ports, git-over-SSH 2222, and the UDP discovery protocols (mDNS 5353,
|
||||
SSDP 1900, STUN 3478). **The other 39 published ports now default to gated.**
|
||||
|
||||
Bitcoin RPC 8332 is deliberately *not* exempted: it is already `bind: 127.0.0.1`, so the
|
||||
gate never sees it, and claiming an exemption it does not need would put a meaningless
|
||||
line in the audit list. If that bind is ever dropped it fails closed.
|
||||
|
||||
Two corpus tests pin this: every shipped manifest must parse, and the exempt set is
|
||||
frozen at 17 so the node's unauthenticated surface cannot grow by accident.
|
||||
|
||||
### 1c. The gate itself — **IN PROGRESS**
|
||||
|
||||
`core/archipelago/src/appgate/` — `identity.rs` (port → app id/name/icon, gated vs
|
||||
exempt, re-read from manifests so a catalog refresh applies without a restart),
|
||||
`mod.rs` (authorize + login page + TOTP step + reverse proxy), `listener.rs` (binds the
|
||||
external addresses, sweeps every 60s).
|
||||
|
||||
Design points worth not re-deriving:
|
||||
|
||||
- **It invents no auth policy.** `verify_password`, `totp::decrypt_secret`,
|
||||
`verify_code` + used-step replay protection, `SessionStore::create/create_pending/
|
||||
upgrade_to_full`, and the *same* `LoginRateLimiter` instance as the JSON-RPC path.
|
||||
Only the transport differs (HTML form vs JSON-RPC), because a browser being redirected
|
||||
to an app cannot speak JSON-RPC. Sharing the limiter matters: otherwise an attacker
|
||||
gets a fresh budget of password guesses by moving to an app port.
|
||||
- **2FA is free.** A session still pending its TOTP step fails `validate()`, so the gate
|
||||
rejects it without knowing anything about second factors.
|
||||
- **Cookies ignore port.** The session cookie is host-only with no `Domain`, so one
|
||||
sign-in covers the dashboard and every app port on the same host. The corollary is
|
||||
that an app reached on a *different* host — its own onion — is a separate sign-in.
|
||||
- **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.
|
||||
- **The gate strips `Cookie` and `Authorization` before proxying.** The app has no use
|
||||
for the node session and must never be in a position to log or forward it.
|
||||
- **Machine clients**: `device_tokens` grew `apps: Option<Vec<String>>` and
|
||||
`verify_for_app`. `None` = node-wide (what every existing companion token is —
|
||||
migrating them by guessing a scope would silently revoke access nobody asked to
|
||||
revoke); `Some(list)` restricts to those apps. An empty list is rejected rather than
|
||||
minted, since it would read as "unrestricted" while authorising nothing.
|
||||
|
||||
#### ⚠️ The ordering constraint that shapes the rollout
|
||||
|
||||
A published container port is bound `0.0.0.0:<port>`, which claims **every** host
|
||||
address. While the app holds that, the gate **cannot** bind `<lan-ip>:<port>` at all.
|
||||
So the gate can only stand in front of an app whose publish has been pinned to loopback
|
||||
(`bind: 127.0.0.1`) and whose container has been recreated. Gate-first is not possible;
|
||||
all-apps-at-once would recreate every container on the node simultaneously.
|
||||
|
||||
Therefore the rollout is **per app**, and the gate is built to be honest about being
|
||||
partially deployed: a port it cannot claim is logged at **warn** every sweep and recorded
|
||||
in `GateStatus::unprotected`. The failure mode this exists to prevent 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
|
||||
killed the nft drop-in: `/etc/fips/fips.nft` is provisioned out-of-band and its absence
|
||||
is a silent no-op.)
|
||||
|
||||
**Still open on this item:** pin the 39 gated ports to loopback app-by-app, repoint
|
||||
`HiddenServicePort` at the gate (Tor connects *from* loopback, so a loopback-exempt
|
||||
redirect will not catch it, and the mapping loses the original destination port), gate
|
||||
the FIPS relay path, surface `GateStatus` in the UI, and verify on a real node.
|
||||
|
||||
### 2. Filebrowser ships an insecure default login — **OPEN**
|
||||
- Change the default credential **without breaking the dashboard's Cloud view**, which
|
||||
@@ -289,6 +362,51 @@ The whole update *pipeline* is built and is already independent of OTA:
|
||||
3. **The detail-page affordance** — same treatment as the card.
|
||||
4. **Button copy**: "See update" rather than "Update".
|
||||
|
||||
### 6b. Multiversion for ALL apps + upstream release discovery — **OPEN** (operator, 2026-08-03)
|
||||
> "we also need a way to provide multiversion support for all apps and it automatically
|
||||
> pulls the latest versions from the source app repository, safely, and the user can
|
||||
> choose to update so we aren't always updating manually"
|
||||
|
||||
#### Verified 2026-08-03: the schema and runtime already exist
|
||||
|
||||
This is much less work than it sounds, because the multiversion machinery built for
|
||||
Bitcoin generalises as data rather than code:
|
||||
|
||||
- `releases/app-catalog.json` entries already support a `versions[]` array of
|
||||
`{version, image, default?, deprecated?}`.
|
||||
- Runtime is complete: `catalog_versions(app_id)`, `catalog_default_version`,
|
||||
`catalog_image_for_version`, `package.versions`, version pinning through
|
||||
`package.set-config`, and `available_update_for_app` falling back to the
|
||||
`image-versions.sh` baseline pin.
|
||||
|
||||
**It is populated for 2 of 66 apps** — `bitcoin-core` (9 versions) and `bitcoin-knots`
|
||||
(5). Every other app carries a single `version`. So "multiversion for all apps" is
|
||||
primarily a **catalog-generation and image-mirroring job**, not new runtime plumbing.
|
||||
|
||||
#### What has to be built
|
||||
|
||||
1. **Populate `versions[]` fleet-wide.** Extend `scripts/generate-app-catalog.py` to emit
|
||||
a version list per app instead of a single pin. Needs a per-app policy for how many
|
||||
historical versions to carry and which is `default` (Bitcoin's list shows the shape,
|
||||
including `deprecated: true` for old-but-installable).
|
||||
2. **Mirror the images.** A version in the catalog that is not in our registry is a
|
||||
broken promise — `package.update` would pull and fail. Use the existing skopeo path
|
||||
(`feedback_skopeo_source_selection`: prefer `.160`, concurrency ≤ 6).
|
||||
3. **An upstream release-watcher.** 48 manifests already carry a `repo:` URL under
|
||||
`metadata`, so there is something to poll (GitHub releases / registry tags). It runs
|
||||
**off-node**, as part of catalog generation.
|
||||
4. **Keep the signed catalog as the trust boundary.** This is the whole of "safely":
|
||||
the watcher **proposes** versions, the offline signing ceremony **admits** them, and
|
||||
nodes only ever install what the signed catalog carries. A node must never pull
|
||||
straight from an upstream repo — that would put an unsigned third party inside the
|
||||
supply chain, which is exactly what the signed-registry model exists to prevent.
|
||||
5. **The user chooses.** Discovery must never auto-apply. `package.check-updates` already
|
||||
refreshes and hot-reloads without touching the running containers, so "a new version
|
||||
exists" and "install it" stay separate — which is also what item 6's modal is for.
|
||||
|
||||
**Sequencing note:** 6b's step 1 and item 6's UI-vs-app classification want the same
|
||||
thing — `*-ui` images represented in the catalog. Doing that once unblocks both.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Carried over from v1.7.120
|
||||
|
||||
Reference in New Issue
Block a user