fix(security): require a session for the LND connect info and Bitcoin RPC proxies
CRITICAL. Two app-UI ports handed unauthenticated callers full control of the node's money. Both verified live on archi-dev-box 2026-08-02 over the fips0 mesh ULA with no cookies. GET /lnd-connect-info returned 200 with the LND ADMIN MACAROON, the TLS cert, the gRPC/REST ports and the node's onion address — a complete remote wallet-drain package, and the onion means an attacker keeps that ability after losing network access. POST /bitcoin-rpc/ reached Bitcoin Core RPC with credentials the proxy injected on the caller's behalf, with a wallet loaded, so wallet methods were reachable too. Both were reachable because ports 18083 (lnd-ui) and 8334 (bitcoin-ui) bind 0.0.0.0 AND sit on the fips0 mesh allowlist in fips/app_ports.rs. Any mesh peer, LAN host or Tailscale peer could take either path. The root cause is one mistaken idea in two places: that a check performed by a reverse proxy is an auth check. It is not — it only holds for traffic that arrived through that proxy. /lnd-connect-info's comment said "nginx validates session cookie (presence check), backend is bound to 127.0.0.1 so only nginx can reach it". Both clauses were false in production: the lnd-ui container runs its OWN nginx on :18083 that proxies straight to the backend forwarding whatever cookies arrived, including none, and that second front door never performed the check the premise named. So authorisation moves to the resource: - /lnd-connect-info now requires a session, like /proxy/lnd/ beside it. The 401 carries CORS headers so the wallet UI shows a readable error rather than an opaque CORS failure. - New GET /auth/session-check returns 204/401 and nothing else, giving container nginx an auth_request gate it can actually use. - bitcoin-ui's /bitcoin-rpc/ is gated by that auth_request. Its `Access-Control-Allow-Origin *` is also gone: on a proxy that injects credentials, it let any page a user visited drive the node's RPC. Preflight is answered before the gate, since OPTIONS carries no cookies. The nginx template is include_str!'d and re-rendered on every reconcile pass, so this ships atomically with the binary. Operators must treat the LND admin macaroon and the Bitcoin RPC password on every affected node as compromised and rotate them AFTER this is deployed — rotating first just re-leaks through the same hole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
689c4cca1a
commit
a05956c4ce
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user