diff --git a/.changeset/server-allowed-host-flag.md b/.changeset/server-allowed-host-flag.md new file mode 100644 index 0000000000..5e58fa48e6 --- /dev/null +++ b/.changeset/server-allowed-host-flag.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/server": patch +--- + +Add a --allowed-host flag to kimi server run that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index 28ffc8dcb3..84070a7363 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -59,6 +59,8 @@ export interface EnsureDaemonOptions { allowRemoteShutdown?: boolean; /** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */ allowRemoteTerminals?: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts?: readonly string[]; /** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */ idleGraceMs?: number; } @@ -200,6 +202,7 @@ interface SpawnDaemonChildOptions { insecureNoTls?: boolean; allowRemoteShutdown?: boolean; allowRemoteTerminals?: boolean; + allowedHosts?: readonly string[]; idleGraceMs?: number; } @@ -235,6 +238,9 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess if (options.idleGraceMs !== undefined) { args.push('--idle-grace-ms', String(options.idleGraceMs)); } + if (options.allowedHosts !== undefined && options.allowedHosts.length > 0) { + args.push('--allowed-host', ...options.allowedHosts); + } // On Windows `.mjs` files are not executable PE binaries, so we must run // the script through the Node binary rather than spawning it directly. In // SEA mode or when re-spawning from an already-running daemon, `program` is @@ -314,6 +320,7 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise for a specific host. The bearer token is printed at startup.`, ) + .option( + '--allowed-host ', + 'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).', + ) .option( '--insecure-no-tls', 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.', @@ -247,6 +251,7 @@ export async function startServerBackground( insecureNoTls: options.insecureNoTls, allowRemoteShutdown: options.allowRemoteShutdown, allowRemoteTerminals: options.allowRemoteTerminals, + allowedHosts: options.allowedHosts, idleGraceMs: options.idleGraceMs, }); } @@ -325,6 +330,7 @@ async function runServerInProcess( insecureNoTls: options.insecureNoTls, allowRemoteShutdown: options.allowRemoteShutdown, allowRemoteTerminals: options.allowRemoteTerminals, + allowedHosts: options.allowedHosts, webAssetsDir: serverWebAssetsDir(), coreProcessOptions: { identity: createKimiCodeHostIdentity(version), diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index 28c22efa4e..09acad29e5 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -50,6 +50,8 @@ export interface ParsedServerOptions { allowRemoteShutdown: boolean; /** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */ allowRemoteTerminals: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts: readonly string[]; /** Internal: run as an idle-exiting background daemon instead of foreground. */ daemon: boolean; /** Internal: idle-shutdown grace in ms (daemon mode only). */ @@ -67,6 +69,8 @@ export interface ServerCliOptions { allowRemoteShutdown?: boolean; /** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */ allowRemoteTerminals?: boolean; + /** Extra `Host` header values to allow (`--allowed-host`). */ + allowedHost?: string[]; /** Internal flag set by the daemon spawner (`kimi web`). */ daemon?: boolean; /** Internal flag set by the daemon spawner / tests. */ @@ -82,11 +86,20 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions insecureNoTls: opts.insecureNoTls !== false, allowRemoteShutdown: opts.allowRemoteShutdown === true, allowRemoteTerminals: opts.allowRemoteTerminals === true, + allowedHosts: parseAllowedHostArgs(opts.allowedHost), daemon: opts.daemon === true, idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), }; } +export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] { + if (raw === undefined) return []; + return raw + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + function parseHost(raw: string | boolean | undefined): string { if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST; if (raw === true || raw === '') return DEFAULT_LAN_HOST; diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index b5706a8dfa..782ccbc924 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -708,6 +708,36 @@ describe('default bind (M6.3)', () => { }); }); +describe('--allowed-host threading', () => { + it('parses comma-separated --allowed-host values', async () => { + const { parseAllowedHostArgs } = await import('#/cli/sub/server/shared'); + expect(parseAllowedHostArgs(['.example.com, app.example.com'])).toEqual([ + '.example.com', + 'app.example.com', + ]); + }); + + it('threads --allowed-host to the background daemon options', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', allowedHost: ['.example.com'] }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://127.0.0.1:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ allowedHosts: ['.example.com'] }); + }); +}); + describe('lockConnectHost (M6.2 connect side)', () => { it('maps a 0.0.0.0 bind to 127.0.0.1 so the CLI connects over loopback', async () => { const { lockConnectHost } = await import('#/cli/sub/server/daemon'); @@ -984,6 +1014,19 @@ describe('spawnDaemonChild', () => { const [, args] = spawnMock.mock.calls[0]!; expect(args).toEqual(expect.arrayContaining(['--insecure-no-tls'])); }); + + it('passes --allowed-host through to the daemon child args', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + spawnMock.mockClear(); + spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); + + const { spawnDaemonChild } = await import('#/cli/sub/server/daemon'); + spawnDaemonChild({ port: 58627, logLevel: 'info', allowedHosts: ['.example.com'] }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--allowed-host', '.example.com'])); + }); }); describe('ensureDaemon surfaces boot failures via early exit', () => { diff --git a/packages/server/SECURITY.md b/packages/server/SECURITY.md index 77e135db52..35acdb01d5 100644 --- a/packages/server/SECURITY.md +++ b/packages/server/SECURITY.md @@ -158,6 +158,8 @@ tiers. `403 Invalid Host header` (DNS-rebinding protection). - **`KIMI_CODE_ALLOWED_HOSTS`** — comma-separated extra hosts. A leading dot matches a subdomain wildcard, e.g. `KIMI_CODE_ALLOWED_HOSTS=.example.com,kimi.local`. +- **`kimi server run --allowed-host `** — CLI equivalent for appending + extra allowed hosts; repeatable or comma-separated. - **`KIMI_CODE_CORS_ORIGINS`** — comma-separated list of allowed cross-origin values (full `scheme://host[:port]`). No `*` wildcard. Matched origins get `Access-Control-Allow-Origin` echoed; `OPTIONS` preflight short-circuits to `204`. diff --git a/packages/server/src/middleware/hostnames.ts b/packages/server/src/middleware/hostnames.ts index 7b620b9e3f..235977a3c6 100644 --- a/packages/server/src/middleware/hostnames.ts +++ b/packages/server/src/middleware/hostnames.ts @@ -33,7 +33,6 @@ import { errEnvelope } from '#/envelope'; /** Daemon-reserved "invalid Host" code (not in the protocol `ErrorCode` enum). */ const HOST_ERROR_CODE = 40301; -const HOST_ERROR_MSG = 'Invalid Host header'; export interface HostCheckOptions { /** The host the server bound to; always allowed (port stripped both sides). */ @@ -104,6 +103,13 @@ export function stripPort(host: string): string { return host.toLowerCase(); } +export function formatHostErrorMessage(host: string | undefined): string { + const normalizedHost = host === undefined || host.length === 0 ? undefined : stripPort(host); + const hostLabel = normalizedHost ?? ''; + const hostArg = normalizedHost ?? ''; + return `Invalid Host header: ${hostLabel}; allow this host with KIMI_CODE_ALLOWED_HOSTS=${hostArg} or 'kimi server run --allowed-host ${hostArg}'.`; +} + /** * Decide whether a `Host` value is allowed under the given options. * @@ -159,7 +165,7 @@ export function createHostCheck(opts: HostCheckOptions): HostCheck { reply: FastifyReply, ): Promise => { if (!isAllowed(req.headers.host)) { - return reply.code(403).send(errEnvelope(HOST_ERROR_CODE, HOST_ERROR_MSG, req.id)); + return reply.code(403).send(errEnvelope(HOST_ERROR_CODE, formatHostErrorMessage(req.headers.host), req.id)); } }; return { onRequest, isAllowed }; diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 9adc10d836..9d25a67927 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -94,6 +94,12 @@ export interface ServerStartOptions { webAssetsDir?: string; + /** + * Extra `Host` header values to allow, in addition to the default allowlist + * and `KIMI_CODE_ALLOWED_HOSTS`. A leading dot matches a domain suffix. + */ + allowedHosts?: readonly string[]; + serviceOverrides?: ReadonlyArray, unknown]>; } @@ -140,9 +146,10 @@ export async function startServer(opts: ServerStartOptions): Promise` working. + const allowedHosts = [...parseAllowedHosts(process.env), ...(opts.allowedHosts ?? [])]; const hostCheck = createHostCheck({ boundHost: opts.host, - extra: parseAllowedHosts(process.env), + extra: allowedHosts, disable: isHostCheckDisabled(process.env), }); const originHook = createOriginHook({ allowedOrigins: parseCorsOrigins(process.env) }); @@ -262,7 +269,7 @@ export async function startServer(opts: ServerStartOptions): Promise { expect(res.status).toBe(403); const body = JSON.parse(res.body) as Record; expect(body['code']).toBe(40301); - expect(body['msg']).toBe('Invalid Host header'); + expect(body['msg']).toBe( + "Invalid Host header: evil.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=evil.com or 'kimi server run --allowed-host evil.com'.", + ); }); it('allows the default 127.0.0.1: Host', async () => { diff --git a/packages/server/test/hostnames.test.ts b/packages/server/test/hostnames.test.ts index e5cc1a5c61..253d2c6f03 100644 --- a/packages/server/test/hostnames.test.ts +++ b/packages/server/test/hostnames.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createHostCheck, + formatHostErrorMessage, isAllowedHost, isHostCheckDisabled, parseAllowedHosts, @@ -35,6 +36,14 @@ describe('stripPort', () => { }); }); +describe('formatHostErrorMessage', () => { + it('includes the rejected host and allow guidance', () => { + expect(formatHostErrorMessage('APP.Example.com:443')).toBe( + "Invalid Host header: app.example.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=app.example.com or 'kimi server run --allowed-host app.example.com'.", + ); + }); +}); + describe('isAllowedHost (default allow set)', () => { const allow = ['localhost', 'localhost:80', 'foo.localhost', '127.0.0.1', '127.0.0.1:58627', '[::1]', '::1', '8.8.8.8']; @@ -138,7 +147,9 @@ describe('createHostCheck (onRequest hook)', () => { expect(res.statusCode).toBe(403); const body = res.json() as Record; expect(body['code']).toBe(40301); - expect(body['msg']).toBe('Invalid Host header'); + expect(body['msg']).toBe( + "Invalid Host header: evil.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=evil.com or 'kimi server run --allowed-host evil.com'.", + ); expect(body['data']).toBeNull(); expect(typeof body['request_id']).toBe('string'); });