Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/vis/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { join, resolve } from 'node:path';

import { Hono } from 'hono';

import { resolveHost } from './config';
import { hostGuard } from './host-guard';
import { contextRoute } from './routes/context';
import { sessionDetailRoute } from './routes/session-detail';
import { sessionsRoute } from './routes/sessions';
Expand Down Expand Up @@ -51,6 +53,11 @@ function mimeFor(path: string): string {

export interface CreateAppOptions {
readonly authToken?: string;
/** Host the server is bound to; used by the no-token DNS-rebinding guard.
* Defaults to resolveHost(). */
readonly host?: string;
/** Raw comma-separated VIS_ALLOWED_HOSTS value for the no-token guard. */
readonly allowedHosts?: string;
}

function bearerToken(value: string | undefined): string | null {
Expand All @@ -69,9 +76,22 @@ function tokenMatches(actual: string, expected: string): boolean {
export async function createApp(options: CreateAppOptions = {}): Promise<Hono> {
const app = new Hono();

const authToken = options.authToken;

// DNS-rebinding guard. Only meaningful in the no-token loopback mode: when a
// token is configured (required for any non-loopback bind), the token is the
// access control and a rebinding attacker cannot read it cross-origin, so a
// Host allow-list would only break legitimate LAN / wildcard access. Mounted
// before the routes so it covers /api/* and the static fallback alike.
if (authToken === undefined || authToken.length === 0) {
app.use(
'*',
hostGuard({ bindHost: options.host ?? resolveHost(), allowedHosts: options.allowedHosts }),
);
}

// /api/* handlers.
const api = new Hono();
const authToken = options.authToken;
if (authToken !== undefined && authToken.length > 0) {
api.use('*', async (c, next) => {
const token = bearerToken(c.req.header('authorization'));
Expand Down
9 changes: 8 additions & 1 deletion apps/vis/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@ export function resolveHost(): string {
return host !== undefined && host.length > 0 ? host : '127.0.0.1';
}

/** Strict dotted-quad match for the 127.0.0.0/8 loopback range. Anchored so a
* hostname that merely *starts with* `127.` (e.g. `127.0.0.1.nip.io`) is not
* mistaken for a loopback address. */
const LOOPBACK_IPV4 = /^127\.(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){2}$/u;

export function isLoopbackHost(host: string): boolean {
const normalized = host.trim().toLowerCase().replaceAll('[', '').replaceAll(']', '');
return (
normalized === 'localhost' ||
// RFC 6761: `localhost.` and any `*.localhost` name resolves to loopback.
normalized.endsWith('.localhost') ||
Comment on lines 40 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Treat trailing-dot localhost names as loopback

With the new host guard enabled in no-token mode, requests using valid FQDN loopback spellings like localhost. are rejected with FORBIDDEN_HOST because loopback detection only accepts exact localhost or *.localhost and never normalizes a trailing DNS root dot. This is a functional regression from pre-guard behavior (which accepted any host) and can block legitimate local clients that preserve the trailing dot form.

Useful? React with 👍 / 👎.

normalized === '::1' ||
normalized === '0:0:0:0:0:0:0:1' ||
normalized.startsWith('127.')
LOOPBACK_IPV4.test(normalized)
);
}

Expand Down
120 changes: 120 additions & 0 deletions apps/vis/server/src/host-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { Context, Next } from 'hono';

import { isLoopbackHost } from './config';

/** Extract the hostname (without port) from a Host header or URL authority.
* Handles IPv6 bracket form (`[::1]:3001`), bare IPv6 literals (`2001:db8::1`),
* and `host:port`. Returns null for empty/missing input.
*
* A malformed authority is returned verbatim (lowercased) rather than coerced,
* so it fails every allow check instead of being silently reduced to a value
* that looks allowed (e.g. `[::1]evil.com` must not become `::1`). */
export function hostnameFromAuthority(authority: string | undefined | null): string | null {
if (authority === undefined || authority === null) return null;
const trimmed = authority.trim();
if (trimmed.length === 0) return null;
if (trimmed.startsWith('[')) {
const end = trimmed.indexOf(']');
if (end < 0) return trimmed.toLowerCase(); // unterminated bracket → cannot match
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unterminated bracket authorities

Treating an unterminated bracket host as a pass-through string here creates a guard bypass: hostnameFromAuthority('[::1') returns '[::1', and isLoopbackHost later strips brackets, turning it into '::1' (loopback) so the request is accepted instead of rejected. This means malformed Host values can still satisfy the allow check in no-token mode, contrary to the comment that malformed authorities "cannot match".

Useful? React with 👍 / 👎.

const rest = trimmed.slice(end + 1);
// After the closing bracket only an empty string or `:port` is valid; any
// other trailing text means the authority is malformed — don't let the
// bracketed prefix masquerade as the real host.
if (rest.length > 0 && !/^:\d+$/u.test(rest)) return trimmed.toLowerCase();
return trimmed.slice(1, end).toLowerCase();
}
const firstColon = trimmed.indexOf(':');
if (firstColon < 0) return trimmed.toLowerCase();
// An unbracketed authority with more than one colon is a bare IPv6 literal
// with no port (a port requires bracket form), so the whole string is the
// host. A single colon is an ordinary `host:port`.
if (trimmed.includes(':', firstColon + 1)) return trimmed.toLowerCase();
return trimmed.slice(0, firstColon).toLowerCase();
}

/** Wildcard bind addresses (`0.0.0.0`, `::`) mean "every interface" and never
* appear as a concrete client's Host, so they cannot serve as a match target. */
function isWildcardHost(host: string): boolean {
return host === '0.0.0.0' || host === '::' || host === '0:0:0:0:0:0:0:0';
}

function parseAllowedHosts(raw: string | undefined): ReadonlySet<string> {
const set = new Set<string>();
if (raw === undefined) return set;
for (const part of raw.split(',')) {
const h = hostnameFromAuthority(part);
if (h !== null) set.add(h);
}
return set;
}

/** Decide whether a single request hostname is one we expect to serve. Loopback
* names are always allowed (a DNS-rebinding attacker cannot forge a loopback
* Host from a browser). Otherwise the hostname must match the configured bind
* host or an explicit allow-list entry. */
function hostnameAllowed(
hostname: string,
normalizedBindHost: string,
allowedHosts: ReadonlySet<string>,
): boolean {
if (isLoopbackHost(hostname)) return true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject hostnames that only start with 127.

hostnameAllowed trusts isLoopbackHost(hostname) for untrusted request hostnames, but that helper treats any string beginning with 127. as loopback. As a result, attacker-controlled domains like 127.0.0.1.nip.io or 127.evil.com are accepted as loopback and bypass the new DNS-rebinding guard, allowing the exact cross-origin access this change is meant to block when the server runs in default no-token loopback mode.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in d1832a7isLoopbackHost no longer uses startsWith('127.'). It's now a strict 127.0.0.0/8 dotted-quad match (LOOPBACK_IPV4 in config.ts), so 127.0.0.1.nip.io and 127.evil.com are rejected. test/host-guard.test.ts asserts exactly this — isLoopbackHost('127.0.0.1.nip.io') === false, and the guard returns 403 FORBIDDEN_HOST for it. (The fix lives in config.ts; this line is just the call site.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat .localhost subdomains as loopback

The guard claims loopback names are always allowed, but it delegates to isLoopbackHost, which only recognizes localhost, 127.*, and ::1. Valid localhost-domain names like tenant.localhost (defined as localhost names under RFC 6761 and commonly used for local dev) are rejected with 403 FORBIDDEN_HOST in the default loopback configuration unless users manually add them to VIS_ALLOWED_HOSTS, which is a functional regression introduced by this host check.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in d1832a7isLoopbackHost now treats *.localhost as loopback (normalized.endsWith('.localhost')) per RFC 6761, so tenant.localhost is allowed rather than 403'd. test/host-guard.test.ts covers it (allowed → 200).

if (allowedHosts.has(hostname)) return true;
if (normalizedBindHost.length > 0 && hostname === normalizedBindHost) return true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow requests when binding to wildcard addresses

The host check only allows an exact match with normalizedBindHost, which breaks wildcard binds (VIS_HOST=0.0.0.0 or VIS_HOST=::). In those modes, real clients connect using a concrete LAN/public hostname or IP, not 0.0.0.0/::, so requests are rejected as forbidden unless every reachable host is manually duplicated in VIS_ALLOWED_HOSTS. This regresses the existing non-loopback access flow that was previously enabled by setting a bind host plus auth token.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed at the wiring level rather than here: as of d1832a7 the guard is installed only when no VIS_AUTH_TOKEN is set (see createApp), and a non-loopback/wildcard bind already requires a token (config.ts throws otherwise). So for VIS_HOST=0.0.0.0/:: the guard isn't mounted at all — the token is the access control and concrete LAN clients are served normally. test/host-guard.test.ts verifies this (token + 0.0.0.0 bind + a 192.168.x client is not 403). The exact-match line flagged here only runs in no-token loopback mode, where the bind host is loopback. Additionally isWildcardHost already drops a wildcard bindHost as a match target, so even a direct call wouldn't rely on 0.0.0.0 matching.

return false;
}

export interface HostGuardOptions {
/** The host the server is bound to (e.g. resolveHost()). */
readonly bindHost: string;
/** Raw comma-separated VIS_ALLOWED_HOSTS value, if any. */
readonly allowedHosts?: string;
}

/** Hono middleware that rejects requests whose Host header / URL authority is
* neither a loopback name nor an explicitly allowed host.
*
* This is a DNS-rebinding defense for the *no-token loopback* mode: the vis
* server binds to loopback by default and serves no auth token there, so
* without this check any web page the user visits could rebind its own
* hostname to 127.0.0.1 and read or delete the user's local agent sessions
* cross-origin. A browser performing that attack still sends the *original*
* attacker hostname in the Host header, which will not match a loopback name
* or the configured bind host. When an auth token is configured the token is
* the access control — a rebinding attacker cannot read it cross-origin — so
* this guard is not installed in that mode (see createApp), which keeps LAN /
* wildcard binds working without listing every reachable host. */
export function hostGuard(options: HostGuardOptions) {
const allowedHosts = parseAllowedHosts(options.allowedHosts);
const bindHost = hostnameFromAuthority(options.bindHost) ?? '';
const normalizedBindHost = isWildcardHost(bindHost) ? '' : bindHost;

return async (c: Context, next: Next): Promise<Response | void> => {
// Collect every hostname the request claims. In @hono/node-server the URL
// authority is built from the client's Host header, so both reflect what a
// rebinding attacker controls; require all present hostnames to be allowed.
const hostnames: string[] = [];
try {
const fromUrl = hostnameFromAuthority(new URL(c.req.url).host);
if (fromUrl !== null) hostnames.push(fromUrl);
} catch {
// ignore unparseable URL; fall back to the Host header below
}
const fromHeader = hostnameFromAuthority(c.req.header('host'));
if (fromHeader !== null) hostnames.push(fromHeader);

const ok =
hostnames.length > 0 &&
hostnames.every((h) => hostnameAllowed(h, normalizedBindHost, allowedHosts));
if (!ok) {
return c.json(
{
error:
'forbidden host: request Host is not loopback, the configured bind host, or in VIS_ALLOWED_HOSTS',
code: 'FORBIDDEN_HOST',
},
403,
);
}
await next();
};
}
2 changes: 1 addition & 1 deletion apps/vis/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { formatStartupBanner } from './startup-banner';
async function main(): Promise<void> {
const host = resolveHost();
const authToken = resolveVisAuthToken(host);
const app = await createApp({ authToken });
const app = await createApp({ authToken, host, allowedHosts: process.env['VIS_ALLOWED_HOSTS'] });
const port = resolvePort();
serve({ fetch: app.fetch, hostname: host, port }, (info) => {
// Startup banner.
Expand Down
110 changes: 110 additions & 0 deletions apps/vis/server/test/host-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// apps/vis/server/test/host-guard.test.ts
import { Hono } from 'hono';
import { afterEach, describe, expect, it } from 'vitest';

import { createApp } from '../src/app';
import { isLoopbackHost } from '../src/config';
import { hostGuard, hostnameFromAuthority } from '../src/host-guard';

/** Minimal app exercising only the guard, so assertions don't depend on the
* session routes or KIMI_CODE_HOME. */
function guarded(options: { bindHost: string; allowedHosts?: string }): Hono {
const app = new Hono();
app.use('*', hostGuard(options));
app.get('/api/ping', (c) => c.json({ ok: true }));
return app;
}

describe('hostnameFromAuthority', () => {
it('parses host:port and bare hostnames', () => {
expect(hostnameFromAuthority('example.com:3001')).toBe('example.com');
expect(hostnameFromAuthority('127.0.0.1')).toBe('127.0.0.1');
expect(hostnameFromAuthority(' Example.COM ')).toBe('example.com');
expect(hostnameFromAuthority('')).toBeNull();
expect(hostnameFromAuthority(undefined)).toBeNull();
});

it('does not truncate bare IPv6 literals at the first colon', () => {
expect(hostnameFromAuthority('2001:db8::10')).toBe('2001:db8::10');
expect(hostnameFromAuthority('[2001:db8::10]:3001')).toBe('2001:db8::10');
expect(hostnameFromAuthority('[::1]')).toBe('::1');
});

it('does not let trailing junk after a bracket masquerade as the host', () => {
// `[::1]evil.com` must NOT normalize to the loopback `::1`.
const parsed = hostnameFromAuthority('[::1]evil.com');
expect(parsed).not.toBe('::1');
expect(isLoopbackHost(parsed ?? '')).toBe(false);
});
});

describe('isLoopbackHost', () => {
it('accepts loopback names and the 127.0.0.0/8 range', () => {
for (const h of ['localhost', 'tenant.localhost', '127.0.0.1', '127.0.0.2', '::1', '[::1]']) {
expect(isLoopbackHost(h), h).toBe(true);
}
});

it('rejects hostnames that merely start with 127.', () => {
for (const h of ['127.0.0.1.nip.io', '127.evil.com', '1270.0.0.1', '127.0.0.256']) {
expect(isLoopbackHost(h), h).toBe(false);
}
});
});

describe('hostGuard (DNS-rebinding defense)', () => {
it('rejects a rebound non-loopback Host', async () => {
const app = guarded({ bindHost: '127.0.0.1' });
const res = await app.request('http://attacker.example/api/ping');
expect(res.status).toBe(403);
await expect(res.json()).resolves.toMatchObject({ code: 'FORBIDDEN_HOST' });
});

it('allows loopback and *.localhost Host values', async () => {
const app = guarded({ bindHost: '127.0.0.1' });
for (const origin of [
'http://localhost/api/ping',
'http://127.0.0.1/api/ping',
'http://127.0.0.2/api/ping',
'http://tenant.localhost/api/ping',
]) {
expect((await app.request(origin)).status, origin).toBe(200);
}
});

it('does not allow a domain that only starts with 127. (no prefix bypass)', async () => {
const app = guarded({ bindHost: '127.0.0.1' });
expect((await app.request('http://127.0.0.1.nip.io/api/ping')).status).toBe(403);
});

it('allows hosts listed in VIS_ALLOWED_HOSTS, still blocks others', async () => {
const app = guarded({ bindHost: '127.0.0.1', allowedHosts: 'vis.internal,dev.box' });
expect((await app.request('http://vis.internal/api/ping')).status).toBe(200);
expect((await app.request('http://attacker.example/api/ping')).status).toBe(403);
});
});

describe('createApp host-guard wiring', () => {
const savedEnv = { ...process.env };
afterEach(() => {
process.env = { ...savedEnv };
});

it('guards requests when no auth token is configured', async () => {
const app = await createApp({ host: '127.0.0.1' });
const res = await app.request('http://attacker.example/api/sessions');
expect(res.status).toBe(403);
await expect(res.json()).resolves.toMatchObject({ code: 'FORBIDDEN_HOST' });
});

it('does not host-guard in token mode, so wildcard LAN access keeps working', async () => {
// Binding to a wildcard requires a token; the token (not the Host) is the
// access control, so a concrete LAN client must not be rejected as a
// forbidden host.
const app = await createApp({ authToken: 'secret-token', host: '0.0.0.0' });
const res = await app.request('http://192.168.1.10/api/sessions', {
headers: { authorization: 'Bearer secret-token' },
});
expect(res.status).not.toBe(403);
});
});