From a164f4490211703e7daabd5ff7f85a8a8932d79f Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Fri, 17 Jul 2026 11:04:42 -0700 Subject: [PATCH 1/8] fix(web): redirect unauthenticated remote dashboard to /login When auth is enabled on a non-loopback bind, serve /login instead of the SPA for clients without a session cookie. Remote users were seeing an empty "No providers configured" dashboard because /api/* returned 401. Local use is unchanged: localhost binds and loopback clients still auto-establish cookies. ABBENAY_HTTP_AUTH=0 remains the escape hatch for throwaway local development (loopback only). --- docs/CONFIGURATION.md | 6 ++ docs/GETTING_STARTED.md | 10 ++- .../src/daemon/web/http-security.test.ts | 73 +++++++++++++++++++ .../daemon/src/daemon/web/http-security.ts | 30 ++++++++ packages/daemon/src/daemon/web/server.ts | 20 ++++- .../tests/integration/http-security.test.ts | 7 ++ 6 files changed, 139 insertions(+), 7 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0f3bb49..3c739b6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -131,6 +131,12 @@ Binding to `0.0.0.0` requires an explicit opt-in and logs a warning — only do this when you intentionally expose the HTTP API (e.g. containers) and have set a strong token. +When auth is enabled and the HTTP server is bound beyond loopback, unauthenticated +browser requests to `/` (or `/index.html`) redirect to `/login`. Loopback clients +and localhost-only binds still auto-establish a session cookie for local use. +API routes (`/api/*`, `/v1/*`, `/mcp`) continue to return `401` JSON when +unauthenticated. + > **WARNING — disabling HTTP auth:** Auth is **on by default**. For throwaway > local development only you may set `ABBENAY_HTTP_AUTH=0`. That allows any > process (and any website that can reach the bind address) to call the diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 6f69d19..3c3205e 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -288,9 +288,13 @@ aby web ``` Open http://127.0.0.1:8787 in your browser (loopback clients get a session -automatically). For remote binds, use http://127.0.0.1:8787/login or -`POST /login` with the API token in the body — avoid putting the token in -the query string (it can leak via history, Referer, and logs). +automatically). For remote binds (`--host 0.0.0.0`), unauthenticated visits to +`/` redirect to `/login` — sign in with the API token (or `POST /login` with +the token in the body). Avoid putting the token in the query string (it can +leak via history, Referer, and logs). + +For throwaway local development only, `ABBENAY_HTTP_AUTH=0` disables HTTP auth +(loopback bind only; refused with `--host 0.0.0.0`). Use the dashboard to: diff --git a/packages/daemon/src/daemon/web/http-security.test.ts b/packages/daemon/src/daemon/web/http-security.test.ts index bbd9515..f53e7b1 100644 --- a/packages/daemon/src/daemon/web/http-security.test.ts +++ b/packages/daemon/src/daemon/web/http-security.test.ts @@ -5,6 +5,8 @@ import { describe, it, expect, afterEach } from 'vitest'; import { isLocalhostBind, + isLoopbackRemoteAddress, + shouldRedirectDashboardToLogin, isHttpAuthEnabled, assertHttpAuthBindAllowed, HttpAuthBindSecurityError, @@ -40,6 +42,77 @@ describe('isLocalhostBind', () => { }); }); +describe('isLoopbackRemoteAddress', () => { + it('accepts loopback peers', () => { + expect(isLoopbackRemoteAddress('127.0.0.1')).toBe(true); + expect(isLoopbackRemoteAddress('::1')).toBe(true); + expect(isLoopbackRemoteAddress('::ffff:127.0.0.1')).toBe(true); + }); + + it('rejects non-loopback peers', () => { + expect(isLoopbackRemoteAddress('192.168.0.24')).toBe(false); + expect(isLoopbackRemoteAddress('10.88.0.1')).toBe(false); + expect(isLoopbackRemoteAddress(undefined)).toBe(false); + }); +}); + +describe('shouldRedirectDashboardToLogin', () => { + it('does not redirect when auth is disabled', () => { + expect( + shouldRedirectDashboardToLogin({ + authEnabled: false, + hasValidAuthCookie: false, + bindHost: '0.0.0.0', + remoteAddress: '192.168.0.24', + }), + ).toBe(false); + }); + + it('does not redirect when a valid auth cookie is present', () => { + expect( + shouldRedirectDashboardToLogin({ + authEnabled: true, + hasValidAuthCookie: true, + bindHost: '0.0.0.0', + remoteAddress: '192.168.0.24', + }), + ).toBe(false); + }); + + it('does not redirect on localhost bind (local auto-session)', () => { + expect( + shouldRedirectDashboardToLogin({ + authEnabled: true, + hasValidAuthCookie: false, + bindHost: '127.0.0.1', + remoteAddress: '127.0.0.1', + }), + ).toBe(false); + }); + + it('does not redirect for loopback clients on non-loopback bind', () => { + expect( + shouldRedirectDashboardToLogin({ + authEnabled: true, + hasValidAuthCookie: false, + bindHost: '0.0.0.0', + remoteAddress: '127.0.0.1', + }), + ).toBe(false); + }); + + it('redirects remote clients on non-loopback bind without a cookie', () => { + expect( + shouldRedirectDashboardToLogin({ + authEnabled: true, + hasValidAuthCookie: false, + bindHost: '0.0.0.0', + remoteAddress: '192.168.0.24', + }), + ).toBe(true); + }); +}); + describe('isHttpAuthEnabled', () => { const prev = process.env.ABBENAY_HTTP_AUTH; diff --git a/packages/daemon/src/daemon/web/http-security.ts b/packages/daemon/src/daemon/web/http-security.ts index 788be2d..f29087c 100644 --- a/packages/daemon/src/daemon/web/http-security.ts +++ b/packages/daemon/src/daemon/web/http-security.ts @@ -95,6 +95,36 @@ export function isLocalhostBind(host: string): boolean { return h === '127.0.0.1' || h === '::1' || h === 'localhost'; } +/** + * Return true when the TCP peer looks like loopback. + */ +export function isLoopbackRemoteAddress(addr?: string | null): boolean { + const a = (addr || '').trim(); + return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1'; +} + +/** + * Whether an unauthenticated dashboard HTML request should 302 to `/login`. + * + * Localhost-only binds and loopback clients keep auto-session cookies for + * local use. Remote clients on a non-loopback bind must use `/login` first — + * otherwise the SPA loads and `/api/*` returns 401, which looks like an empty + * config ("No providers configured"). + * + * No-op when auth is disabled (`ABBENAY_HTTP_AUTH=0`) or a valid auth cookie + * is already present. + */ +export function shouldRedirectDashboardToLogin(opts: { + authEnabled: boolean; + hasValidAuthCookie: boolean; + bindHost: string; + remoteAddress?: string | null; +}): boolean { + if (!opts.authEnabled || opts.hasValidAuthCookie) return false; + if (isLocalhostBind(opts.bindHost)) return false; + return !isLoopbackRemoteAddress(opts.remoteAddress); +} + /** * Thrown when HTTP auth is disabled on a non-loopback bind (fail-closed). */ diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index 39febf7..bf18d64 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -35,6 +35,8 @@ import { API_TOKEN_COOKIE, CSRF_COOKIE, isLocalhostBind, + isLoopbackRemoteAddress, + shouldRedirectDashboardToLogin, assertHttpAuthBindAllowed, type WebSecurityOptions, type ResolvedHttpSecurity, @@ -125,10 +127,8 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): next(); }); - const isLoopbackClient = (req: Request): boolean => { - const addr = req.socket.remoteAddress || ''; - return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'; - }; + const isLoopbackClient = (req: Request): boolean => + isLoopbackRemoteAddress(req.socket.remoteAddress); const cookieOpts = (req: Request) => ({ secure: cookieSecureFromRequest(req) }); @@ -192,6 +192,18 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): } const hasAuthCookie = hasValidAuthCookie(req); + if ( + shouldRedirectDashboardToLogin({ + authEnabled: security.authEnabled, + hasValidAuthCookie: hasAuthCookie, + bindHost: security.host, + remoteAddress: req.socket.remoteAddress, + }) + ) { + res.redirect(302, '/login'); + return; + } + const mayEstablishSession = hasAuthCookie || isLoopbackClient(req) || isLocalhostBind(security.host); let csrf = getCookie(req, CSRF_COOKIE); diff --git a/packages/daemon/tests/integration/http-security.test.ts b/packages/daemon/tests/integration/http-security.test.ts index f7082be..bb6894a 100644 --- a/packages/daemon/tests/integration/http-security.test.ts +++ b/packages/daemon/tests/integration/http-security.test.ts @@ -370,6 +370,13 @@ describe('dashboard login', () => { expect(String(res.body)).toContain('action="/login"'); expect(String(res.body)).toContain('name="token"'); }); + + it('GET / on localhost bind still serves the dashboard without a prior cookie', async () => { + // Default test bind is 127.0.0.1 — auto-session for local use (no redirect). + const res = await httpRequest('GET', '/', { token: null }); + expect(res.statusCode).toBe(200); + expect(String(res.body)).toContain('Abbenay'); + }); }); describe('CORS allowlist', () => { From 06aa936ec00d081c881cded6945c541a281f44e2 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Fri, 17 Jul 2026 11:41:28 -0700 Subject: [PATCH 2/8] fix(web): require local Host for dashboard auto-session Reverse proxies often appear as loopback peers after TLS termination. Gate auto-session and skip-redirect on both local TCP peer and local Host/X-Forwarded-Host so public hostnames always hit /login. Addresses Copilot review on #79. --- docs/CONFIGURATION.md | 11 ++-- .../src/daemon/web/http-security.test.ts | 47 ++++++++++++-- .../daemon/src/daemon/web/http-security.ts | 63 +++++++++++++++++-- packages/daemon/src/daemon/web/server.ts | 25 ++++---- 4 files changed, 118 insertions(+), 28 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3c739b6..8700299 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -131,11 +131,12 @@ Binding to `0.0.0.0` requires an explicit opt-in and logs a warning — only do this when you intentionally expose the HTTP API (e.g. containers) and have set a strong token. -When auth is enabled and the HTTP server is bound beyond loopback, unauthenticated -browser requests to `/` (or `/index.html`) redirect to `/login`. Loopback clients -and localhost-only binds still auto-establish a session cookie for local use. -API routes (`/api/*`, `/v1/*`, `/mcp`) continue to return `401` JSON when -unauthenticated. +When auth is enabled, unauthenticated browser requests to `/` (or `/index.html`) +redirect to `/login` unless both the TCP peer and the browser `Host` / +`X-Forwarded-Host` are localhost (direct local use). That way a reverse proxy +that connects over loopback still cannot auto-establish a session for a public +hostname. API routes (`/api/*`, `/v1/*`, `/mcp`) continue to return `401` JSON +when unauthenticated. > **WARNING — disabling HTTP auth:** Auth is **on by default**. For throwaway > local development only you may set `ABBENAY_HTTP_AUTH=0`. That allows any diff --git a/packages/daemon/src/daemon/web/http-security.test.ts b/packages/daemon/src/daemon/web/http-security.test.ts index f53e7b1..fcb9d55 100644 --- a/packages/daemon/src/daemon/web/http-security.test.ts +++ b/packages/daemon/src/daemon/web/http-security.test.ts @@ -6,7 +6,9 @@ import { describe, it, expect, afterEach } from 'vitest'; import { isLocalhostBind, isLoopbackRemoteAddress, + isLocalDashboardHost, shouldRedirectDashboardToLogin, + mayAutoEstablishDashboardSession, isHttpAuthEnabled, assertHttpAuthBindAllowed, HttpAuthBindSecurityError, @@ -56,6 +58,19 @@ describe('isLoopbackRemoteAddress', () => { }); }); +describe('isLocalDashboardHost', () => { + it('accepts localhost Host values', () => { + expect(isLocalDashboardHost('localhost:8787')).toBe(true); + expect(isLocalDashboardHost('127.0.0.1:8787')).toBe(true); + expect(isLocalDashboardHost('[::1]:8787')).toBe(true); + }); + + it('rejects public hostnames', () => { + expect(isLocalDashboardHost('abbenay.20665.net')).toBe(false); + expect(isLocalDashboardHost('abbenay.example:443')).toBe(false); + }); +}); + describe('shouldRedirectDashboardToLogin', () => { it('does not redirect when auth is disabled', () => { expect( @@ -64,6 +79,7 @@ describe('shouldRedirectDashboardToLogin', () => { hasValidAuthCookie: false, bindHost: '0.0.0.0', remoteAddress: '192.168.0.24', + hostHeader: 'abbenay.example', }), ).toBe(false); }); @@ -75,39 +91,62 @@ describe('shouldRedirectDashboardToLogin', () => { hasValidAuthCookie: true, bindHost: '0.0.0.0', remoteAddress: '192.168.0.24', + hostHeader: 'abbenay.example', }), ).toBe(false); }); - it('does not redirect on localhost bind (local auto-session)', () => { + it('does not redirect for direct local access (loopback peer + local Host)', () => { expect( shouldRedirectDashboardToLogin({ authEnabled: true, hasValidAuthCookie: false, - bindHost: '127.0.0.1', + bindHost: '0.0.0.0', remoteAddress: '127.0.0.1', + hostHeader: '127.0.0.1:8787', }), ).toBe(false); + expect( + mayAutoEstablishDashboardSession({ + authEnabled: true, + hasValidAuthCookie: false, + bindHost: '127.0.0.1', + remoteAddress: '127.0.0.1', + hostHeader: 'localhost:8787', + }), + ).toBe(true); }); - it('does not redirect for loopback clients on non-loopback bind', () => { + it('redirects when reverse-proxy peer is loopback but Host is public', () => { + // Copilot: TLS-terminated proxy often appears as loopback remoteAddress expect( shouldRedirectDashboardToLogin({ authEnabled: true, hasValidAuthCookie: false, bindHost: '0.0.0.0', remoteAddress: '127.0.0.1', + hostHeader: 'abbenay.20665.net', + }), + ).toBe(true); + expect( + mayAutoEstablishDashboardSession({ + authEnabled: true, + hasValidAuthCookie: false, + bindHost: '0.0.0.0', + remoteAddress: '127.0.0.1', + hostHeader: 'abbenay.20665.net', }), ).toBe(false); }); - it('redirects remote clients on non-loopback bind without a cookie', () => { + it('redirects remote clients without a cookie', () => { expect( shouldRedirectDashboardToLogin({ authEnabled: true, hasValidAuthCookie: false, bindHost: '0.0.0.0', remoteAddress: '192.168.0.24', + hostHeader: '192.168.0.3:8787', }), ).toBe(true); }); diff --git a/packages/daemon/src/daemon/web/http-security.ts b/packages/daemon/src/daemon/web/http-security.ts index f29087c..0c7ed73 100644 --- a/packages/daemon/src/daemon/web/http-security.ts +++ b/packages/daemon/src/daemon/web/http-security.ts @@ -103,13 +103,46 @@ export function isLoopbackRemoteAddress(addr?: string | null): boolean { return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1'; } +/** + * Return true when the browser Host (or first X-Forwarded-Host) is loopback. + * + * Used so TLS-terminated reverse proxies (peer often loopback) cannot trigger + * auto-session cookies for public hostnames. + */ +export function isLocalDashboardHost(hostHeader?: string | string[] | null): boolean { + const raw = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; + const first = (raw || '').split(',')[0]?.trim().toLowerCase() || ''; + if (!first) return false; + // Strip port; handle [IPv6]:port + const hostname = first.startsWith('[') + ? (first.match(/^\[([^\]]+)\]/)?.[1] || first) + : first.replace(/:\d+$/, ''); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; +} + +/** + * Prefer X-Forwarded-Host (first value) then Host — same trust model as + * X-Forwarded-Proto for Secure cookies. + */ +export function requestDashboardHost(req: { + headers: { host?: string | string[]; 'x-forwarded-host'?: string | string[] }; +}): string | undefined { + const xf = req.headers['x-forwarded-host']; + if (typeof xf === 'string' && xf.trim()) return xf; + if (Array.isArray(xf) && xf[0]) return xf[0]; + const host = req.headers.host; + if (typeof host === 'string') return host; + if (Array.isArray(host)) return host[0]; + return undefined; +} + /** * Whether an unauthenticated dashboard HTML request should 302 to `/login`. * - * Localhost-only binds and loopback clients keep auto-session cookies for - * local use. Remote clients on a non-loopback bind must use `/login` first — - * otherwise the SPA loads and `/api/*` returns 401, which looks like an empty - * config ("No providers configured"). + * Auto-session (skip redirect) only when the TCP peer is local **and** the + * browser Host / X-Forwarded-Host is localhost. That keeps `aby web` on + * 127.0.0.1 convenient, but forces `/login` when a reverse proxy presents a + * public hostname (peer is often loopback after TLS termination). * * No-op when auth is disabled (`ABBENAY_HTTP_AUTH=0`) or a valid auth cookie * is already present. @@ -119,10 +152,28 @@ export function shouldRedirectDashboardToLogin(opts: { hasValidAuthCookie: boolean; bindHost: string; remoteAddress?: string | null; + hostHeader?: string | string[] | null; }): boolean { if (!opts.authEnabled || opts.hasValidAuthCookie) return false; - if (isLocalhostBind(opts.bindHost)) return false; - return !isLoopbackRemoteAddress(opts.remoteAddress); + const localPeer = isLocalhostBind(opts.bindHost) || isLoopbackRemoteAddress(opts.remoteAddress); + const localHost = isLocalDashboardHost(opts.hostHeader); + return !(localPeer && localHost); +} + +/** + * Whether the dashboard may auto-establish session cookies without /login. + * Same locality rules as {@link shouldRedirectDashboardToLogin}. + */ +export function mayAutoEstablishDashboardSession(opts: { + authEnabled: boolean; + hasValidAuthCookie: boolean; + bindHost: string; + remoteAddress?: string | null; + hostHeader?: string | string[] | null; +}): boolean { + if (!opts.authEnabled) return true; + if (opts.hasValidAuthCookie) return true; + return !shouldRedirectDashboardToLogin({ ...opts, hasValidAuthCookie: false }); } /** diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index bf18d64..511f8ab 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -35,8 +35,9 @@ import { API_TOKEN_COOKIE, CSRF_COOKIE, isLocalhostBind, - isLoopbackRemoteAddress, shouldRedirectDashboardToLogin, + mayAutoEstablishDashboardSession, + requestDashboardHost, assertHttpAuthBindAllowed, type WebSecurityOptions, type ResolvedHttpSecurity, @@ -127,9 +128,6 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): next(); }); - const isLoopbackClient = (req: Request): boolean => - isLoopbackRemoteAddress(req.socket.remoteAddress); - const cookieOpts = (req: Request) => ({ secure: cookieSecureFromRequest(req) }); const hasValidAuthCookie = (req: Request): boolean => { @@ -192,19 +190,20 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): } const hasAuthCookie = hasValidAuthCookie(req); - if ( - shouldRedirectDashboardToLogin({ - authEnabled: security.authEnabled, - hasValidAuthCookie: hasAuthCookie, - bindHost: security.host, - remoteAddress: req.socket.remoteAddress, - }) - ) { + const hostHeader = requestDashboardHost(req); + const locality = { + authEnabled: security.authEnabled, + hasValidAuthCookie: hasAuthCookie, + bindHost: security.host, + remoteAddress: req.socket.remoteAddress, + hostHeader, + }; + if (shouldRedirectDashboardToLogin(locality)) { res.redirect(302, '/login'); return; } - const mayEstablishSession = hasAuthCookie || isLoopbackClient(req) || isLocalhostBind(security.host); + const mayEstablishSession = mayAutoEstablishDashboardSession(locality); let csrf = getCookie(req, CSRF_COOKIE); if (mayEstablishSession && (!hasAuthCookie || !csrf)) { From e77d9a6e004ab5c0d36f814bef2a5e07197eb79e Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Fri, 17 Jul 2026 11:55:20 -0700 Subject: [PATCH 3/8] fix(web): harden dashboard Host locality checks Preserve bare IPv6 loopback Host values, take the first X-Forwarded-Host entry, and cover both in unit tests. --- .../src/daemon/web/http-security.test.ts | 25 ++++++++++++++++- .../daemon/src/daemon/web/http-security.ts | 27 +++++++++++-------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/daemon/src/daemon/web/http-security.test.ts b/packages/daemon/src/daemon/web/http-security.test.ts index fcb9d55..da98fc8 100644 --- a/packages/daemon/src/daemon/web/http-security.test.ts +++ b/packages/daemon/src/daemon/web/http-security.test.ts @@ -7,6 +7,7 @@ import { isLocalhostBind, isLoopbackRemoteAddress, isLocalDashboardHost, + requestDashboardHost, shouldRedirectDashboardToLogin, mayAutoEstablishDashboardSession, isHttpAuthEnabled, @@ -63,6 +64,7 @@ describe('isLocalDashboardHost', () => { expect(isLocalDashboardHost('localhost:8787')).toBe(true); expect(isLocalDashboardHost('127.0.0.1:8787')).toBe(true); expect(isLocalDashboardHost('[::1]:8787')).toBe(true); + expect(isLocalDashboardHost('::1')).toBe(true); }); it('rejects public hostnames', () => { @@ -71,6 +73,27 @@ describe('isLocalDashboardHost', () => { }); }); +describe('requestDashboardHost', () => { + it('prefers the first X-Forwarded-Host value', () => { + expect( + requestDashboardHost({ + headers: { + host: '127.0.0.1:8787', + 'x-forwarded-host': 'abbenay.example, internal.local', + }, + }), + ).toBe('abbenay.example'); + }); + + it('falls back to Host when X-Forwarded-Host is absent', () => { + expect( + requestDashboardHost({ + headers: { host: 'localhost:8787' }, + }), + ).toBe('localhost:8787'); + }); +}); + describe('shouldRedirectDashboardToLogin', () => { it('does not redirect when auth is disabled', () => { expect( @@ -118,7 +141,7 @@ describe('shouldRedirectDashboardToLogin', () => { }); it('redirects when reverse-proxy peer is loopback but Host is public', () => { - // Copilot: TLS-terminated proxy often appears as loopback remoteAddress + // TLS-terminated proxies often present as loopback remoteAddress expect( shouldRedirectDashboardToLogin({ authEnabled: true, diff --git a/packages/daemon/src/daemon/web/http-security.ts b/packages/daemon/src/daemon/web/http-security.ts index 0c7ed73..71beae9 100644 --- a/packages/daemon/src/daemon/web/http-security.ts +++ b/packages/daemon/src/daemon/web/http-security.ts @@ -113,10 +113,16 @@ export function isLocalDashboardHost(hostHeader?: string | string[] | null): boo const raw = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; const first = (raw || '').split(',')[0]?.trim().toLowerCase() || ''; if (!first) return false; - // Strip port; handle [IPv6]:port - const hostname = first.startsWith('[') - ? (first.match(/^\[([^\]]+)\]/)?.[1] || first) - : first.replace(/:\d+$/, ''); + // Strip port for hostname / IPv4; handle [IPv6]:port. Do not strip from + // bare IPv6 (e.g. ::1) — trailing :digits would mangle it to ::. + let hostname: string; + if (first.startsWith('[')) { + hostname = first.match(/^\[([^\]]+)\]/)?.[1] || first; + } else if ((first.match(/:/g) || []).length > 1) { + hostname = first; + } else { + hostname = first.replace(/:\d+$/, ''); + } return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; } @@ -127,13 +133,12 @@ export function isLocalDashboardHost(hostHeader?: string | string[] | null): boo export function requestDashboardHost(req: { headers: { host?: string | string[]; 'x-forwarded-host'?: string | string[] }; }): string | undefined { - const xf = req.headers['x-forwarded-host']; - if (typeof xf === 'string' && xf.trim()) return xf; - if (Array.isArray(xf) && xf[0]) return xf[0]; - const host = req.headers.host; - if (typeof host === 'string') return host; - if (Array.isArray(host)) return host[0]; - return undefined; + const firstHeaderValue = (value?: string | string[]): string | undefined => { + const raw = Array.isArray(value) ? value[0] : value; + if (typeof raw !== 'string' || !raw.trim()) return undefined; + return raw.split(',')[0]?.trim() || undefined; + }; + return firstHeaderValue(req.headers['x-forwarded-host']) ?? firstHeaderValue(req.headers.host); } /** From a91969ffaaef27153b8ef6abcaba78a667405713 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Fri, 17 Jul 2026 12:03:38 -0700 Subject: [PATCH 4/8] fix(web): treat Host locality as all-or-nothing Require every Host / X-Forwarded-Host value to be loopback before auto-session, and cover spoofed multi-value chains plus a GET / integration redirect behind a public forwarded host. --- .../src/daemon/web/http-security.test.ts | 13 +++- .../daemon/src/daemon/web/http-security.ts | 74 ++++++++++++------- .../tests/integration/http-security.test.ts | 11 +++ 3 files changed, 69 insertions(+), 29 deletions(-) diff --git a/packages/daemon/src/daemon/web/http-security.test.ts b/packages/daemon/src/daemon/web/http-security.test.ts index da98fc8..95030e4 100644 --- a/packages/daemon/src/daemon/web/http-security.test.ts +++ b/packages/daemon/src/daemon/web/http-security.test.ts @@ -71,18 +71,25 @@ describe('isLocalDashboardHost', () => { expect(isLocalDashboardHost('abbenay.20665.net')).toBe(false); expect(isLocalDashboardHost('abbenay.example:443')).toBe(false); }); + + it('rejects when any comma-separated or multi-header value is public', () => { + expect(isLocalDashboardHost('localhost, abbenay.example')).toBe(false); + expect(isLocalDashboardHost('abbenay.example, localhost')).toBe(false); + expect(isLocalDashboardHost(['localhost', 'abbenay.example'])).toBe(false); + expect(isLocalDashboardHost('localhost, 127.0.0.1')).toBe(true); + }); }); describe('requestDashboardHost', () => { - it('prefers the first X-Forwarded-Host value', () => { + it('prefers the full X-Forwarded-Host value over Host', () => { expect( requestDashboardHost({ headers: { host: '127.0.0.1:8787', - 'x-forwarded-host': 'abbenay.example, internal.local', + 'x-forwarded-host': 'localhost, abbenay.example', }, }), - ).toBe('abbenay.example'); + ).toBe('localhost, abbenay.example'); }); it('falls back to Host when X-Forwarded-Host is absent', () => { diff --git a/packages/daemon/src/daemon/web/http-security.ts b/packages/daemon/src/daemon/web/http-security.ts index 71beae9..1b503c9 100644 --- a/packages/daemon/src/daemon/web/http-security.ts +++ b/packages/daemon/src/daemon/web/http-security.ts @@ -104,48 +104,70 @@ export function isLoopbackRemoteAddress(addr?: string | null): boolean { } /** - * Return true when the browser Host (or first X-Forwarded-Host) is loopback. + * Normalize a single Host / X-Forwarded-Host token to a hostname. * - * Used so TLS-terminated reverse proxies (peer often loopback) cannot trigger - * auto-session cookies for public hostnames. + * Strips port for hostname / IPv4 and `[IPv6]:port`. Does not strip from bare + * IPv6 (e.g. `::1`) — trailing `:digits` would mangle it to `::`. */ -export function isLocalDashboardHost(hostHeader?: string | string[] | null): boolean { - const raw = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; - const first = (raw || '').split(',')[0]?.trim().toLowerCase() || ''; - if (!first) return false; - // Strip port for hostname / IPv4; handle [IPv6]:port. Do not strip from - // bare IPv6 (e.g. ::1) — trailing :digits would mangle it to ::. - let hostname: string; +function dashboardHostname(hostToken: string): string { + const first = hostToken.trim().toLowerCase(); + if (!first) return ''; if (first.startsWith('[')) { - hostname = first.match(/^\[([^\]]+)\]/)?.[1] || first; - } else if ((first.match(/:/g) || []).length > 1) { - hostname = first; - } else { - hostname = first.replace(/:\d+$/, ''); + return first.match(/^\[([^\]]+)\]/)?.[1] || first; + } + if ((first.match(/:/g) || []).length > 1) { + return first; } + return first.replace(/:\d+$/, ''); +} + +function isLoopbackHostname(hostname: string): boolean { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; } /** - * Prefer X-Forwarded-Host (first value) then Host — same trust model as - * X-Forwarded-Proto for Secure cookies. + * Return true when every Host / X-Forwarded-Host value is loopback. + * + * Fail-closed: if a proxy appends a public hostname after a client-spoofed + * `localhost`, the chain is not treated as local (auto-session must not skip + * `/login`). + */ +export function isLocalDashboardHost(hostHeader?: string | string[] | null): boolean { + const tokens: string[] = []; + const parts = Array.isArray(hostHeader) ? hostHeader : hostHeader ? [hostHeader] : []; + for (const part of parts) { + for (const token of String(part).split(',')) { + const trimmed = token.trim(); + if (trimmed) tokens.push(trimmed); + } + } + if (tokens.length === 0) return false; + return tokens.every((token) => isLoopbackHostname(dashboardHostname(token))); +} + +/** + * Prefer X-Forwarded-Host then Host for dashboard locality checks. + * + * Returns the full header value (comma-separated / multi-value preserved) so + * {@link isLocalDashboardHost} can require every hop to be loopback. */ export function requestDashboardHost(req: { headers: { host?: string | string[]; 'x-forwarded-host'?: string | string[] }; -}): string | undefined { - const firstHeaderValue = (value?: string | string[]): string | undefined => { - const raw = Array.isArray(value) ? value[0] : value; - if (typeof raw !== 'string' || !raw.trim()) return undefined; - return raw.split(',')[0]?.trim() || undefined; - }; - return firstHeaderValue(req.headers['x-forwarded-host']) ?? firstHeaderValue(req.headers.host); +}): string | string[] | undefined { + const xf = req.headers['x-forwarded-host']; + if (typeof xf === 'string' && xf.trim()) return xf; + if (Array.isArray(xf) && xf.length > 0) return xf; + const host = req.headers.host; + if (typeof host === 'string' && host.trim()) return host; + if (Array.isArray(host) && host.length > 0) return host; + return undefined; } /** * Whether an unauthenticated dashboard HTML request should 302 to `/login`. * - * Auto-session (skip redirect) only when the TCP peer is local **and** the - * browser Host / X-Forwarded-Host is localhost. That keeps `aby web` on + * Auto-session (skip redirect) only when the TCP peer is local **and** every + * Host / X-Forwarded-Host value is localhost. That keeps `aby web` on * 127.0.0.1 convenient, but forces `/login` when a reverse proxy presents a * public hostname (peer is often loopback after TLS termination). * diff --git a/packages/daemon/tests/integration/http-security.test.ts b/packages/daemon/tests/integration/http-security.test.ts index bb6894a..c54a9b8 100644 --- a/packages/daemon/tests/integration/http-security.test.ts +++ b/packages/daemon/tests/integration/http-security.test.ts @@ -377,6 +377,17 @@ describe('dashboard login', () => { expect(res.statusCode).toBe(200); expect(String(res.body)).toContain('Abbenay'); }); + + it('GET / redirects to /login when X-Forwarded-Host is a public hostname', async () => { + // Loopback peer + public forwarded host (TLS-terminated proxy) must not + // auto-establish a session. + const res = await httpRequest('GET', '/', { + token: null, + headers: { 'X-Forwarded-Host': 'abbenay.example' }, + }); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('/login'); + }); }); describe('CORS allowlist', () => { From 2c14a2b766d2599a1b1fcb6384605dc555595248 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Fri, 17 Jul 2026 12:09:58 -0700 Subject: [PATCH 5/8] docs(web): align locality wording with Host+peer rule Tighten the localhost dashboard integration assertion and update serveDashboardHtml / GETTING_STARTED prose so they match the peer-and-Host auto-session policy. --- docs/GETTING_STARTED.md | 12 +++++++----- packages/daemon/src/daemon/web/server.ts | 6 +++++- .../daemon/tests/integration/http-security.test.ts | 4 +++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 3c3205e..6ab31aa 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -287,11 +287,13 @@ settings. Use your Abbenay HTTP API token as the API key. aby web ``` -Open http://127.0.0.1:8787 in your browser (loopback clients get a session -automatically). For remote binds (`--host 0.0.0.0`), unauthenticated visits to -`/` redirect to `/login` — sign in with the API token (or `POST /login` with -the token in the body). Avoid putting the token in the query string (it can -leak via history, Referer, and logs). +Open http://127.0.0.1:8787 in your browser (loopback clients with a localhost +Host get a session automatically). On remote binds (`--host 0.0.0.0`), +unauthenticated **non-local** visits to `/` redirect to `/login` — for example +LAN or reverse-proxy hostnames — while a direct loopback peer with a localhost +Host can still auto-establish a session. Sign in with the API token (or +`POST /login` with the token in the body). Avoid putting the token in the query +string (it can leak via history, Referer, and logs). For throwaway local development only, `ABBENAY_HTTP_AUTH=0` disables HTTP auth (loopback bind only; refused with `--host 0.0.0.0`). diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index 511f8ab..5900e0a 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -159,7 +159,11 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): * Serve dashboard HTML. Establishes SameSite auth cookies when auth is * enabled and: * - POST /login (preferred) or legacy ?token= succeeded, or - * - the client is loopback (safe with default 127.0.0.1 bind) + * - locality allows auto-session: local TCP peer/bind **and** every + * Host / X-Forwarded-Host value is loopback (see + * {@link mayAutoEstablishDashboardSession}) + * + * Otherwise unauthenticated HTML requests redirect to `/login`. * * The API token is never embedded in HTML for remote clients; the dashboard * authenticates via HttpOnly cookie + CSRF header (and API clients use Bearer). diff --git a/packages/daemon/tests/integration/http-security.test.ts b/packages/daemon/tests/integration/http-security.test.ts index c54a9b8..8e35c1b 100644 --- a/packages/daemon/tests/integration/http-security.test.ts +++ b/packages/daemon/tests/integration/http-security.test.ts @@ -375,7 +375,9 @@ describe('dashboard login', () => { // Default test bind is 127.0.0.1 — auto-session for local use (no redirect). const res = await httpRequest('GET', '/', { token: null }); expect(res.statusCode).toBe(200); - expect(String(res.body)).toContain('Abbenay'); + const body = String(res.body); + expect(body).toContain('window.__ABBENAY_CSRF__'); + expect(body).not.toContain('action="/login"'); }); it('GET / redirects to /login when X-Forwarded-Host is a public hostname', async () => { From 35c77241aff72622cb37eb196490d08e5d2ff873 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Fri, 17 Jul 2026 12:17:46 -0700 Subject: [PATCH 6/8] fix(web): apply login redirect to SPA fallback routes Route the catch-all dashboard HTML handler through serveDashboardHtml so paths like /providers use the same Host locality policy as /. --- packages/daemon/src/daemon/web/server.ts | 32 ++----------------- .../tests/integration/http-security.test.ts | 9 ++++++ 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index 5900e0a..83cdb59 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -1695,6 +1695,9 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): // ── OpenAI-compatible API (/v1/*) ───────────────────────────────────── registerOpenAIRoutes(app, state); + // SPA fallback — same locality /login policy as `/` and `/index.html`. + app.get('*', serveDashboardHtml); + return app; } @@ -1740,35 +1743,6 @@ export async function startEmbeddedWebServer( skipConfig: true, }); - // SPA fallback (serve index.html for non-API routes) - app.get('*', (req, res) => { - const indexPath = path.join(STATIC_PATH, 'index.html'); - if (!fs.existsSync(indexPath)) { - res.status(404).send('Web dashboard not found. Static files not at: ' + STATIC_PATH); - return; - } - if (!security.authEnabled) { - res.type('html').send(fs.readFileSync(indexPath, 'utf-8')); - return; - } - const addr = req.socket.remoteAddress || ''; - const loopback = addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'; - const cookieToken = getCookie(req, API_TOKEN_COOKIE); - const hasAuthCookie = - cookieToken !== null && timingSafeEqualString(cookieToken, security.apiToken); - const mayEstablish = hasAuthCookie || loopback || isLocalhostBind(bindHost); - let csrf = getCookie(req, CSRF_COOKIE); - if (mayEstablish && (!hasAuthCookie || !csrf)) { - csrf = setAuthCookies(res, security.apiToken, { secure: cookieSecureFromRequest(req) }); - } - let html = fs.readFileSync(indexPath, 'utf-8'); - const inject = ``; - html = html.includes('') - ? html.replace('', `${inject}`) - : `${inject}${html}`; - res.type('html').send(html); - }); - _webPort = port; _webHost = bindHost; diff --git a/packages/daemon/tests/integration/http-security.test.ts b/packages/daemon/tests/integration/http-security.test.ts index 8e35c1b..6fbf2d6 100644 --- a/packages/daemon/tests/integration/http-security.test.ts +++ b/packages/daemon/tests/integration/http-security.test.ts @@ -390,6 +390,15 @@ describe('dashboard login', () => { expect(res.statusCode).toBe(302); expect(res.headers.location).toBe('/login'); }); + + it('SPA fallback paths also redirect when X-Forwarded-Host is public', async () => { + const res = await httpRequest('GET', '/providers', { + token: null, + headers: { 'X-Forwarded-Host': 'abbenay.example' }, + }); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('/login'); + }); }); describe('CORS allowlist', () => { From 3b60106809065b72ada256b756d47e20a64e01b3 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Fri, 17 Jul 2026 12:23:56 -0700 Subject: [PATCH 7/8] fix(web): no-store Cache-Control on all dashboard HTML Set Cache-Control inside serveDashboardHtml so SPA deep-links cannot cache a stale embedded CSRF token. --- packages/daemon/src/daemon/web/server.ts | 4 ++++ packages/daemon/tests/integration/http-security.test.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index 83cdb59..3676185 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -169,6 +169,10 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): * authenticates via HttpOnly cookie + CSRF header (and API clients use Bearer). */ const serveDashboardHtml = (req: Request, res: Response): void => { + // All HTML entry points (/, /index.html, SPA deep-links) must be + // non-cacheable — embedded window.__ABBENAY_CSRF__ must stay fresh. + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + const indexPath = path.join(STATIC_PATH, 'index.html'); if (!fs.existsSync(indexPath)) { res.status(404).send('Web dashboard not found. Static files not at: ' + STATIC_PATH); diff --git a/packages/daemon/tests/integration/http-security.test.ts b/packages/daemon/tests/integration/http-security.test.ts index 6fbf2d6..ae408ea 100644 --- a/packages/daemon/tests/integration/http-security.test.ts +++ b/packages/daemon/tests/integration/http-security.test.ts @@ -399,6 +399,12 @@ describe('dashboard login', () => { expect(res.statusCode).toBe(302); expect(res.headers.location).toBe('/login'); }); + + it('SPA fallback dashboard HTML is non-cacheable', async () => { + const res = await httpRequest('GET', '/providers', { token: null }); + expect(res.statusCode).toBe(200); + expect(String(res.headers['cache-control'] || '')).toMatch(/no-store/i); + }); }); describe('CORS allowlist', () => { From ee969d1a46a26c3cd440784988331ac20b0c4fad Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Fri, 17 Jul 2026 12:37:25 -0700 Subject: [PATCH 8/8] fix(web): keep API 404s out of the SPA catch-all Unknown /api, /v1, and /mcp GETs return JSON 404 instead of dashboard HTML or a /login redirect from the SPA fallback. --- packages/daemon/src/daemon/web/server.ts | 16 ++++++++++++++-- .../tests/integration/http-security.test.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index 3676185..4b88591 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -1699,8 +1699,20 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): // ── OpenAI-compatible API (/v1/*) ───────────────────────────────────── registerOpenAIRoutes(app, state); - // SPA fallback — same locality /login policy as `/` and `/index.html`. - app.get('*', serveDashboardHtml); + // Unknown API/OpenAI/MCP GETs stay JSON 404s — never SPA HTML or /login. + const isApiStylePath = (p: string): boolean => + p === '/api' || p.startsWith('/api/') || + p === '/v1' || p.startsWith('/v1/') || + p === '/mcp' || p.startsWith('/mcp/'); + + app.get('*', (req, res) => { + if (isApiStylePath(req.path)) { + res.status(404).json({ error: 'Not found' }); + return; + } + // SPA fallback — same locality /login policy as `/` and `/index.html`. + serveDashboardHtml(req, res); + }); return app; } diff --git a/packages/daemon/tests/integration/http-security.test.ts b/packages/daemon/tests/integration/http-security.test.ts index ae408ea..c8f4572 100644 --- a/packages/daemon/tests/integration/http-security.test.ts +++ b/packages/daemon/tests/integration/http-security.test.ts @@ -405,6 +405,21 @@ describe('dashboard login', () => { expect(res.statusCode).toBe(200); expect(String(res.headers['cache-control'] || '')).toMatch(/no-store/i); }); + + it('unknown API GET returns JSON 404, not SPA HTML or /login', async () => { + const res = await httpRequest('GET', '/api/does-not-exist', { + token: null, + headers: { 'X-Forwarded-Host': 'abbenay.example' }, + }); + // Auth runs first → 401 for unauthenticated; with auth, unknown → JSON 404. + expect(res.statusCode).toBe(401); + expect(res.body).toEqual({ error: 'Unauthorized' }); + + const authed = await httpRequest('GET', '/api/does-not-exist'); + expect(authed.statusCode).toBe(404); + expect(authed.body).toEqual({ error: 'Not found' }); + expect(String(authed.headers.location || '')).toBe(''); + }); }); describe('CORS allowlist', () => {