diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 3d5afcbb..fef490a8 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -600,12 +600,62 @@ impl ApiHandler { )) } - // LND connect info — nginx validates session cookie (presence check), - // backend is bound to 127.0.0.1 so only nginx can reach it. - // No backend auth check here because the LND UI iframe fetches this - // endpoint and the session cookie flow is validated at the nginx layer. + // Session probe for app-container nginx `auth_request` gates. + // + // App UIs run their own nginx and proxy selected paths into this + // backend. Some of those paths inject credentials the caller never + // supplied (bitcoin-ui's /bitcoin-rpc/ adds Bitcoin Core's Basic + // auth), which makes the proxy itself the authorization boundary — + // and nginx has no way to validate a session cookie on its own. This + // endpoint gives it one: 204 when the request carries a valid + // session, 401 otherwise. Body is deliberately empty; `auth_request` + // discards it and it must never become an oracle. + (Method::GET, "/auth/session-check") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .header("Cache-Control", "no-store") + .body(hyper::Body::empty()) + .unwrap()) + } + + // LND connect info — REQUIRES A SESSION. This response is a complete + // remote-control package for the node's Lightning wallet: the admin + // macaroon, the TLS cert, the gRPC/REST ports and the onion address. + // Anyone who receives it can drain the wallet from anywhere, and the + // onion means they keep that ability after losing network access. + // + // It used to carry no backend check, on two premises that were both + // false in production: + // + // "nginx validates the session cookie" — the MAIN nginx does. But + // the lnd-ui app container runs its OWN nginx on :18083 that + // proxies /lnd-connect-info straight here, forwarding whatever + // cookies arrived, including none. That second front door never + // performed the presence check the premise depended on. + // + // "the backend is bound to 127.0.0.1 so only nginx can reach it" — + // true of the backend socket, but irrelevant: :18083 is a reachable + // proxy INTO it, it binds 0.0.0.0, and it is explicitly on the + // fips0 mesh allowlist (fips/app_ports.rs). So an unauthenticated + // GET from any mesh peer, LAN host or Tailscale peer returned the + // admin macaroon. Verified live on archi-dev-box 2026-08-02. + // + // The lesson generalises: an auth check performed by one reverse + // proxy is not an auth check, because it only holds for traffic that + // arrived through that proxy. Authorisation belongs at the resource. + // Do not remove this in favour of a front-door check again. + // + // 401s carry CORS headers for the same reason /proxy/lnd/ does: the + // wallet UI fetches this cross-origin, so a bare 401 without them + // surfaces in the browser as an unreadable CORS failure. (Method::GET, "/lnd-connect-info") => { let origin = self.app_cors_origin(&headers); + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized_cors(&origin)); + } Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await } diff --git a/core/archipelago/src/container/bitcoin_ui_nginx.conf.template b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template index 43ad2ee6..0a5d5173 100644 --- a/core/archipelago/src/container/bitcoin_ui_nginx.conf.template +++ b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template @@ -3,17 +3,47 @@ server { server_name _; root /usr/share/nginx/html; index index.html; + # Session gate for the credential-injecting proxy below. Internal: it can + # only be reached by nginx's own auth_request subrequest, never by a client. + location = /_session_check { + internal; + proxy_pass http://127.0.0.1:5678/auth/session-check; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-CSRF-Token $http_x_csrf_token; + } + + # Bitcoin Core RPC. This proxy ADDS Bitcoin Core's Basic auth that the + # caller never supplied, so reaching this location at all is equivalent to + # holding the node's RPC credentials — which is why it must be gated here + # rather than anywhere upstream. + # + # It previously had no gate at all. This port (8334) binds 0.0.0.0 AND sits + # on the fips0 mesh allowlist (fips/app_ports.rs), so any mesh peer, LAN + # host or Tailscale peer could POST authenticated Bitcoin Core RPC — + # including wallet methods, with a wallet loaded. Verified live on + # archi-dev-box 2026-08-02. + # + # `Access-Control-Allow-Origin *` is also removed: paired with a proxy that + # injects credentials it let any web page a user visited drive this RPC. location /bitcoin-rpc/ { + # Preflight carries no cookies by design — answer it before the gate, + # otherwise the browser reports an opaque CORS failure instead of a 401. + if ($request_method = OPTIONS) { return 204; } + auth_request /_session_check; proxy_pass http://127.0.0.1:8332/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Authorization "Basic {{BITCOIN_RPC_AUTH}}"; - add_header Access-Control-Allow-Origin *; - add_header Access-Control-Allow-Methods "POST, GET, OPTIONS"; - add_header Access-Control-Allow-Headers "Content-Type, Authorization"; - if ($request_method = OPTIONS) { return 204; } + add_header Access-Control-Allow-Origin $scheme://$http_host always; + add_header Access-Control-Allow-Credentials "true" always; + add_header Vary "Origin" always; + add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; } location /bitcoin-status { proxy_pass http://127.0.0.1:5678/bitcoin-status;