Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/server-allowed-host-flag.md
Original file line number Diff line number Diff line change
@@ -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 <host> to allow an extra host.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/cli/sub/server/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -200,6 +202,7 @@ interface SpawnDaemonChildOptions {
insecureNoTls?: boolean;
allowRemoteShutdown?: boolean;
allowRemoteTerminals?: boolean;
allowedHosts?: readonly string[];
idleGraceMs?: number;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -314,6 +320,7 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise<E
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
allowedHosts: options.allowedHosts,
idleGraceMs: options.idleGraceMs,
});

Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/cli/sub/server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean })
'--host [host]',
`Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host <host> for a specific host. The bearer token is printed at startup.`,
)
.option(
'--allowed-host <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.',
Expand Down Expand Up @@ -247,6 +251,7 @@ export async function startServerBackground(
insecureNoTls: options.insecureNoTls,
allowRemoteShutdown: options.allowRemoteShutdown,
allowRemoteTerminals: options.allowRemoteTerminals,
allowedHosts: options.allowedHosts,
idleGraceMs: options.idleGraceMs,
});
}
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions apps/kimi-code/src/cli/sub/server/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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. */
Expand All @@ -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);
Comment on lines +98 to +100

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 Normalize allowed host entries before forwarding

When a user passes the actual Host header value to the new --allowed-host flag, common values like app.example.com:58627 or mixed-case hosts are forwarded unchanged, but isAllowedHost() lowercases and strips the port only from the incoming request before comparing against extra. In that scenario the newly added flag still rejects the same host it was meant to allow, so the CLI parser should normalize entries the same way the request side is normalized.

Useful? React with 👍 / 👎.

}

function parseHost(raw: string | boolean | undefined): string {
if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST;
if (raw === true || raw === '') return DEFAULT_LAN_HOST;
Expand Down
43 changes: 43 additions & 0 deletions apps/kimi-code/test/cli/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/server/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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`.
Expand Down
10 changes: 8 additions & 2 deletions packages/server/src/middleware/hostnames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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 ?? '<missing>';
const hostArg = normalizedHost ?? '<host>';
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.
*
Expand Down Expand Up @@ -159,7 +165,7 @@ export function createHostCheck(opts: HostCheckOptions): HostCheck {
reply: FastifyReply,
): Promise<FastifyReply | void> => {
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 };
Expand Down
11 changes: 9 additions & 2 deletions packages/server/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly [ServiceIdentifier<unknown>, unknown]>;
}

Expand Down Expand Up @@ -140,9 +146,10 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
// D3) — even on loopback — so behavior does not depend on how the server is
// reached. The default-allow set keeps `app.inject` (`Host: localhost:80`)
// and real `fetch` to `127.0.0.1:<port>` 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) });
Expand Down Expand Up @@ -262,7 +269,7 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
...opts.wsGatewayOptions,
hostCheck: opts.wsGatewayOptions?.hostCheck ?? {
boundHost: opts.host,
extra: parseAllowedHosts(process.env),
extra: allowedHosts,
disable: isHostCheckDisabled(process.env),
},
allowedOrigins:
Expand Down
4 changes: 3 additions & 1 deletion packages/server/test/host-origin.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ describe('HTTP Host check (start.ts)', () => {
expect(res.status).toBe(403);
const body = JSON.parse(res.body) as Record<string, unknown>;
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:<port> Host', async () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/server/test/hostnames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
createHostCheck,
formatHostErrorMessage,
isAllowedHost,
isHostCheckDisabled,
parseAllowedHosts,
Expand All @@ -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'];

Expand Down Expand Up @@ -138,7 +147,9 @@ describe('createHostCheck (onRequest hook)', () => {
expect(res.statusCode).toBe(403);
const body = res.json() as Record<string, unknown>;
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');
});
Expand Down
Loading