Skip to content
7 changes: 7 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
142 changes: 142 additions & 0 deletions packages/daemon/src/daemon/web/http-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
isLocalhostBind,
isLoopbackRemoteAddress,
isLocalDashboardHost,
requestDashboardHost,
shouldRedirectDashboardToLogin,
mayAutoEstablishDashboardSession,
isHttpAuthEnabled,
assertHttpAuthBindAllowed,
HttpAuthBindSecurityError,
Expand Down Expand Up @@ -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;

Expand Down
108 changes: 108 additions & 0 deletions packages/daemon/src/daemon/web/http-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down
Loading
Loading