From 7eeb2294c940af66618c9d8903630b6addaee9db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:45:55 +0000 Subject: [PATCH] fix(auth): honor X-Forwarded-Proto when validating NIP-98 URLs Behind a TLS-terminating reverse proxy (bundled Caddy, Nginx Proxy Manager, ...) the app receives plain HTTP, and @hono/node-server derives the request scheme only from the socket. The browser signs the public https:// URL, so every authenticated dashboard call failed with "NIP-98 URL mismatch": the keypair stayed on "Generating...", no QR code rendered, and new connections never appeared. The middleware now takes the scheme from X-Forwarded-Proto (host still comes from the Host header, so a token signed for another site can't be replayed by spoofing X-Forwarded-Host). An optional PUBLIC_URL env var pins the origin for proxies that don't forward these headers. Fixes #1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WYVXxXnVonPB4UYazaLLRK --- .env.dist | 5 ++ .env.example | 5 ++ DEPLOY.md | 1 + README.md | 3 + packages/app/src/__tests__/nip98_auth.test.ts | 73 +++++++++++++++++++ packages/app/src/middleware/nip98.ts | 29 +++++++- 6 files changed, 115 insertions(+), 1 deletion(-) diff --git a/.env.dist b/.env.dist index 5036c87..76decd6 100644 --- a/.env.dist +++ b/.env.dist @@ -44,3 +44,8 @@ CERTBOT_EMAIL= # Caddy automatically provisions and renews TLS certificates for this domain. DOMAIN=localhost +# Optional. Public origin of the dashboard, e.g. https://bunker.example.com. +# Only needed behind a reverse proxy that does not forward the Host and +# X-Forwarded-Proto headers (NIP-98 auth compares against the exact public URL). +PUBLIC_URL= + diff --git a/.env.example b/.env.example index f551b1e..f7b9efb 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,8 @@ CERTBOT_EMAIL= # Primary domain for Bilo Bunker (e.g. bunker.example.com or localhost). DOMAIN=localhost +# Optional. Public origin of the dashboard, e.g. https://bunker.example.com. +# Only needed behind a reverse proxy that does not forward the Host and +# X-Forwarded-Proto headers (NIP-98 auth compares against the exact public URL). +PUBLIC_URL= + diff --git a/DEPLOY.md b/DEPLOY.md index 5ae8f1b..57c94c5 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -154,6 +154,7 @@ Produces a clean, transaction-consistent snapshot saved to `./backups/bunker_YYY |---|---|---|---| | **App** | Healthcheck failing (`502 Bad Gateway` or `Connection Refused`) | `OWNER_PUBKEY` variable missing or invalid hex length | Check container logs: `docker compose logs app`. Ensure `.env` contains valid 64-char hex public key for `OWNER_PUBKEY`. | | **Caddy / TLS** | HTTPS certificate errors or TLS challenge timeout | Port 80/443 blocked or DNS A-record not pointing to server IP | Ensure DNS A-record resolves to server IP and inbound ports 80/443 TCP/UDP are open in firewall. Check Caddy logs: `docker compose logs caddy`. | +| **Dashboard** | Logged in, but keypair stays on "Generating...", no QR code, new connections don't appear (API returns `401 NIP-98 URL mismatch`) | A custom reverse proxy (e.g. Nginx Proxy Manager) terminates TLS but the app sees `http://` or a different host | Make the proxy forward the `Host` header and set `X-Forwarded-Proto`, or set `PUBLIC_URL=https://your.domain` in the app environment. | | **Relay** | NIP-46 signing requests timed out | Outbound WebSocket connection failure or unreachable relay | View logs: `docker compose logs app \| grep "\[relay\]"`. Verify relay URLs start with `wss://`. | | **DB** | SQLite locks or permission error | File permission error on `/data` volume | Ensure container user (`bunker`) has write ownership of `/data` directory volume. | diff --git a/README.md b/README.md index e3b5f8c..451b334 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ docker run -d \ ghcr.io/workouse/bilo-bunker:latest ``` +> **Using your own reverse proxy (Nginx Proxy Manager, Traefik, nginx, ...)?** Make sure it forwards the original `Host` header and sets `X-Forwarded-Proto` (most do by default). Dashboard requests are authenticated with NIP-98, which signs the exact public URL, so the app has to know it was reached over `https://`. If your proxy can't forward these headers, set `PUBLIC_URL=https://bunker.example.com` instead. + --- ## ⚙️ Environment Variables Reference @@ -97,6 +99,7 @@ docker run -d \ | `PORT` | Node.js application server internal port | No | `3000` | | `DB_PATH` | Path to SQLite database file inside container | No | `/data/bunker.db` | | `LOG_LEVEL` | Application logging verbosity (`error`, `warn`, `info`, `debug`) | No | `info` | +| `PUBLIC_URL` | Public origin the dashboard is served from (e.g. `https://bunker.example.com`). Only needed if your reverse proxy does not forward the `Host` and `X-Forwarded-Proto` headers | No | `""` | --- diff --git a/packages/app/src/__tests__/nip98_auth.test.ts b/packages/app/src/__tests__/nip98_auth.test.ts index 088438a..8ecf6bc 100644 --- a/packages/app/src/__tests__/nip98_auth.test.ts +++ b/packages/app/src/__tests__/nip98_auth.test.ts @@ -36,6 +36,7 @@ describe('NIP-98 Authentication & Authorization Middleware', () => { delete process.env['NSEC']; delete process.env['OWNER_NPUB']; delete process.env['OWNER_PUBKEY']; + delete process.env['PUBLIC_URL']; }); afterEach(() => { @@ -112,4 +113,76 @@ describe('NIP-98 Authentication & Authorization Middleware', () => { expect(strangerData.error).toBe('Forbidden'); expect(strangerData.message).toContain('Single-user mode'); }); + + describe('behind a TLS-terminating reverse proxy', () => { + const publicUrl = 'https://bunker.example.com/api/v1/bunker/uri'; + const internalUrl = 'http://bunker.example.com/api/v1/bunker/uri'; + + it('accepts an https-signed request forwarded over http with X-Forwarded-Proto', async () => { + const bunker = new BunkerService(db); + const app = createApp(bunker, new RelayManager(bunker, db)); + + const res = await app.request(internalUrl, { + headers: { + Authorization: createNip98AuthHeader(generateSecretKey(), publicUrl, 'GET'), + 'X-Forwarded-Proto': 'https', + }, + }); + expect(res.status).toBe(200); + }); + + it('uses the first value of a comma-separated X-Forwarded-Proto chain', async () => { + const bunker = new BunkerService(db); + const app = createApp(bunker, new RelayManager(bunker, db)); + + const res = await app.request(internalUrl, { + headers: { + Authorization: createNip98AuthHeader(generateSecretKey(), publicUrl, 'GET'), + 'X-Forwarded-Proto': 'https, http', + }, + }); + expect(res.status).toBe(200); + }); + + it('still rejects an https-signed request without forwarding headers', async () => { + const bunker = new BunkerService(db); + const app = createApp(bunker, new RelayManager(bunker, db)); + + const res = await app.request(internalUrl, { + headers: { Authorization: createNip98AuthHeader(generateSecretKey(), publicUrl, 'GET') }, + }); + expect(res.status).toBe(401); + const body = (await res.json()) as { message: string }; + expect(body.message).toContain('NIP-98 URL mismatch'); + }); + + it('does not let X-Forwarded-Host replay a token signed for another host', async () => { + const bunker = new BunkerService(db); + const app = createApp(bunker, new RelayManager(bunker, db)); + + const res = await app.request(internalUrl, { + headers: { + Authorization: createNip98AuthHeader( + generateSecretKey(), + 'https://other-service.example.org/api/v1/bunker/uri', + 'GET' + ), + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'other-service.example.org', + }, + }); + expect(res.status).toBe(401); + }); + + it('uses PUBLIC_URL as the origin when set', async () => { + process.env['PUBLIC_URL'] = 'https://bunker.example.com'; + const bunker = new BunkerService(db); + const app = createApp(bunker, new RelayManager(bunker, db)); + + const res = await app.request('http://app:3000/api/v1/bunker/uri', { + headers: { Authorization: createNip98AuthHeader(generateSecretKey(), publicUrl, 'GET') }, + }); + expect(res.status).toBe(200); + }); + }); }); diff --git a/packages/app/src/middleware/nip98.ts b/packages/app/src/middleware/nip98.ts index 05efd04..5630e7a 100644 --- a/packages/app/src/middleware/nip98.ts +++ b/packages/app/src/middleware/nip98.ts @@ -2,10 +2,37 @@ import { Context, Next } from 'hono'; import { validateNip98Header } from '../services/nostr.js'; import type { BunkerService } from '../services/bunker.js'; +/** + * Reconstructs the URL the client actually called, so it can be compared to + * the NIP-98 `u` tag. + * + * Behind a TLS-terminating reverse proxy (Caddy, Nginx Proxy Manager, ...) + * the app receives plain HTTP, so `c.req.url` starts with `http://` while the + * browser signed `https://`. The scheme is taken from `X-Forwarded-Proto`; + * the host is kept from the Host header (forwarded by the proxy) so a token + * signed for another site cannot be replayed here by spoofing a header. + * `PUBLIC_URL` pins the origin explicitly when the proxy setup is unusual. + */ +export function getExternalRequestUrl(c: Context): string { + const url = new URL(c.req.url); + + const publicUrl = process.env.PUBLIC_URL?.trim(); + if (publicUrl) { + return new URL(url.pathname + url.search, new URL(publicUrl).origin).toString(); + } + + const forwardedProto = c.req.header('X-Forwarded-Proto')?.split(',')[0]?.trim().toLowerCase(); + if (forwardedProto === 'https' || forwardedProto === 'http') { + url.protocol = `${forwardedProto}:`; + } + + return url.toString(); +} + export function createNip98AuthMiddleware(bunkerService: BunkerService) { return async function nip98AuthMiddleware(c: Context, next: Next) { const authHeader = c.req.header('Authorization') || ''; - const url = c.req.url; + const url = getExternalRequestUrl(c); const method = c.req.method; const result = validateNip98Header(authHeader, url, method);