diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0f3bb49..8700299 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -131,6 +131,13 @@ 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, 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 > 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..6ab31aa 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -287,10 +287,16 @@ 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, 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). +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`). 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..95030e4 100644 --- a/packages/daemon/src/daemon/web/http-security.test.ts +++ b/packages/daemon/src/daemon/web/http-security.test.ts @@ -5,6 +5,11 @@ import { describe, it, expect, afterEach } from 'vitest'; import { isLocalhostBind, + isLoopbackRemoteAddress, + isLocalDashboardHost, + requestDashboardHost, + shouldRedirectDashboardToLogin, + mayAutoEstablishDashboardSession, isHttpAuthEnabled, assertHttpAuthBindAllowed, HttpAuthBindSecurityError, @@ -40,6 +45,143 @@ 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('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); + expect(isLocalDashboardHost('::1')).toBe(true); + }); + + it('rejects public hostnames', () => { + 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 full X-Forwarded-Host value over Host', () => { + expect( + requestDashboardHost({ + headers: { + host: '127.0.0.1:8787', + 'x-forwarded-host': 'localhost, abbenay.example', + }, + }), + ).toBe('localhost, 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( + shouldRedirectDashboardToLogin({ + authEnabled: false, + hasValidAuthCookie: false, + bindHost: '0.0.0.0', + remoteAddress: '192.168.0.24', + hostHeader: 'abbenay.example', + }), + ).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', + hostHeader: 'abbenay.example', + }), + ).toBe(false); + }); + + it('does not redirect for direct local access (loopback peer + local Host)', () => { + expect( + shouldRedirectDashboardToLogin({ + authEnabled: true, + hasValidAuthCookie: false, + 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('redirects when reverse-proxy peer is loopback but Host is public', () => { + // TLS-terminated proxies often present 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 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); + }); +}); + 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..1b503c9 100644 --- a/packages/daemon/src/daemon/web/http-security.ts +++ b/packages/daemon/src/daemon/web/http-security.ts @@ -95,6 +95,114 @@ 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'; +} + +/** + * Normalize a single Host / X-Forwarded-Host token to a hostname. + * + * Strips port for hostname / IPv4 and `[IPv6]:port`. Does not strip from bare + * IPv6 (e.g. `::1`) — trailing `:digits` would mangle it to `::`. + */ +function dashboardHostname(hostToken: string): string { + const first = hostToken.trim().toLowerCase(); + if (!first) return ''; + if (first.startsWith('[')) { + 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'; +} + +/** + * 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 | 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** 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). + * + * 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; + hostHeader?: string | string[] | null; +}): boolean { + if (!opts.authEnabled || opts.hasValidAuthCookie) return false; + 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 }); +} + /** * 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..4b88591 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -35,6 +35,9 @@ import { API_TOKEN_COOKIE, CSRF_COOKIE, isLocalhostBind, + shouldRedirectDashboardToLogin, + mayAutoEstablishDashboardSession, + requestDashboardHost, assertHttpAuthBindAllowed, type WebSecurityOptions, type ResolvedHttpSecurity, @@ -125,11 +128,6 @@ 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 cookieOpts = (req: Request) => ({ secure: cookieSecureFromRequest(req) }); const hasValidAuthCookie = (req: Request): boolean => { @@ -161,12 +159,20 @@ 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). */ 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); @@ -192,7 +198,20 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): } const hasAuthCookie = hasValidAuthCookie(req); - const mayEstablishSession = hasAuthCookie || isLoopbackClient(req) || isLocalhostBind(security.host); + 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 = mayAutoEstablishDashboardSession(locality); let csrf = getCookie(req, CSRF_COOKIE); if (mayEstablishSession && (!hasAuthCookie || !csrf)) { @@ -1680,6 +1699,21 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): // ── OpenAI-compatible API (/v1/*) ───────────────────────────────────── registerOpenAIRoutes(app, state); + // 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; } @@ -1725,35 +1759,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 f7082be..c8f4572 100644 --- a/packages/daemon/tests/integration/http-security.test.ts +++ b/packages/daemon/tests/integration/http-security.test.ts @@ -370,6 +370,56 @@ 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); + 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 () => { + // 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'); + }); + + 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'); + }); + + 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); + }); + + 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', () => {