diff --git a/.changeset/server-auth-and-host-exposure.md b/.changeset/server-auth-and-host-exposure.md new file mode 100644 index 000000000..d66a418ad --- /dev/null +++ b/.changeset/server-auth-and-host-exposure.md @@ -0,0 +1,10 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add server authentication and safe `--host` exposure. The local server now +requires a per-start bearer token on all API and WebSocket calls (the CLI reads +it automatically), enforces Host/Origin checks, and gains `--host` with a +public-binding hardening tier: mandatory `KIMI_CODE_PASSWORD`, TLS (or +`--insecure-no-tls`), auth-failure rate limiting, disabled remote +shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`. diff --git a/apps/kimi-code/src/cli/sub/server/access-urls.ts b/apps/kimi-code/src/cli/sub/server/access-urls.ts new file mode 100644 index 000000000..0c6233fe8 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/access-urls.ts @@ -0,0 +1,85 @@ +/** + * Build the clickable/copyable access URLs for the running server. + * + * Shared by the `server run` ready banner and `server rotate-token` so both + * show the same Local/Network links. When a token is known it rides in the + * `#token=` fragment (never sent to the server, so never logged), letting a + * user open the link on another device and be authenticated automatically. + */ + +import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks'; + +/** + * Build a directly-openable server URL. When the token is known it is appended + * as `#token=`; otherwise the bare origin (with a trailing slash) is + * returned. + */ +export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string { + const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin; + return token === undefined ? `${base}/` : `${base}/#token=${token}`; +} + +/** + * Split a full URL into the part before `#token=` and the `#token=…` fragment + * itself, so callers can render the fragment in a de-emphasized color. Returns + * `[fullUrl, '']` when there is no token fragment. + */ +export function splitTokenFragment(fullUrl: string): [string, string] { + const marker = '#token='; + const idx = fullUrl.indexOf(marker); + return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)]; +} + +export interface AccessUrlLine { + /** Fixed-width label including trailing padding, e.g. `"Local: "`. */ + label: string; + /** Full URL, carrying `#token=` when a token is known. */ + url: string; +} + +function isWildcard(host: string): boolean { + return host === '' || host === '0.0.0.0' || host === '::'; +} + +/** True when `host` is a loopback address (this host only). */ +export function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function hostOrigin(host: string, port: number): string { + const family = host.includes(':') ? 'IPv6' : 'IPv4'; + return `http://${formatHostForUrl(host, family)}:${port}`; +} + +/** + * Compute the access-URL lines for a bind host/port. + * + * - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one + * `Network:` line per non-loopback interface. + * - loopback: a single `Local:` line. + * - specific host: a single `URL:` line. + */ +export function accessUrlLines( + host: string, + port: number, + token: string | undefined, + networkAddresses?: NetworkAddress[], +): AccessUrlLine[] { + if (isWildcard(host)) { + const lines: AccessUrlLine[] = [ + { label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) }, + ]; + const addrs = networkAddresses ?? listNetworkAddresses(); + for (const addr of addrs) { + lines.push({ + label: 'Network: ', + url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token), + }); + } + return lines; + } + if (isLoopbackHost(host)) { + return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; + } + return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; +} diff --git a/apps/kimi-code/src/cli/sub/server/daemon.ts b/apps/kimi-code/src/cli/sub/server/daemon.ts index f8ef8ffa8..fadf7adb9 100644 --- a/apps/kimi-code/src/cli/sub/server/daemon.ts +++ b/apps/kimi-code/src/cli/sub/server/daemon.ts @@ -16,8 +16,8 @@ * foreground runner so it can share the same bootstrap helpers. */ -import { spawn } from 'node:child_process'; -import { appendFileSync, closeSync, mkdirSync, openSync } from 'node:fs'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { createServer } from 'node:net'; import { dirname, isAbsolute, join, resolve } from 'node:path'; @@ -44,18 +44,32 @@ const POLL_INTERVAL_MS = 200; const DEFAULT_DAEMON_LOG_LEVEL = 'info'; export interface EnsureDaemonOptions { + /** Bind host for the spawned daemon (default `127.0.0.1`). */ + host?: string; /** Preferred port; on conflict a free port is chosen automatically. */ port?: number; /** Pino log level for the spawned daemon (defaults to `info`). */ logLevel?: string; /** Mount `/api/v1/debug/*` routes on the spawned daemon. */ debugEndpoints?: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls?: boolean; + /** Keep `POST /api/v1/shutdown` enabled on a non-loopback bind. */ + allowRemoteShutdown?: boolean; + /** Keep the PTY `/api/v1/terminals/*` routes enabled on a non-loopback bind. */ + allowRemoteTerminals?: boolean; /** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */ idleGraceMs?: number; } export interface EnsureDaemonResult { readonly origin: string; + /** True when an already-running daemon was reused (no new server started). */ + readonly reused: boolean; + /** Bind host the running daemon is actually listening on (from the lock). */ + readonly host: string; + /** Port the running daemon is actually listening on (from the lock). */ + readonly port: number; } /** Path of the daemon log file (shared with the OS-service log location). */ @@ -178,13 +192,17 @@ export function resolveDaemonProgram( } interface SpawnDaemonChildOptions { + host?: string; port: number; logLevel: string; debugEndpoints?: boolean; + insecureNoTls?: boolean; + allowRemoteShutdown?: boolean; + allowRemoteTerminals?: boolean; idleGraceMs?: number; } -export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { +export function spawnDaemonChild(options: SpawnDaemonChildOptions): ChildProcess { const program = resolveDaemonProgram(); const logPath = daemonLogPath(); const logDir = dirname(logPath); @@ -198,9 +216,21 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { '--log-level', options.logLevel, ]; + if (options.host !== undefined) { + args.push('--host', options.host); + } if (options.debugEndpoints === true) { args.push('--debug-endpoints'); } + if (options.insecureNoTls === true) { + args.push('--insecure-no-tls'); + } + if (options.allowRemoteShutdown === true) { + args.push('--allow-remote-shutdown'); + } + if (options.allowRemoteTerminals === true) { + args.push('--allow-remote-terminals'); + } if (options.idleGraceMs !== undefined) { args.push('--idle-grace-ms', String(options.idleGraceMs)); } @@ -233,6 +263,7 @@ export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { } }); child.unref(); + return child; } finally { // `spawn` dups the fd into the child; the parent must not keep it open. closeSync(logFd); @@ -251,6 +282,7 @@ function sleep(ms: number): Promise { * detached process after this returns. */ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + const host = options.host ?? DEFAULT_SERVER_HOST; const preferred = options.port ?? DEFAULT_SERVER_PORT; const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; @@ -259,7 +291,12 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { + childExit = { code, signal }; + }); + child.once('error', () => { + // Spawn failure (ENOENT etc.) is already recorded in the log by + // spawnDaemonChild; treat it as an early exit here. + childExit = { code: -1, signal: null }; + }); + // 3. Wait until some live daemon (ours, or a racer that won the lock) is up. const deadline = Date.now() + SPAWN_TIMEOUT_MS; while (Date.now() < deadline) { @@ -282,14 +338,53 @@ export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise line.length > 0); + return lines.slice(-maxLines).join('\n'); + } catch { + return ''; + } +} diff --git a/apps/kimi-code/src/cli/sub/server/index.ts b/apps/kimi-code/src/cli/sub/server/index.ts index c17a61d18..b8e9a5ffa 100644 --- a/apps/kimi-code/src/cli/sub/server/index.ts +++ b/apps/kimi-code/src/cli/sub/server/index.ts @@ -17,6 +17,7 @@ import type { Command } from 'commander'; import { registerPsCommand } from './ps'; import { registerKillCommand } from './kill'; import { buildRunCommand } from './run'; +import { registerRotateTokenCommand } from './rotate-token'; import { registerWebAliasCommand } from './web-alias'; export function registerServerCommand(program: Command): void { @@ -33,6 +34,8 @@ export function registerServerCommand(program: Command): void { registerKillCommand(server); + registerRotateTokenCommand(server); + // OS service-manager commands (`install/uninstall/start/stop/restart/status`) // are temporarily hidden — the product now favors the on-demand background // daemon (`kimi web`) over service-ization. The implementation still lives in diff --git a/apps/kimi-code/src/cli/sub/server/kill.ts b/apps/kimi-code/src/cli/sub/server/kill.ts index 7275991e5..71b4f2e44 100644 --- a/apps/kimi-code/src/cli/sub/server/kill.ts +++ b/apps/kimi-code/src/cli/sub/server/kill.ts @@ -18,8 +18,10 @@ import type { Command } from 'commander'; import { getLiveLock, type LockContents } from '@moonshot-ai/server'; +import { getDataDir } from '#/utils/paths'; + import { lockConnectHost } from './daemon'; -import { serverOrigin } from './shared'; +import { authHeaders, serverOrigin, tryResolveServerToken } from './shared'; /** How long to wait for the graceful API shutdown request. */ const API_TIMEOUT_MS = 2000; @@ -32,7 +34,9 @@ const POLL_INTERVAL_MS = 100; export interface KillCommandDeps { getLiveLock(): LockContents | undefined; - requestShutdown(origin: string): Promise; + requestShutdown(origin: string, token: string | undefined): Promise; + /** Best-effort read of the persistent bearer token; undefined on miss. */ + resolveToken(): string | undefined; signalPid(pid: number, signal: NodeJS.Signals): boolean; pidAlive(pid: number): boolean; sleep(ms: number): Promise; @@ -66,8 +70,11 @@ export async function handleKillCommand(deps: KillCommandDeps): Promise { // 1. API path — best-effort graceful shutdown. Ignore every outcome: the // server may be an older build without the route, already wedged, or may - // drop the connection as it exits. - await deps.requestShutdown(origin).catch(() => {}); + // drop the connection as it exits. The bearer token (M5.1) is best-effort + // too: if it can't be read the API call 401s and the PID path below still + // guarantees the kill. + const token = deps.resolveToken(); + await deps.requestShutdown(origin, token).catch(() => {}); // 2. PID path — SIGTERM, wait, then SIGKILL. deps.signalPid(pid, 'SIGTERM'); @@ -126,7 +133,10 @@ export function signalPid(pid: number, signal: NodeJS.Signals): boolean { } /** POST the shutdown endpoint; resolves once the request completes or times out. */ -export async function requestShutdownViaApi(origin: string): Promise { +export async function requestShutdownViaApi( + origin: string, + token: string | undefined, +): Promise { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); @@ -134,6 +144,7 @@ export async function requestShutdownViaApi(origin: string): Promise { try { await fetch(`${origin}/api/v1/shutdown`, { method: 'POST', + headers: token !== undefined ? authHeaders(token) : undefined, signal: controller.signal, }); } finally { @@ -144,6 +155,7 @@ export async function requestShutdownViaApi(origin: string): Promise { const DEFAULT_KILL_DEPS: KillCommandDeps = { getLiveLock, requestShutdown: requestShutdownViaApi, + resolveToken: () => tryResolveServerToken(getDataDir()), signalPid, pidAlive, sleep: (ms) => diff --git a/apps/kimi-code/src/cli/sub/server/networks.ts b/apps/kimi-code/src/cli/sub/server/networks.ts new file mode 100644 index 000000000..39ca7d084 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/networks.ts @@ -0,0 +1,84 @@ +/** + * Enumerate this machine's non-loopback network interface addresses, used to + * print `Network: http://:/` hints (à la Vite) when the server + * binds a wildcard host (`0.0.0.0` / `::`). + */ + +import { networkInterfaces } from 'node:os'; + +export interface NetworkAddress { + /** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */ + address: string; + family: 'IPv4' | 'IPv6'; +} + +/** + * List non-internal interface addresses, IPv4 first then IPv6, preserving + * interface order within each family. + * + * Like Vite, this lists the machine's own interface addresses — LAN + * (192.168/10/172.16) plus any directly-assigned public address. It does not + * (and cannot, without an external service) discover a NAT-translated WAN IP, + * and we deliberately avoid any network call for a startup hint. + */ +export function listNetworkAddresses(): NetworkAddress[] { + const raw: NetworkAddress[] = []; + for (const entries of Object.values(networkInterfaces())) { + for (const info of entries ?? []) { + if (info.internal) { + continue; + } + if (info.family === 'IPv4') { + raw.push({ address: info.address, family: 'IPv4' }); + } else if (info.family === 'IPv6') { + raw.push({ address: info.address, family: 'IPv6' }); + } + } + } + return filterDisplayAddresses(raw); +} + +/** + * Drop addresses that are not useful as a connect target and de-duplicate. + * + * IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a + * zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it + * is pure noise — and it is the bulk of what `os.networkInterfaces()` reports + * on a typical machine. Duplicates (the same address reported on more than one + * interface) are collapsed. The result is IPv4 first, then IPv6, preserving + * order within each family. + */ +export function filterDisplayAddresses( + addrs: readonly NetworkAddress[], +): NetworkAddress[] { + const seen = new Set(); + const kept: NetworkAddress[] = []; + for (const addr of addrs) { + if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) { + continue; + } + if (seen.has(addr.address)) { + continue; + } + seen.add(addr.address); + kept.push(addr); + } + return [ + ...kept.filter((a) => a.family === 'IPv4'), + ...kept.filter((a) => a.family === 'IPv6'), + ]; +} + +/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */ +function isLinkLocalV6(address: string): boolean { + const first = Number.parseInt(address.split(':')[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +/** + * Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986, + * return IPv4 as-is. + */ +export function formatHostForUrl(address: string, family: NetworkAddress['family']): string { + return family === 'IPv6' ? `[${address}]` : address; +} diff --git a/apps/kimi-code/src/cli/sub/server/ps.ts b/apps/kimi-code/src/cli/sub/server/ps.ts index 7db750545..6e17d71c0 100644 --- a/apps/kimi-code/src/cli/sub/server/ps.ts +++ b/apps/kimi-code/src/cli/sub/server/ps.ts @@ -11,8 +11,10 @@ import type { Command } from 'commander'; import { getLiveLock } from '@moonshot-ai/server'; +import { getDataDir } from '#/utils/paths'; + import { lockConnectHost } from './daemon'; -import { isServerHealthy, serverOrigin } from './shared'; +import { authHeaders, isServerHealthy, resolveServerToken, serverOrigin } from './shared'; /** Wire shape of a single connection returned by `GET /api/v1/connections`. */ interface ConnectionInfo { @@ -62,7 +64,11 @@ async function handlePsCommand(opts: { json?: boolean }): Promise { throw new Error(`Kimi server at ${origin} is not responding.`); } - const connections = await fetchConnections(origin); + // The `/api/v1/connections` route is gated by bearer auth (M5.1). Read the + // persistent token; a clear error here means the server has never been + // started (no token file yet) or the token file was removed. + const token = resolveServerToken(getDataDir()); + const connections = await fetchConnections(origin, token); if (opts.json) { process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`); @@ -71,13 +77,14 @@ async function handlePsCommand(opts: { json?: boolean }): Promise { process.stdout.write(formatTable(connections)); } -async function fetchConnections(origin: string): Promise { +async function fetchConnections(origin: string, token: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, FETCH_TIMEOUT_MS); try { const res = await fetch(`${origin}/api/v1/connections`, { + headers: authHeaders(token), signal: controller.signal, }); if (!res.ok) { diff --git a/apps/kimi-code/src/cli/sub/server/rotate-token.ts b/apps/kimi-code/src/cli/sub/server/rotate-token.ts new file mode 100644 index 000000000..8f9a47970 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/server/rotate-token.ts @@ -0,0 +1,57 @@ +/** + * `kimi server rotate-token` — generate a new persistent server token. + * + * Rewrites `/server.token` (0600, atomic). The previous token + * stops working immediately: a running server re-reads the file on its next + * auth check, so rotation takes effect without a restart. + */ + +import { getLiveLock, rotateServerToken } from '@moonshot-ai/server'; +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { darkColors } from '#/tui/theme/colors'; +import { getDataDir } from '#/utils/paths'; + +import { accessUrlLines, splitTokenFragment } from './access-urls'; +import { DEFAULT_SERVER_HOST } from './shared'; + +export function registerRotateTokenCommand(server: Command): void { + server + .command('rotate-token') + .description( + 'Generate a new persistent server token; the previous token stops working immediately.', + ) + .action(async () => { + try { + const token = await rotateServerToken(getDataDir()); + process.stdout.write( + 'The previous token is now invalid. A running server picks up the new token automatically.\n', + ); + + // Token in the middle: indented and set off by blank lines (no color + // highlight), so it is easy to spot without dominating the output. + process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`); + + // Re-print the access links with the new token so the user can + // reconnect immediately. When a server is running its bind host/port + // come from the lock; otherwise there is nothing to connect to yet. + const lock = getLiveLock(); + if (lock !== undefined) { + const host = lock.host ?? DEFAULT_SERVER_HOST; + for (const { label, url: href } of accessUrlLines(host, lock.port, token)) { + // De-emphasize the `#token=…` fragment so the host/port stands out. + const [base, frag] = splitTokenFragment(href); + const rendered = + frag === '' + ? chalk.hex(darkColors.accent)(base) + : chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag); + process.stdout.write(` ${chalk.dim(label)}${rendered}\n`); + } + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/server/run.ts b/apps/kimi-code/src/cli/sub/server/run.ts index c556f9f5a..bf79c0d8b 100644 --- a/apps/kimi-code/src/cli/sub/server/run.ts +++ b/apps/kimi-code/src/cli/sub/server/run.ts @@ -13,7 +13,6 @@ import { join } from 'node:path'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import { startServer, type RunningServer } from '@moonshot-ai/server'; import chalk from 'chalk'; @@ -23,21 +22,30 @@ import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_UI_MODE } from '#/constant/app'; import { getNativeWebAssetsDir } from '#/native/web-assets'; import { darkColors } from '#/tui/theme/colors'; import { openUrl as defaultOpenUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; import { initializeServerTelemetry } from '../../telemetry'; import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; -import { ensureDaemon } from './daemon'; +import { + accessUrlLines, + buildOpenableUrl, + isLoopbackHost, + splitTokenFragment, +} from './access-urls'; +import { ensureDaemon, type EnsureDaemonResult } from './daemon'; +import { type NetworkAddress } from './networks'; import { DEFAULT_FOREGROUND_LOG_LEVEL, + DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT, parseServerOptions, + tryResolveServerToken, VALID_LOG_LEVELS, type ParsedServerOptions, type ServerCliOptions, } from './shared'; const WEB_ASSETS_DIR = 'dist-web'; -const READY_PANEL_WIDTH = 72; export interface RunCliOptions extends ServerCliOptions { open?: boolean; @@ -51,17 +59,49 @@ export interface StartForegroundHooks { } export interface RunCommandDeps { - startServerBackground(options: ParsedServerOptions): Promise<{ origin: string }>; + startServerBackground(options: ParsedServerOptions): Promise<{ + origin: string; + /** True when an already-running daemon was reused (no new server started). */ + reused?: boolean; + /** Bind host the running daemon is actually listening on (from the lock). */ + host?: string; + /** Port the running daemon is actually listening on (from the lock). */ + port?: number; + }>; /** Foreground runner; defaults to the real in-process runner when omitted. */ startServerForeground?: ( options: ParsedServerOptions, hooks?: StartForegroundHooks, ) => Promise; openUrl(url: string): void; + /** + * Best-effort read of the server's persistent bearer token. When it returns + * a token, the ready banner prints it and the opened Web UI URL carries it in + * the `#token=` fragment (M5.5). Optional so callers/tests that don't supply + * it simply print/open the plain origin. + */ + resolveToken?: () => string | undefined; + /** + * Non-loopback interface addresses to display for a wildcard bind. Defaults + * to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed + * list in tests for deterministic output. + */ + networkAddresses?: NetworkAddress[]; stdout: Pick; stderr: Pick; } +/** + * Build the Web UI URL, carrying the bearer token in the URL fragment. + * + * The token rides in `#token=` — a client-side fragment that is never + * sent to the server (so it never appears in server access logs) and is not + * logged by proxies. The Web UI reads it from `location.hash` after load. + */ +export function buildWebUrl(origin: string, token: string): string { + return buildOpenableUrl(origin, token); +} + /** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { return cmd @@ -70,6 +110,26 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT), ) + .option( + '--host ', + `Bind host (default ${DEFAULT_SERVER_HOST}). Use 0.0.0.0 to listen on all interfaces (requires --insecure-no-tls unless behind a TLS proxy). The bearer token is printed at startup.`, + String(DEFAULT_SERVER_HOST), + ) + .option( + '--insecure-no-tls', + 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Required to bind beyond 127.0.0.1; use a tunnel or reverse proxy in production.', + false, + ) + .option( + '--allow-remote-shutdown', + 'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).', + false, + ) + .option( + '--allow-remote-terminals', + 'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.', + false, + ) .option( '--log-level ', `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, @@ -119,34 +179,53 @@ export async function handleRunCommand( await startServerDaemon(parsed); return; } - const startedAt = Date.now(); + // Resolve the persistent token once: it is printed in the ready banner and + // rides in the opened Web UI URL's `#token=` fragment (M5.5). Falls back to + // the plain origin / no token line when unavailable. + const writeReady = (result: { origin: string; reused?: boolean; host?: string }): void => { + const { origin } = result; + const host = result.host ?? parsed.host; + const token = deps.resolveToken?.(); + let output = ''; + if (result.reused === true) { + // A daemon was already running, so this command's --host/--port/etc. did + // not start a new one. Say so loudly, then print the actual running + // server's URLs (using its real bind host, not the requested one). + output += formatReuseNotice(origin); + } + output += + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, host, { + token, + networkAddresses: deps.networkAddresses, + }) + : formatReadyLine(origin, token); + deps.stdout.write(output); + if (opts.open === true) { + deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + } + }; if (opts.foreground === true) { const run = deps.startServerForeground ?? startServerForeground; await run(parsed, { - onReady: (origin) => { - const readyMs = Date.now() - startedAt; - deps.stdout.write( - parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) - : `Kimi server: ${origin}\n`, - ); - if (opts.open === true) { - deps.openUrl(origin); - } - }, + onReady: (origin) => writeReady({ origin, reused: false, host: parsed.host }), }); return; } - const { origin } = await deps.startServerBackground(parsed); - const readyMs = Date.now() - startedAt; - deps.stdout.write( - parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) - : `Kimi server: ${origin}\n`, + const result = await deps.startServerBackground(parsed); + writeReady(result); +} + +function formatReuseNotice(origin: string): string { + return ( + `${chalk.hex(darkColors.warning)('A server is already running')} at ${origin} — ` + + `the options from this command were not applied. ` + + `Run ${chalk.bold('kimi server kill')} first to bind a new host/port.\n` ); - if (opts.open === true) { - deps.openUrl(origin); - } +} + +function formatReadyLine(origin: string, token: string | undefined): string { + return `Kimi server: ${buildOpenableUrl(origin, token)}\n`; } /** @@ -157,14 +236,17 @@ export async function handleRunCommand( */ export async function startServerBackground( options: ParsedServerOptions, -): Promise<{ origin: string }> { - const { origin } = await ensureDaemon({ +): Promise { + return ensureDaemon({ + host: options.host, port: options.port, logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, idleGraceMs: options.idleGraceMs, }); - return { origin }; } /** @@ -238,6 +320,9 @@ async function runServerInProcess( port: options.port, logLevel: options.logLevel, debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, webAssetsDir: serverWebAssetsDir(), coreProcessOptions: { identity: createKimiCodeHostIdentity(version), @@ -323,65 +408,80 @@ export function resolveServerWebAssetsDir( return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); } -function formatReadyBanner(origin: string, readyMs: number): string { +interface FormatReadyBannerOptions { + /** Persistent bearer token to print; omitted when unresolvable. */ + token?: string; + /** Non-loopback interface addresses to list for a wildcard bind. */ + networkAddresses?: NetworkAddress[]; +} + +function formatReadyBanner( + origin: string, + host: string, + opts: FormatReadyBannerOptions = {}, +): string { const primary = (text: string): string => chalk.hex(darkColors.primary)(text); const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); - const url = chalk.hex(darkColors.accent)(displayOrigin(origin)); - const width = READY_PANEL_WIDTH; - const innerWidth = width - 4; - const pad = ' '; + const url = (text: string): string => chalk.hex(darkColors.accent)(text); + // Render the `#token=…` fragment in a de-emphasized gray so the host/port + // stands out while the full URL stays selectable for copying. + const urlWithDimToken = (href: string): string => { + const [base, frag] = splitTokenFragment(href); + return frag === '' ? url(base) : url(base) + dim(frag); + }; + const port = Number(new URL(origin).port); + // Borderless header: the Kimi sprite (the little mascot with eyes) sits next + // to the title, keeping the brand without the enclosing box. const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; - const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); - const gap = ' '; - const textWidth = innerWidth - logoWidth - gap.length; - const headerLines = [ - primary(logo[0].padEnd(logoWidth)) + - gap + - truncateToWidth(title('Kimi server ready'), textWidth, '…'), - primary(logo[1].padEnd(logoWidth)) + - gap + - truncateToWidth(dim('Local web UI is available from this machine.'), textWidth, '…'), - ]; - const infoLines = [ - label('URL: ') + url, - label('Network: ') + muted('local only'), - label('Logs: ') + muted('off') + dim(' use --log-level info to enable'), - label('Stop: ') + muted('kimi server kill'), - label('Ready: ') + muted(`${String(Math.max(0, readyMs))} ms`), - label('Version: ') + muted(getVersion()), - ]; - const contentLines = [...headerLines, '', ...infoLines]; - - const lines = [ + const lines: string[] = [ + '', + ` ${primary(logo[0])} ${title('Kimi server ready')} ${dim(getVersion())}`, + ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, '', - primary('╭' + '─'.repeat(width - 2) + '╮'), - primary('│') + ' '.repeat(width - 2) + primary('│'), ]; - for (const content of contentLines) { - const truncated = truncateToWidth(content, innerWidth, '…'); - const rightPad = Math.max(0, innerWidth - visibleWidth(truncated)); - lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); + // Access links. + for (const { label: text, url: href } of accessUrlLines( + host, + port, + opts.token, + opts.networkAddresses, + )) { + lines.push(` ${label(text)}${urlWithDimToken(href)}`); + } + // On a loopback bind there is no network URL; show how to enable one. + if (isLoopbackHost(host)) { + lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host 0.0.0.0 to enable')}`); + } + if (opts.token !== undefined) { + // Set the token off with surrounding whitespace rather than color, so it is + // easy to spot without being highlighted. + lines.push(''); + lines.push(` ${label('Token: ')}${opts.token}`); + lines.push(''); } - lines.push(primary('│') + ' '.repeat(width - 2) + primary('│')); - lines.push(primary('╰' + '─'.repeat(width - 2) + '╯')); + // Auxiliary controls last. + lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); + lines.push(` ${label('Stop: ')}${muted('kimi server kill')}`); lines.push(''); return lines.join('\n'); } -function displayOrigin(origin: string): string { - return origin.endsWith('/') ? origin : `${origin}/`; -} - const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { startServerBackground, startServerForeground, openUrl: defaultOpenUrl, + resolveToken: () => { + // Read the persistent `/server.token` written on first boot + // (M5.1). Best-effort: a missing/older server yields undefined and the + // caller opens the plain origin. + return tryResolveServerToken(getDataDir()); + }, stdout: process.stdout, stderr: process.stderr, }; diff --git a/apps/kimi-code/src/cli/sub/server/shared.ts b/apps/kimi-code/src/cli/sub/server/shared.ts index b0550c65b..76287d57c 100644 --- a/apps/kimi-code/src/cli/sub/server/shared.ts +++ b/apps/kimi-code/src/cli/sub/server/shared.ts @@ -5,12 +5,18 @@ * `run`, `web`, and `status` all use. */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import type { ServerLogLevel } from '@moonshot-ai/server'; export const DEFAULT_SERVER_HOST = '127.0.0.1'; export const DEFAULT_SERVER_PORT = 58627; export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT); +/** Filename (under KIMI_CODE_HOME) of the persistent server bearer token. */ +export const SERVER_TOKEN_FILE = 'server.token'; + export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; @@ -36,6 +42,12 @@ export interface ParsedServerOptions { port: number; logLevel: ServerLogLevel; debugEndpoints: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls: boolean; + /** Allow `POST /api/v1/shutdown` on a non-loopback bind. */ + allowRemoteShutdown: boolean; + /** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */ + allowRemoteTerminals: boolean; /** Internal: run as an idle-exiting background daemon instead of foreground. */ daemon: boolean; /** Internal: idle-shutdown grace in ms (daemon mode only). */ @@ -47,6 +59,12 @@ export interface ServerCliOptions { port?: string; logLevel?: string; debugEndpoints?: boolean; + /** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */ + insecureNoTls?: boolean; + /** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */ + allowRemoteShutdown?: boolean; + /** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */ + allowRemoteTerminals?: boolean; /** Internal flag set by the daemon spawner (`kimi web`). */ daemon?: boolean; /** Internal flag set by the daemon spawner / tests. */ @@ -59,6 +77,9 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), debugEndpoints: opts.debugEndpoints === true, + insecureNoTls: opts.insecureNoTls === true, + allowRemoteShutdown: opts.allowRemoteShutdown === true, + allowRemoteTerminals: opts.allowRemoteTerminals === true, daemon: opts.daemon === true, idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), }; @@ -174,3 +195,41 @@ export async function ensureServerWebReady(origin: string): Promise { clearTimeout(timeout); } } + +/** + * Read the persistent bearer token for the server. + * + * The server writes `/server.token` (0600) on first boot and reuses + * it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route + * read it back here and send it as `Authorization: Bearer `. `homeDir` + * is the CLI's own KIMI_CODE_HOME resolution (`getDataDir()`). + * + * Throws a clear error when the file is missing/unreadable — the usual cause + * is a server that has never been started (no token file yet), or an older + * build that predates token auth. + */ +export function resolveServerToken(homeDir: string): string { + const tokenPath = join(homeDir, SERVER_TOKEN_FILE); + try { + return readFileSync(tokenPath, 'utf8').trim(); + } catch (error) { + throw new Error( + `unable to read server token at ${tokenPath}; has the server been started at least once?`, + { cause: error }, + ); + } +} + +/** Best-effort token read: returns `undefined` instead of throwing. */ +export function tryResolveServerToken(homeDir: string): string | undefined { + try { + return resolveServerToken(homeDir); + } catch { + return undefined; + } +} + +/** An `Authorization: Bearer ` header bag for `fetch`. */ +export function authHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/apps/kimi-code/test/cli/server/server.test.ts b/apps/kimi-code/test/cli/server/server.test.ts index b465d4e4f..0b9afb671 100644 --- a/apps/kimi-code/test/cli/server/server.test.ts +++ b/apps/kimi-code/test/cli/server/server.test.ts @@ -9,7 +9,7 @@ */ import type { ChildProcess } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -45,7 +45,7 @@ describe('kimi server', () => { const server = program.commands.find((c) => c.name() === 'server'); expect(server).toBeDefined(); const subs = server?.commands.map((c) => c.name()).toSorted(); - expect(subs).toEqual(['kill', 'ps', 'run']); + expect(subs).toEqual(['kill', 'ps', 'rotate-token', 'run']); }); it('`server run` exposes local-only foreground options', () => { @@ -55,10 +55,13 @@ describe('kimi server', () => { ?.commands.find((c) => c.name() === 'run'); expect(run).toBeDefined(); const longs = run!.options.map((o) => o.long).filter(Boolean); - expect(longs).not.toContain('--host'); + expect(longs).toContain('--host'); expect(longs).toContain('--port'); expect(longs).toContain('--log-level'); expect(longs).toContain('--debug-endpoints'); + expect(longs).toContain('--insecure-no-tls'); + expect(longs).toContain('--allow-remote-shutdown'); + expect(longs).toContain('--allow-remote-terminals'); expect(longs).toContain('--foreground'); // run defaults to NOT opening the browser → option is the positive --open expect(longs).toContain('--open'); @@ -87,7 +90,7 @@ describe('kimi server', () => { const longs = web!.options.map((o) => o.long).filter(Boolean); // web defaults to opening → the option is the negative form --no-open expect(longs).toContain('--no-open'); - expect(longs).not.toContain('--host'); + expect(longs).toContain('--host'); expect(longs).toContain('--port'); }); }); @@ -333,6 +336,44 @@ describe('`kimi server run` background start', () => { expect(parsed).toMatchObject({ logLevel: 'debug' }); }); + it('warns and uses the running server host when a daemon is reused', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + // The user asks for a public bind, but a loopback daemon is already up. + await handleRunCommand( + { port: '58627', host: '0.0.0.0' }, + { + startServerBackground: async () => ({ + origin: 'http://127.0.0.1:58627', + reused: true, + host: '127.0.0.1', + port: 58627, + }), + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const plain = stripAnsi(stdout); + // A clear notice that a server was already running and options were ignored. + expect(plain).toContain('A server is already running'); + expect(plain).toContain('kimi server kill'); + // The banner uses the *actual* host (loopback), not the requested 0.0.0.0 — + // so it shows a Local URL plus the "network disabled" hint, NOT real + // Network addresses (which would be misleading since nothing binds them). + expect(plain).toContain('http://127.0.0.1:58627/#token=tok'); + expect(plain).toContain('use --host 0.0.0.0 to enable'); + expect(plain).not.toContain('Network: http'); + }); + it('prints a TUI-style ready panel once the daemon is up', async () => { const { handleRunCommand } = await import('#/cli/sub/server/run'); let stdout = ''; @@ -357,21 +398,33 @@ describe('`kimi server run` background start', () => { ); const plain = stripAnsi(stdout); - expect(plain).toContain('╭'); - expect(plain).toContain('╰'); - expect(plain).toContain('▐█▛█▛█▌'); - expect(plain).toContain('▐█████▌'); expect(plain).toContain('Kimi server ready'); - expect(plain).toContain('URL:'); + expect(plain).toContain('Local:'); expect(plain).toContain('http://127.0.0.1:58627/'); + // Loopback bind shows a Network hint for enabling network access. expect(plain).toContain('Network:'); - expect(plain).toContain('local only'); + expect(plain).toContain('use --host 0.0.0.0 to enable'); expect(plain).toContain('Logs:'); expect(plain).toContain('off'); expect(plain).toContain('Stop:'); expect(plain).toContain('kimi server kill'); + // Version sits on the title line; no separate Ready:/Version: rows and no + // startup-time metric. + expect(plain).not.toContain('Ready:'); + expect(plain).not.toContain('Version:'); + expect(plain).not.toContain(' ms'); + // No bordered panel (the token URL must print in full for copying), but + // the Kimi sprite stays next to the title. + expect(plain).not.toContain('╭'); + expect(plain).not.toContain('╰'); + expect(plain).toContain('▐█▛█▛█▌'); + expect(plain).toContain('▐█████▌'); expect(plain).not.toContain('➜'); expect(plain).not.toContain('Kimi server:'); + + // Title is above the URLs; Logs/Stop are at the bottom. + expect(plain.indexOf('Kimi server ready')).toBeLessThan(plain.indexOf('Local:')); + expect(plain.indexOf('Logs:')).toBeLessThan(plain.indexOf('Stop:')); }); it('uses the TUI dark palette for the ready banner', async () => { @@ -407,8 +460,8 @@ describe('`kimi server run` background start', () => { expect(stdout).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌')); expect(stdout).toContain(color.bold.hex(darkColors.primary)('Kimi server ready')); expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/')); - expect(stdout).toContain(color.bold.hex(darkColors.textDim)('URL: ')); - expect(stdout).toContain(color.hex(darkColors.textMuted)('local only')); + expect(stdout).toContain(color.bold.hex(darkColors.textDim)('Local: ')); + expect(stdout).toContain(color.hex(darkColors.textMuted)('off')); }); }); @@ -564,6 +617,190 @@ async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promi throw new Error('could not allocate a run of adjacent free ports'); } +describe('--host threading (M6.2)', () => { + it('passes --host through to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0' }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ host: '0.0.0.0', port: 58627 }); + }); + + it('passes --host through to the foreground runner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { port: '58627', host: '0.0.0.0', foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0' }); + }); +}); + +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'); + // The daemon binds 0.0.0.0 (all interfaces), but the local CLI must + // connect over loopback — 0.0.0.0 is not a connectable address. The token + // then rides on that loopback connection (covered by the M5.4 kill/ps + // Authorization tests). + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '0.0.0.0' })).toBe( + '127.0.0.1', + ); + }); + + it('preserves a loopback / concrete bind host', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '127.0.0.1' })).toBe( + '127.0.0.1', + ); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627, host: '192.168.1.5' })).toBe( + '192.168.1.5', + ); + }); + + it('falls back to 127.0.0.1 when the lock has no host', async () => { + const { lockConnectHost } = await import('#/cli/sub/server/daemon'); + expect(lockConnectHost({ pid: 1, started_at: '', port: 58627 })).toBe('127.0.0.1'); + }); +}); + +describe('--insecure-no-tls threading (M6.3)', () => { + it('threads --insecure-no-tls to the foreground runner', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let foregroundOptions: unknown; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true, foreground: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + startServerForeground: async (options) => { + foregroundOptions = options; + return undefined as unknown as never; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(foregroundOptions).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); + + it('threads --insecure-no-tls to the background daemon', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let parsed: unknown; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true }, + { + startServerBackground: async (options) => { + parsed = options; + return { origin: 'http://0.0.0.0:58627' }; + }, + openUrl: vi.fn(), + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + + expect(parsed).toMatchObject({ insecureNoTls: true }); + }); +}); + +describe('ready banner reflects the bind class (M6.3)', () => { + it('lists Local + Network addresses for a 0.0.0.0 bind (Vite-style)', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '0.0.0.0', insecureNoTls: true }, + { + startServerBackground: async () => ({ origin: 'http://0.0.0.0:58627' }), + resolveToken: () => 'tok-xyz', + networkAddresses: [ + { address: '192.168.98.66', family: 'IPv4' }, + { address: '10.8.12.216', family: 'IPv4' }, + ], + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const raw = stripAnsi(stdout); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + expect(raw).toContain('Network:'); + // Full token-bearing URLs are printed plainly (no box, no truncation) so + // they are easy to copy. + expect(raw).toContain('http://localhost:58627/#token=tok-xyz'); + expect(raw).toContain('http://192.168.98.66:58627/#token=tok-xyz'); + expect(raw).toContain('http://10.8.12.216:58627/#token=tok-xyz'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-xyz'); + expect(raw).not.toContain('╭'); + }); + + it('prints the Local URL and token for a 127.0.0.1 bind', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + let stdout = ''; + + await handleRunCommand( + { host: '127.0.0.1' }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok-loop', + openUrl: vi.fn(), + stdout: { + write(chunk: string | Uint8Array) { + stdout += String(chunk); + return true; + }, + }, + stderr: { write: () => true }, + }, + ); + + const raw = stripAnsi(stdout); + expect(raw).toContain('Kimi server ready'); + expect(raw).toContain('Local:'); + // Full token-bearing URL, printed plainly for copying. + expect(raw).toContain('http://127.0.0.1:58627/#token=tok-loop'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-loop'); + expect(raw).not.toContain('╭'); + }); +}); + describe('resolveDaemonPort', () => { it('returns the preferred port when it is free', async () => { const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); @@ -673,6 +910,94 @@ describe('spawnDaemonChild', () => { expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) }); expect(options?.cwd).not.toBe(process.cwd()); }); + + it('passes --host through to the daemon child args (M6.2)', 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({ host: '0.0.0.0', port: 58627, logLevel: 'info' }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--host', '0.0.0.0'])); + }); + + it('passes --insecure-no-tls through to the daemon child args (M6.3)', 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({ host: '0.0.0.0', port: 58627, logLevel: 'info', insecureNoTls: true }); + + const [, args] = spawnMock.mock.calls[0]!; + expect(args).toEqual(expect.arrayContaining(['--insecure-no-tls'])); + }); +}); + +describe('ensureDaemon surfaces boot failures via early exit', () => { + let workDir: string; + let prevHome: string | undefined; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-ensure-exit-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = workDir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(workDir, { recursive: true, force: true }); + }); + + it('rejects fast with the exit reason and a log tail when the daemon exits early', async () => { + const { spawn } = await import('node:child_process'); + const spawnMock = vi.mocked(spawn); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + const { daemonLogPath, ensureDaemon } = await import('#/cli/sub/server/daemon'); + + // Seed the daemon log with the kind of line a failing boot writes, so we + // can assert it is surfaced to the user instead of a generic timeout. + mkdirSync(dirname(daemonLogPath()), { recursive: true }); + writeSync(daemonLogPath(), 'fatal: Refusing to bind a non-loopback host without TLS.\n'); + + // Fake child that exits with code 1 shortly after the 'exit' listener is + // attached — simulating a daemon that fails during boot. + const fakeChild = { + unref: vi.fn(), + once: vi.fn((event: string, cb: (...a: unknown[]) => void) => { + if (event === 'exit') { + setTimeout(() => cb(1, null), 5); + } + return fakeChild; + }), + }; + spawnMock.mockReturnValueOnce(fakeChild as unknown as ChildProcess); + + const start = Date.now(); + let caught: unknown; + try { + await ensureDaemon({ port: 0 }); + } catch (error) { + caught = error; + } + const elapsed = Date.now() - start; + + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect(message).toMatch(/exited with code 1/); + expect(message).toContain('Refusing to bind'); + // Must fail fast — nowhere near the 20s spawn timeout. + expect(elapsed).toBeLessThan(5000); + }); }); describe('createIdleShutdownHandler', () => { @@ -809,6 +1134,7 @@ function makeKillDeps(overrides: Partial = {}): { requestShutdown: async () => { state.shutdownCalls += 1; }, + resolveToken: () => undefined, signalPid: (pid, signal) => { signals.push({ pid, signal }); return true; @@ -883,5 +1209,271 @@ describe('`kimi server kill`', () => { }); }); +describe('resolveServerToken', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-server-token-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads the token from /server.token', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server.token'), 'secret-token\n'); + expect(resolveServerToken(dir)).toBe('secret-token'); + }); + + it('trims surrounding whitespace', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + writeFileSync(join(dir, 'server.token'), ' tok \n'); + expect(resolveServerToken(dir)).toBe('tok'); + }); + + it('throws a clear error when the token file is missing', async () => { + const { resolveServerToken } = await import('#/cli/sub/server/shared'); + expect(() => resolveServerToken(dir)).toThrow(/unable to read server token/); + }); +}); + +describe('authHeaders', () => { + it('builds a Bearer Authorization header', async () => { + const { authHeaders } = await import('#/cli/sub/server/shared'); + expect(authHeaders('abc')).toEqual({ Authorization: 'Bearer abc' }); + }); +}); + +describe('`kimi server kill` carries the bearer token', () => { + const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; + + it('passes the resolved token to requestShutdown', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveLock: () => liveLock, + resolveToken: () => 'tok-123', + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBe('tok-123'); + }); + + it('passes undefined when the token cannot be read (best-effort)', async () => { + const { handleKillCommand } = await import('#/cli/sub/server/kill'); + let seenToken: string | undefined = 'unset'; + const { deps } = makeKillDeps({ + getLiveLock: () => liveLock, + resolveToken: () => undefined, + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => false, + }); + + await handleKillCommand(deps); + + expect(seenToken).toBeUndefined(); + }); +}); + +describe('buildWebUrl', () => { + it('carries the token in the URL fragment (not path or query)', async () => { + const { buildWebUrl } = await import('#/cli/sub/server/run'); + const url = buildWebUrl('http://127.0.0.1:58627', 'abc123'); + expect(url).toBe('http://127.0.0.1:58627/#token=abc123'); + const parsed = new URL(url); + expect(parsed.hash).toBe('#token=abc123'); + // The token is client-side only: it must NOT appear in the path or query + // (which WOULD be sent to the server and logged). + expect(parsed.pathname).not.toContain('abc123'); + expect(parsed.search).not.toContain('abc123'); + }); + + it('normalizes a trailing slash', async () => { + const { buildWebUrl } = await import('#/cli/sub/server/run'); + expect(buildWebUrl('http://127.0.0.1:58627/', 't')).toBe( + 'http://127.0.0.1:58627/#token=t', + ); + }); +}); + +describe('accessUrlLines', () => { + it('returns Local + Network lines for a wildcard bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('0.0.0.0', 58627, 'tok', [ + { address: '192.168.1.5', family: 'IPv4' }, + ]); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://localhost:58627/#token=tok' }, + { label: 'Network: ', url: 'http://192.168.1.5:58627/#token=tok' }, + ]); + }); + + it('returns a single Local line for a loopback bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('127.0.0.1', 58627, 'tok'); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://127.0.0.1:58627/#token=tok' }, + ]); + }); + + it('returns a single URL line for a specific host (no token)', async () => { + const { accessUrlLines } = await import('#/cli/sub/server/access-urls'); + const lines = accessUrlLines('192.168.1.5', 58627, undefined); + expect(lines).toEqual([{ label: 'URL: ', url: 'http://192.168.1.5:58627/' }]); + }); + + it('splitTokenFragment splits off the #token= fragment', async () => { + const { splitTokenFragment } = await import('#/cli/sub/server/access-urls'); + expect(splitTokenFragment('http://h:1/#token=abc')).toEqual(['http://h:1/', '#token=abc']); + expect(splitTokenFragment('http://h:1/')).toEqual(['http://h:1/', '']); + }); +}); + +describe('`kimi web` / `server run --open` token fragment (M5.5)', () => { + it('opens the Web UI URL with the token fragment when a token is resolvable', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => 'tok-xyz', + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok-xyz'); + }); + + it('opens the plain origin when no token is resolvable', async () => { + const { handleRunCommand } = await import('#/cli/sub/server/run'); + const openUrl = vi.fn(); + await handleRunCommand( + { port: '58627', open: true }, + { + startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), + resolveToken: () => undefined, + openUrl, + stdout: { write: () => true }, + stderr: { write: () => true }, + }, + ); + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('`kimi server rotate-token`', () => { + let dir: string; + let prevHome: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-rotate-')); + prevHome = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = dir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = prevHome; + } + rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a new token to server.token and prints it', async () => { + const { registerServerCommand } = await import('#/cli/sub/server'); + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(token.length).toBeGreaterThan(20); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(token); + }); + + it('re-prints the access links with the new token when a server is running', async () => { + const { registerServerCommand } = await import('#/cli/sub/server'); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + // Fake a live lock pointing at this (alive) process so getLiveLock() finds + // the running server and the command can re-print its links. + mkdirSync(join(dir, 'server'), { recursive: true }); + writeSync( + join(dir, 'server', 'lock'), + JSON.stringify({ + pid: process.pid, + started_at: new Date().toISOString(), + port: 58627, + host: '127.0.0.1', + }), + ); + + const program = new Command('kimi').exitOverride(); + registerServerCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'kimi', 'server', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(`http://127.0.0.1:58627/#token=${token}`); + // Token line sits between the note and the links. + expect(stdout.indexOf('picks up the new token')).toBeLessThan( + stdout.indexOf('New server token'), + ); + expect(stdout.indexOf('New server token')).toBeLessThan( + stdout.indexOf(`http://127.0.0.1:58627/#token=${token}`), + ); + }); +}); + +describe('formatHostForUrl', () => { + it('bracket-wraps IPv6 and leaves IPv4 as-is', async () => { + const { formatHostForUrl } = await import('#/cli/sub/server/networks'); + expect(formatHostForUrl('192.168.1.5', 'IPv4')).toBe('192.168.1.5'); + expect(formatHostForUrl('fe80::1', 'IPv6')).toBe('[fe80::1]'); + }); +}); + +describe('filterDisplayAddresses', () => { + it('drops IPv6 link-local, de-duplicates, and orders IPv4 before IPv6', async () => { + const { filterDisplayAddresses } = await import('#/cli/sub/server/networks'); + const out = filterDisplayAddresses([ + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '192.168.1.5', family: 'IPv4' }, + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: 'fe80::1', family: 'IPv6' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + expect(out).toEqual([ + { address: '192.168.1.5', family: 'IPv4' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + }); +}); + // Silence vi import for cases where the file is built before tests reference vi. void vi; diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index ec2c11487..0edbabaed 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -33,8 +33,16 @@ import { useSidebarLayout } from './composables/useSidebarLayout'; import { useFilePreview, type DetailTarget } from './composables/useFilePreview'; import { useDetailPanel } from './composables/useDetailPanel'; import { useIsMobile } from './composables/useIsMobile'; +import ServerAuthDialog from './components/ServerAuthDialog.vue'; +import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; import type { AppConfig, ThinkingLevel } from './api/types'; +// Hydrate the server-transport credential (fragment token or sessionStorage) +// BEFORE the client connects, so the first REST/WS calls already carry it. +const hasServerCredential = initServerAuth(); +const showServerAuth = ref(!hasServerCredential); +let offAuthRequired: (() => void) | null = null; + const client = useKimiWebClient(); provide('resolveImage', client.resolveImageUrl); const { t } = useI18n(); @@ -98,10 +106,17 @@ onMounted(() => { // Capture-phase so Escape closes the side detail layer BEFORE the // conversation pane's bubble-phase handler interrupts a running prompt. document.addEventListener('keydown', onGlobalKeydown, true); + offAuthRequired = onAuthRequired(() => { + showServerAuth.value = true; + }); }); onUnmounted(() => { document.removeEventListener('keydown', onGlobalKeydown, true); + if (offAuthRequired !== null) { + offAuthRequired(); + offAuthRequired = null; + } }); function onGlobalKeydown(e: KeyboardEvent): void { @@ -504,6 +519,7 @@ function openPr(url: string): void { + + diff --git a/flake.nix b/flake.nix index e3e04414d..037283001 100644 --- a/flake.nix +++ b/flake.nix @@ -150,7 +150,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-yukns+b5mjDgp/TuFco24FH5pjJm/Ivlw71gMyhSl/Q="; + hash = "sha256-O9xDt/5bCakst2mKTyki3oyUph1g+CuH/BIqA/4fgYE="; }; nativeBuildInputs = [ diff --git a/packages/server/SECURITY.md b/packages/server/SECURITY.md new file mode 100644 index 000000000..77e135db5 --- /dev/null +++ b/packages/server/SECURITY.md @@ -0,0 +1,192 @@ +# Server deployment security + +Operational guide for exposing the `@moonshot-ai/server` HTTP/WebSocket API beyond +`127.0.0.1`. For reporting vulnerabilities, see the repo root `SECURITY.md`. + +## Threat model + +The server classifies the bind host into one of three tiers +(`services/auth/bindClassify.ts`): + +| Tier | Bind host | Trust boundary | Primary threats | Mitigations | +|---|---|---|---|---| +| loopback | `127.0.0.0/8`, `::1`, `localhost` (default) | This host only; local process isolation | Same-host processes/users connecting to the port; malicious web pages hitting `localhost` (CSRF / DNS rebinding) | Persistent bearer token; Host/Origin checks; token file `0600` (this user only) | +| LAN | RFC1918 + `169.254/16`, `fe80::/10` (`--host 192.168.x.x`) | The local network is untrusted | Network-reachable attackers; brute force; CSRF; remote shell / shutdown | **Full hardening (same as public):** bearer token auth (password optional), TLS or explicit opt-out, auth-failure rate limiting, dangerous endpoints disabled, security headers | +| public | Everything else; `0.0.0.0` / `::` / empty | The whole internet is untrusted | Same as LAN, at internet scale | Same hardening as LAN; a TLS-terminating reverse proxy (or tunnel) is strongly recommended | + +**Important:** the hardening gate in `start.ts` is `bindClass !== 'loopback'`. LAN and +public binds therefore receive the **same** hardening stack (TLS opt-out + rate-limit ++ endpoint downgrade + security headers). Authentication is the persistent bearer +token (printed in the startup banner); `KIMI_CODE_PASSWORD` is an optional additional +credential on every tier. The tier label only changes the banner and the automatic +Host allowlist entry. Treat LAN exposure with the same care as public exposure. + +Not in scope (see PLAN §6): NAT traversal, untrusted relays, end-to-end encryption. + +## Default (loopback) deployment + +``` +kimi server run # or: kimi web +``` + +- Binds `127.0.0.1:58627` by default (`--host` / `--port` to override). +- Uses a persistent bearer token (`crypto.randomBytes(32)`, base64url) generated + once on first boot and written to `/server.token` with mode + `0600` (parent directory `0700`). The same token is reused across restarts; it + is NOT deleted when the server exits. Rotate it explicitly with + `kimi server rotate-token` (a running server picks up the new token without a + restart). +- The local CLI reads that token file automatically and sends + `Authorization: Bearer ` on every REST/WebSocket call — no setup required. +- Only loopback is reachable; nothing is exposed to the network. + +## LAN deployment + +Bind a specific LAN interface. Because the hardening gate is `bindClass !== 'loopback'`, +a LAN bind gets the same hardening stack as a public bind: + +``` +kimi server run --host 192.168.1.10 --insecure-no-tls +``` + +- Authentication is the persistent bearer token printed in the startup banner; send it + as `Authorization: Bearer `. `KIMI_CODE_PASSWORD` is optional and adds a + second credential (it is never required). +- `--insecure-no-tls` is required unless TLS is terminated in front of the server + (reverse proxy or tunnel). Use it only on a trusted network. +- `POST /api/v1/shutdown` and the PTY `/api/v1/terminals/*` routes are **404 by + default** on LAN too. Re-enable only with `--allow-remote-shutdown` / + `--allow-remote-terminals`. +- Auth-failure rate limiting and security response headers are active. + +## Public deployment + +A public bind is `0.0.0.0`, `::`, an empty host, or any non-RFC1918 address. Put the +server behind a TLS-terminating reverse proxy (or a tunnel); do not terminate TLS in +process. + +``` +kimi server run --host 0.0.0.0 --insecure-no-tls +``` + +- Authentication is the persistent bearer token printed in the startup banner; + `KIMI_CODE_PASSWORD` is optional. `--insecure-no-tls` is mandatory here because + the proxy terminates TLS; without it (and without app-level TLS) the server refuses + to bind. +- The reverse proxy must pass through the `Authorization` header and the `Host` + header, and must upgrade WebSocket connections (see examples below). +- `POST /api/v1/shutdown` and `/api/v1/terminals/*` are **404 by default**. Re-enable + only with `--allow-remote-shutdown` / `--allow-remote-terminals`. +- Auth-failure rate limiting (10 failures / 60 s window / 60 s ban per source, + response code `42901`) and security headers are active. +- Prefer a tunnel (`cloudflared`, `ssh -R`) over a raw public bind where possible. + +## Reverse-proxy examples + +The server listens on `127.0.0.1:58627`; the proxy terminates TLS and forwards to it. + +**Caddy** (`Caddyfile`; automatic HTTPS): + +``` +kimi.example.com { + reverse_proxy 127.0.0.1:58627 +} +``` + +Caddy's `reverse_proxy` passes `Host`, `Authorization`, and the WebSocket upgrade +headers by default. + +**nginx** (TLS terminated at nginx): + +``` +server { + listen 443 ssl; + server_name kimi.example.com; + + ssl_certificate /etc/letsencrypt/live/kimi.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/kimi.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:58627; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +**Tunnel alternatives:** + +- `cloudflared tunnel --url http://127.0.0.1:58627` (Cloudflare Tunnel; no inbound + firewall rule). +- `ssh -R` (SSH remote port forwarding) to publish the loopback port via a host you + control. + +For both tunnels, keep the server on the default loopback bind; the tunnel provides +the remote reachability and TLS. + +## Credential management + +- **Token** — a persistent bearer token generated once on first boot, held in + memory and written to `/server.token` (`0600`, directory + `0700`). It survives restarts and is reused until explicitly rotated with + `kimi server rotate-token`, which rewrites the file (the previous token stops + working immediately, even for a running server). The CLI reads it + automatically; treat the file as a secret. +- **Password** — set `KIMI_CODE_PASSWORD` in the environment. The server hashes it at + boot with bcrypt (cost 12) and keeps only the hash in memory; verification uses + `bcrypt.compare`. Password auth is accepted as `Authorization: Bearer `. +- **Stored password hash** — the current path is env-only. There is **no** + `set-password` subcommand and no config-file hash option yet. If a config-stored + hash is needed later, generate one externally with cost 12, e.g.: + + ``` + node -e "require('bcryptjs').hash(process.env.KIMI_CODE_PASSWORD,12).then(h=>console.log(h))" + ``` + + (This is forward-looking; do not rely on it until config support lands.) + +## Host / Origin allowlists + +Host and Origin checks run on every request (HTTP and WebSocket upgrade) on all +tiers. + +- **Default Host allowlist:** `localhost`, `*.localhost`, `127.0.0.1`, `::1`, + `[::1]`, and the actual bind host/IP. Requests with any other `Host` get + `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_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`. + The bundled Web UI is same-origin and needs no entry. + Example: `KIMI_CODE_CORS_ORIGINS=https://kimi.example.com`. +- **`KIMI_CODE_DISABLE_HOST_CHECK=1`** — disables the Host check entirely on **all** + tiers (loopback, LAN, and public). Test/controlled environments only; this removes + the DNS-rebinding protection and must not be set in production. + +## Authentication reference + +- **HTTP:** `Authorization: Bearer ` on every route except + `GET /api/v1/healthz`, `OPTIONS *`, and the static Web UI assets (`/`, `/*`). + Failure returns `401` with code `40101`. +- **WebSocket:** send either an `Authorization: Bearer ` header or + the subprotocol `Sec-WebSocket-Protocol: kimi-code.bearer.` (for browsers + that cannot set WS headers). The server echoes the matching subprotocol on accept; + a failed check destroys the socket. + +## CLI flags + +The only server-exposure flags are: + +| Flag | Purpose | +|---|---| +| `--host ` | Bind host (default `127.0.0.1`). | +| `--port ` | Bind port (default `58627`). | +| `--insecure-no-tls` | Allow a non-loopback bind without app-level TLS (i.e. TLS is terminated by a proxy/tunnel). | +| `--allow-remote-shutdown` | Re-enable `POST /api/v1/shutdown` on a non-loopback bind. | +| `--allow-remote-terminals` | Re-enable the PTY `/api/v1/terminals/*` routes on a non-loopback bind. | + +There is no `--bind-class` flag and no `set-password` subcommand. diff --git a/packages/server/package.json b/packages/server/package.json index c8346872e..b5de0a691 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -42,6 +42,7 @@ "@fastify/swagger": "^9.7.0", "@moonshot-ai/agent-core": "workspace:^", "@moonshot-ai/protocol": "workspace:^", + "bcryptjs": "^2.4.3", "fastify": "^5.1.0", "pino": "^9.5.0", "pino-pretty": "^13.0.0", @@ -51,6 +52,7 @@ "zod-to-json-schema": "^3.25.2" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/ws": "^8.18.0" } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8a043c88f..f312ac657 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,6 +2,9 @@ export { startServer, ServerLockedError } from './start'; export type { ServerStartOptions, RunningServer } from './start'; export { okEnvelope, errEnvelope } from './envelope'; export type { Envelope } from './envelope'; +export { classify } from './services/auth/bindClassify'; +export type { BindClass } from './services/auth/bindClassify'; +export { rotateServerToken, serverTokenPath } from './services/auth/persistentToken'; export { createServerLogger } from './services/pinoLoggerService'; export type { CreateLoggerOptions, diff --git a/packages/server/src/lock.ts b/packages/server/src/lock.ts index db516532f..521f413d4 100644 --- a/packages/server/src/lock.ts +++ b/packages/server/src/lock.ts @@ -165,8 +165,10 @@ function tryExclusiveCreate(path: string, contents: LockContents): boolean { let fd: number | undefined; try { // 0o100 (O_CREAT) | 0o200 (O_EXCL) | 0o2 (O_RDWR) — but `openSync` accepts the - // string flag form which is portable. - fd = openSync(path, 'wx'); + // string flag form which is portable. Mode 0o600 so the lock file (which + // lives next to the per-pid token file) is not world/group readable + // (ROADMAP M5.2). + fd = openSync(path, 'wx', 0o600); writeFileSync(fd, JSON.stringify(contents)); return true; } catch (err) { diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 000000000..00b07759f --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -0,0 +1,134 @@ +/** + * Global HTTP bearer-auth hook (ROADMAP M2.2). + * + * `createAuthHook` builds a Fastify `onRequest` hook that requires a valid + * `Authorization: Bearer ` on every non-bypassed route. It is NOT wired + * into `start.ts` in M2 — that happens in M5.1. Until then it is exercised in + * isolation via tests on a minimal Fastify app. + * + * 401 responses use the reserved daemon code `40101` + * (`packages/protocol/src/error-codes.ts` intentionally omits it; the protocol + * package is left untouched). `errEnvelope(code: number, …)` accepts a plain + * number, so the literal is passed directly. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { errEnvelope } from '#/envelope'; +import { + AUTH_RATE_LIMIT_CODE, + AUTH_RATE_LIMIT_MSG, + type AuthFailureLimiter, +} from '#/middleware/rateLimit'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; + +/** Daemon-reserved unauthorized code (not in the protocol `ErrorCode` enum). */ +const AUTH_ERROR_CODE = 40101; +const AUTH_ERROR_MSG = 'Unauthorized'; +const REDACTED = '[redacted]'; +const BEARER_PREFIX = 'Bearer '; + +export interface AuthHookOptions { + /** Return true to skip auth for this request (bypass whitelist). */ + readonly isBypassed?: (req: FastifyRequest) => boolean; + /** + * Optional per-source auth-failure limiter (ROADMAP M6.4). When present, a + * banned source is rejected with `429` before auth runs, and each failed + * attempt is recorded. Wired only on non-loopback binds from `start.ts`. + */ + readonly limiter?: Pick; +} + +/** + * Default bypass policy — the security boundary. + * + * Bypassed (no token required): + * - every `OPTIONS` request (CORS preflight); + * - `GET /api/v1/healthz` (liveness probe for supervisors / load balancers); + * - static web assets, defined as any path that does NOT start with `/api/` + * AND is not one of the meta documents `/openapi.json` / `/asyncapi.json`. + * + * NOT bypassed (token required): all `/api/…` routes plus `/openapi.json` and + * `/asyncapi.json` (the meta documents leak the API shape, so they stay gated). + */ +function defaultIsBypassed(req: FastifyRequest): boolean { + if (req.method === 'OPTIONS') { + return true; + } + const path = req.url.split('?', 1)[0] ?? req.url; + if (req.method === 'GET' && path === '/api/v1/healthz') { + return true; + } + const isApi = path.startsWith('/api/'); + const isMeta = path === '/openapi.json' || path === '/asyncapi.json'; + return !isApi && !isMeta; +} + +/** + * Extract the bearer token from the raw `Authorization` header. + * + * Returns `null` when the header is missing, lacks the case-sensitive + * `Bearer ` prefix, or carries an empty token — all treated as 401. + */ +function extractBearer(header: string | undefined): string | null { + if (header === undefined || !header.startsWith(BEARER_PREFIX)) { + return null; + } + const token = header.slice(BEARER_PREFIX.length); + return token.length === 0 ? null : token; +} + +/** + * Build the global `onRequest` auth hook. + * + * Order inside the hook matters: the raw token is extracted first, then the + * header view is redacted for downstream request logging (`start.ts` logs + * requests), and only then is the candidate validated — so auth still sees the + * real token while logs never do. Returning the `reply` from the async hook + * short-circuits Fastify on 401 (the route handler never runs). + */ +export function createAuthHook( + authTokenService: IAuthTokenService, + opts?: AuthHookOptions, +): (req: FastifyRequest, reply: FastifyReply) => Promise { + const isBypassed = opts?.isBypassed ?? defaultIsBypassed; + + return async (req, reply) => { + // Rate-limit check (ROADMAP M6.4): a banned source is rejected before any + // auth work — even a valid token cannot bypass an active ban. Loopback + // binds pass no limiter, so this branch is a no-op there. + if (opts?.limiter?.isBanned(req.ip) === true) { + return reply + .code(429) + .send(errEnvelope(AUTH_RATE_LIMIT_CODE, AUTH_RATE_LIMIT_MSG, req.id)); + } + + const header = req.headers.authorization; + const token = extractBearer(header); + + // Redact the header view BEFORE the rest of the pipeline logs the request. + // Auth has already consumed the raw value above, so this only affects the + // downstream log view of the request. + if (header !== undefined) { + req.headers.authorization = REDACTED; + } + + if (isBypassed(req)) { + return; + } + + if (token === null) { + opts?.limiter?.recordFailure(req.ip); + return reply + .code(401) + .send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id)); + } + + if (!(await authTokenService.isValid(token))) { + opts?.limiter?.recordFailure(req.ip); + return reply + .code(401) + .send(errEnvelope(AUTH_ERROR_CODE, AUTH_ERROR_MSG, req.id)); + } + }; +} diff --git a/packages/server/src/middleware/hostnames.ts b/packages/server/src/middleware/hostnames.ts new file mode 100644 index 000000000..7b620b9e3 --- /dev/null +++ b/packages/server/src/middleware/hostnames.ts @@ -0,0 +1,166 @@ +/** + * Host-header allowlist middleware (ROADMAP M4.1). + * + * `createHostCheck` builds a Fastify `onRequest` hook that rejects requests + * whose `Host` header is not in the allowlist with a `403 Invalid Host header` + * envelope. This is the primary DNS-rebinding defence once the server is + * reachable beyond localhost (PLAN §3.4). + * + * Default-allow set (no configuration required): + * - `localhost`, `*.localhost` (any subdomain of `localhost`); + * - `127.0.0.1`, `::1`, `[::1]`; + * - any literal IP (`net.isIP(host) !== 0`); + * - the host the server actually bound to (`boundHost`); + * - caller-supplied extras (`extra`), where a leading `.` matches the bare + * domain and any subdomain (e.g. `.example.com` matches `example.com` and + * `a.example.com`). + * + * The default set is intentionally permissive for loopback/IP access so that + * `app.inject` (default `Host: localhost:80`) and real `fetch` to + * `127.0.0.1:` keep working — existing HTTP/WS tests rely on this. + * + * 403 responses use the reserved daemon code `40301` + * (`packages/protocol/src/error-codes.ts` intentionally omits it; the protocol + * package is left untouched). `errEnvelope(code: number, …)` accepts a plain + * number, so the literal is passed directly. + */ + +import net from 'node:net'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +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). */ + readonly boundHost?: string; + /** Extra allowed hosts / domain-suffix patterns (from `KIMI_CODE_ALLOWED_HOSTS`). */ + readonly extra?: readonly string[]; + /** Disable the check entirely (`KIMI_CODE_DISABLE_HOST_CHECK=1`; test-only). */ + readonly disable?: boolean; +} + +/** Returned by {@link createHostCheck}: the Fastify hook plus the raw predicate. */ +export interface HostCheck { + /** Fastify `onRequest` hook that 403s on a disallowed `Host`. */ + readonly onRequest: (req: FastifyRequest, reply: FastifyReply) => Promise; + /** Reusable predicate (also used by the WS upgrade path in M4.3). */ + readonly isAllowed: (host: string | undefined) => boolean; +} + +/** + * Parse `KIMI_CODE_ALLOWED_HOSTS` into an `extra` allowlist. + * + * Comma-separated, trimmed, empties dropped. A leading `.` is preserved so the + * caller can express domain-suffix wildcards (`.example.com`). + */ +export function parseAllowedHosts(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env['KIMI_CODE_ALLOWED_HOSTS']; + if (raw === undefined) { + return []; + } + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** True when `KIMI_CODE_DISABLE_HOST_CHECK=1` (test/controlled env only). */ +export function isHostCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env['KIMI_CODE_DISABLE_HOST_CHECK'] === '1'; +} + +/** + * Strip a trailing `:port` from a `Host` value and lowercase it. + * + * Handles: + * - bracketed IPv6 with a port: `[::1]:80` → `[::1]`; + * - host/IPv4 with a port: `localhost:80` → `localhost`, `1.2.3.4:5678` → `1.2.3.4`; + * - bare values (no port): returned lowercased as-is; + * - bare IPv6 without brackets (multiple colons, e.g. `::1`): returned + * lowercased as-is — there is no unambiguous port to strip. + */ +export function stripPort(host: string): string { + if (host.startsWith('[')) { + const end = host.indexOf(']'); + return (end === -1 ? host : host.slice(0, end + 1)).toLowerCase(); + } + const firstColon = host.indexOf(':'); + if (firstColon === -1) { + return host.toLowerCase(); + } + const lastColon = host.lastIndexOf(':'); + if (firstColon === lastColon) { + const after = host.slice(lastColon + 1); + if (after.length > 0 && /^\d+$/.test(after)) { + return host.slice(0, lastColon).toLowerCase(); + } + } + // Multiple colons (bare IPv6) or a non-digit suffix — no port to strip. + return host.toLowerCase(); +} + +/** + * Decide whether a `Host` value is allowed under the given options. + * + * Missing/empty `Host` is rejected (HTTP/1.1 requires it). The check is a no-op + * when `opts.disable` is set. + */ +export function isAllowedHost(host: string | undefined, opts: HostCheckOptions): boolean { + if (opts.disable === true) { + return true; + } + if (host === undefined || host.length === 0) { + return false; + } + const h = stripPort(host); + + if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]') { + return true; + } + if (h.endsWith('.localhost')) { + return true; + } + if (net.isIP(h) !== 0) { + return true; + } + if (opts.boundHost !== undefined && h === stripPort(opts.boundHost)) { + return true; + } + if (opts.extra !== undefined) { + for (const entry of opts.extra) { + if (entry.startsWith('.')) { + const base = entry.slice(1); + if (h === base || h.endsWith(entry)) { + return true; + } + } else if (h === entry) { + return true; + } + } + } + return false; +} + +/** + * Build the Fastify `onRequest` hook and the reusable `isAllowed` predicate. + * + * Returning the `reply` from the hook short-circuits Fastify on 403 so the + * route handler never runs. + */ +export function createHostCheck(opts: HostCheckOptions): HostCheck { + const isAllowed = (host: string | undefined): boolean => isAllowedHost(host, opts); + const onRequest = async ( + req: FastifyRequest, + reply: FastifyReply, + ): Promise => { + if (!isAllowed(req.headers.host)) { + return reply.code(403).send(errEnvelope(HOST_ERROR_CODE, HOST_ERROR_MSG, req.id)); + } + }; + return { onRequest, isAllowed }; +} diff --git a/packages/server/src/middleware/origin.ts b/packages/server/src/middleware/origin.ts new file mode 100644 index 000000000..7820010f9 --- /dev/null +++ b/packages/server/src/middleware/origin.ts @@ -0,0 +1,122 @@ +/** + * Origin / CORS middleware (ROADMAP M4.2). + * + * HTTP `onRequest` hook: + * - no `Origin` header → non-CORS / same-origin request → proceeds untouched; + * - same-origin (`Origin` host === `Host`, port stripped both sides) → allowed; + * - cross-origin → allowed only if the full origin (scheme + host) is in the + * explicit whitelist (`KIMI_CODE_CORS_ORIGINS`, no `*` wildcard — PLAN + * §3.4). Allowed origins get `Access-Control-Allow-*` echoed; `OPTIONS` + * preflight short-circuits to `204`; + * - cross-origin and NOT whitelisted → no CORS headers are emitted, so the + * browser blocks the response. `OPTIONS` still returns `204` (without CORS + * headers) so the preflight fails closed. + * + * `isOriginAllowed` is also exported for the WS upgrade path (M4.3). There, + * absent/malformed `Origin` is treated as allowed (non-browser Node `ws` + * clients send no `Origin`); a present-but-disallowed browser Origin is + * rejected. See M4.3 for the deliberate present-only deviation. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { stripPort } from './hostnames'; + +const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; +const CORS_ALLOW_HEADERS = 'Content-Type, Authorization'; + +export interface OriginHookOptions { + /** Explicit cross-origin allowlist (full origin strings, scheme + host). */ + readonly allowedOrigins?: readonly string[]; +} + +/** + * Parse `KIMI_CODE_CORS_ORIGINS` into an allowlist. + * + * Comma-separated, trimmed, empties dropped. No `*` wildcard — every entry is + * an explicit origin (PLAN §3.4). + */ +export function parseCorsOrigins(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env['KIMI_CODE_CORS_ORIGINS']; + if (raw === undefined) { + return []; + } + return raw + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +/** + * Return the `host` (host[:port], default port dropped) of an `Origin` value, + * or `undefined` when the origin is missing or malformed. + */ +export function originHost(origin: string | undefined): string | undefined { + if (origin === undefined) { + return undefined; + } + try { + return new URL(origin).host; + } catch { + return undefined; + } +} + +/** + * Decide whether an `Origin` is allowed for a request to `host`. + * + * - missing/malformed `Origin` → allowed (non-CORS / non-browser client); + * - same-origin (`Origin` host === `Host`, port stripped both sides) → allowed; + * - otherwise → allowed only when the full origin string is in `allowed`. + */ +export function isOriginAllowed( + origin: string | undefined, + host: string | undefined, + allowed: readonly string[], +): boolean { + const oh = originHost(origin); + if (oh === undefined) { + return true; + } + if (host !== undefined && stripPort(oh) === stripPort(host)) { + return true; + } + // `origin` is defined here (originHost returned a host), so the whitelist + // match is against the full origin string (scheme + host). + return allowed.includes(origin as string); +} + +/** + * Build the Fastify `onRequest` CORS hook. + * + * Allowed origins get `Access-Control-Allow-*` echoed and `OPTIONS` preflights + * short-circuit to `204`. Disallowed origins get no CORS headers (the browser + * blocks the response); their `OPTIONS` preflight still returns `204` so it + * fails closed without leaking headers. + */ +export function createOriginHook( + opts: OriginHookOptions, +): (req: FastifyRequest, reply: FastifyReply) => Promise { + const allowed = opts.allowedOrigins ?? []; + return async (req, reply) => { + const origin = req.headers.origin; + if (origin === undefined) { + return; + } + if (isOriginAllowed(origin, req.headers.host, allowed)) { + reply.header('Access-Control-Allow-Origin', origin); + reply.header('Access-Control-Allow-Methods', CORS_ALLOW_METHODS); + reply.header('Access-Control-Allow-Headers', CORS_ALLOW_HEADERS); + reply.header('Vary', 'Origin'); + if (req.method === 'OPTIONS') { + return reply.code(204).send(); + } + return; + } + // Origin present but not allowed: emit no CORS headers so the browser + // blocks the response. Short-circuit the preflight to fail closed. + if (req.method === 'OPTIONS') { + return reply.code(204).send(); + } + }; +} diff --git a/packages/server/src/middleware/rateLimit.ts b/packages/server/src/middleware/rateLimit.ts new file mode 100644 index 000000000..ba400b8de --- /dev/null +++ b/packages/server/src/middleware/rateLimit.ts @@ -0,0 +1,103 @@ +/** + * Auth-failure rate limiter (ROADMAP M6.4). + * + * A per-`remoteAddress` failure counter that temporarily bans a source after + * too many failed authentication attempts. Wired into `createAuthHook` only on + * non-loopback binds (`start.ts`), so the loopback default keeps its existing + * "no rate limit" behavior while a public/LAN bind slows brute-force attempts. + * + * Policy: a source is banned when it accumulates `maxFailures` failures within + * a sliding `windowMs` window; the ban lasts `banMs`. Once banned, every + * request from that source is rejected with `429` until the ban expires — even + * a request carrying a valid token — so a banned attacker cannot keep probing. + */ + +/** Reserved daemon code for rate-limited auth (not in the protocol enum). */ +export const AUTH_RATE_LIMIT_CODE = 42901; +export const AUTH_RATE_LIMIT_MSG = 'Too many failed auth attempts'; + +export interface AuthFailureLimiterOptions { + /** Failures within {@link windowMs} that trigger a ban. Default `10`. */ + readonly maxFailures?: number; + /** Rolling failure window in ms. Default `60_000`. */ + readonly windowMs?: number; + /** Ban duration in ms once the threshold is hit. Default `60_000`. */ + readonly banMs?: number; +} + +/** Minimal surface consumed by `createAuthHook`. */ +export interface AuthFailureLimiter { + /** Record one failed auth attempt for `ip`. */ + recordFailure(ip: string): void; + /** True while `ip` is inside an active ban window. */ + isBanned(ip: string): boolean; + /** Stop the periodic cleanup timer and drop all state (shutdown / tests). */ + dispose(): void; +} + +interface Entry { + /** Failures recorded since {@link windowStart}. */ + count: number; + /** Start (ms epoch) of the current failure window. */ + windowStart: number; + /** ms epoch until which the source is banned; `0` when not banned. */ + bannedUntil: number; +} + +const DEFAULT_MAX_FAILURES = 10; +const DEFAULT_WINDOW_MS = 60_000; +const DEFAULT_BAN_MS = 60_000; + +/** + * Build a per-source auth-failure limiter. + * + * A periodic sweep drops entries that are neither banned nor within an active + * failure window so the map does not grow without bound on a long-lived + * public server. The timer is `unref`-ed so it never keeps the process alive. + */ +export function createAuthFailureLimiter( + opts?: AuthFailureLimiterOptions, +): AuthFailureLimiter { + const maxFailures = opts?.maxFailures ?? DEFAULT_MAX_FAILURES; + const windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS; + const banMs = opts?.banMs ?? DEFAULT_BAN_MS; + const entries = new Map(); + + const sweep = setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of entries) { + const banned = entry.bannedUntil > now; + const windowLive = now - entry.windowStart <= windowMs; + if (!banned && !windowLive) { + entries.delete(ip); + } + } + }, windowMs); + if (typeof sweep.unref === 'function') { + sweep.unref(); + } + + return { + recordFailure(ip: string): void { + const now = Date.now(); + let entry = entries.get(ip); + if (entry === undefined || now - entry.windowStart > windowMs) { + entry = { count: 0, windowStart: now, bannedUntil: 0 }; + entries.set(ip, entry); + } + entry.count += 1; + if (entry.count >= maxFailures) { + entry.bannedUntil = now + banMs; + } + }, + isBanned(ip: string): boolean { + const entry = entries.get(ip); + if (entry === undefined) return false; + return entry.bannedUntil > Date.now(); + }, + dispose(): void { + clearInterval(sweep); + entries.clear(); + }, + }; +} diff --git a/packages/server/src/routes/registerApiV1Routes.ts b/packages/server/src/routes/registerApiV1Routes.ts index c710641ea..38297e5f0 100644 --- a/packages/server/src/routes/registerApiV1Routes.ts +++ b/packages/server/src/routes/registerApiV1Routes.ts @@ -46,6 +46,17 @@ interface ApiV1RouteHost { export interface RegisterApiV1RoutesOptions { readonly serverVersion: string; readonly debugEndpoints?: boolean; + /** + * Mount `POST /api/v1/shutdown`. Defaults to true (backward compatible); + * `start.ts` sets it false on a public bind unless `--allow-remote-shutdown`. + */ + readonly enableShutdown?: boolean; + /** + * Mount the PTY `/api/v1/terminals/*` routes. Defaults to true (backward + * compatible); `start.ts` sets it false on a public bind unless + * `--allow-remote-terminals`. + */ + readonly enableTerminals?: boolean; } export async function registerApiV1Routes( @@ -76,10 +87,12 @@ export async function registerApiV1Routes( ix, ); registerSessionsRoutes(apiV1 as unknown as Parameters[0], ix); - registerShutdownRoutes( - apiV1 as unknown as Parameters[0], - ix, - ); + if (opts.enableShutdown !== false) { + registerShutdownRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + } registerSnapshotRoutes(apiV1 as unknown as Parameters[0], ix); registerMessagesRoutes(apiV1 as unknown as Parameters[0], ix); registerPromptsRoutes(apiV1 as unknown as Parameters[0], ix); @@ -94,10 +107,12 @@ export async function registerApiV1Routes( registerToolsRoutes(apiV1 as unknown as Parameters[0], ix); registerSkillsRoutes(apiV1 as unknown as Parameters[0], ix); registerTasksRoutes(apiV1 as unknown as Parameters[0], ix); - registerTerminalsRoutes( - apiV1 as unknown as Parameters[0], - ix, - ); + if (opts.enableTerminals !== false) { + registerTerminalsRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + } registerFsRoutes(apiV1 as unknown as Parameters[0], ix); registerFilesRoutes(apiV1 as unknown as Parameters[0], ix); registerWorkspacesRoutes( diff --git a/packages/server/src/services/auth/authTokenService.ts b/packages/server/src/services/auth/authTokenService.ts new file mode 100644 index 000000000..0eea59984 --- /dev/null +++ b/packages/server/src/services/auth/authTokenService.ts @@ -0,0 +1,56 @@ +/** + * `IAuthTokenService` DI surface (ROADMAP M2.1). + * + * Exposes the persistent bearer token plus a single validity check that accepts + * EITHER the persistent token (constant-time, via `TokenStore`) OR a verified + * user password (bcrypt, async). The seam exists so tests can inject a + * fixed-token impl via `startServer({ serviceOverrides })`, and so `start.ts` + * (M5.1) can wire the real async-built instance at boot. + * + * `isValid` is async because password verification (`bcrypt.compare`) is + * async — the token path is synchronous, but the interface must await both. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; + +import { verifyPassword } from './password'; +import type { TokenStore } from './tokenStore'; + +export interface IAuthTokenService { + readonly _serviceBrand: undefined; + + /** The persistent bearer token (re-read from disk when its mtime changes). */ + getToken(): string; + + /** + * True when `candidate` matches the persistent token OR verifies against the + * configured password hash. Constant-time on the token path; bcrypt on the + * password path. + */ + isValid(candidate: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IAuthTokenService = + createDecorator('authTokenService'); + +/** + * Default `IAuthTokenService` over a `TokenStore` + optional password hash. + * + * Constructed in `start.ts` (M5.1) where the async `TokenStore` / + * `passwordHash` are available, then injected via `serviceOverrides`. NOT built + * inside `createServerServiceCollection`: that path is synchronous and cannot + * await the `TokenStore` file write or the bcrypt hash. + */ +export function createAuthTokenService(deps: { + readonly tokenStore: TokenStore; + readonly passwordHash: string | undefined; +}): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => deps.tokenStore.getToken(), + isValid: async (candidate) => + deps.tokenStore.isValid(candidate) || + (await verifyPassword(candidate, deps.passwordHash)), + }; +} diff --git a/packages/server/src/services/auth/bindClassify.ts b/packages/server/src/services/auth/bindClassify.ts new file mode 100644 index 000000000..9e476884c --- /dev/null +++ b/packages/server/src/services/auth/bindClassify.ts @@ -0,0 +1,112 @@ +/** + * Bind-address classification (ROADMAP M6.1). + * + * `classify(host)` buckets a bind host into the network exposure tier it + * implies, so `start.ts` can decide which hardening to apply: + * + * - `loopback` — only this host (`127.0.0.0/8`, `::1`, `localhost`). The + * token-only default; no public hardening required. + * - `lan` — RFC1918 private ranges (`10/8`, `172.16/12`, `192.168/16`) plus + * link-local (`169.254/16`, `fe80::/10`). Reachable from the local + * network; hardening is recommended but not all of it is mandatory. + * - `public` — everything else. Full D2 hardening (forced password, TLS + * opt-out, auth-failure rate limiting, dangerous-endpoint downgrade, + * security headers). + * + * Wildcard binds (`0.0.0.0`, `::`, empty) are treated as `public` by default — + * a wildcard is reachable from anywhere the host is — unless the caller + * explicitly relaxes the classification via `opts.bindClass: 'lan'` + * (`--bind-class=lan`). + */ + +import net from 'node:net'; + +export type BindClass = 'loopback' | 'lan' | 'public'; + +export interface ClassifyOptions { + /** Override classification of wildcard binds (`0.0.0.0` / `::` / empty). */ + readonly bindClass?: 'lan' | 'public'; +} + +/** Convert a dotted-quad IPv4 literal to its unsigned 32-bit integer form. */ +function ipv4ToInt(ip: string): number { + const [a, b, c, d] = ip.split('.'); + return ( + (((Number(a) << 24) >>> 0) + + ((Number(b) << 16) >>> 0) + + ((Number(c) << 8) >>> 0) + + (Number(d) >>> 0)) >>> + 0 + ); +} + +/** True when `ip` falls inside the IPv4 CIDR `base/prefix`. */ +function ipv4InCidr(ip: string, base: string, prefix: number): boolean { + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0); +} + +/** + * Expand a (possibly `::`-compressed) IPv6 literal into 8 lowercase hextets. + * Returns `null` when the shape is not a plain 8-group IPv6 address. + */ +function expandV6(host: string): readonly string[] | null { + const lower = host.toLowerCase(); + if (lower.includes('::')) { + const halves = lower.split('::'); + const leftRaw = halves[0] ?? ''; + const rightRaw = halves[1] ?? ''; + const left = leftRaw.length > 0 ? leftRaw.split(':') : []; + const right = rightRaw.length > 0 ? rightRaw.split(':') : []; + const missing = 8 - (left.length + right.length); + if (missing < 0) return null; + return [...left, ...Array(missing).fill('0'), ...right]; + } + const parts = lower.split(':'); + return parts.length === 8 ? parts : null; +} + +/** + * True when `host` is an IPv6 link-local address (`fe80::/10`). + * + * The first 10 bits are fixed (`1111111010`), so the leading hextet ranges + * `0xfe80`–`0xfebf`. IPv4-mapped / compressed forms that do not expand to an + * `fe80::/10` leading group return false. + */ +function isLinkLocalV6(host: string): boolean { + const groups = expandV6(host); + if (groups === null) return false; + const first = Number.parseInt(groups[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +/** + * Classify a bind host by the network exposure it implies. + * + * See the module header for the tier definitions. A non-IP hostname that is + * not `localhost` is treated conservatively as `public` — a DNS name could + * resolve to a public address. + */ +export function classify(host: string, opts?: ClassifyOptions): BindClass { + if (host === '' || host === '0.0.0.0' || host === '::') { + return opts?.bindClass ?? 'public'; + } + if (host === 'localhost') { + return 'loopback'; + } + const family = net.isIP(host); + if (family === 4) { + if (host.startsWith('127.')) return 'loopback'; + if (ipv4InCidr(host, '10.0.0.0', 8)) return 'lan'; + if (ipv4InCidr(host, '172.16.0.0', 12)) return 'lan'; + if (ipv4InCidr(host, '192.168.0.0', 16)) return 'lan'; + if (ipv4InCidr(host, '169.254.0.0', 16)) return 'lan'; + return 'public'; + } + if (family === 6) { + if (host.toLowerCase() === '::1') return 'loopback'; + if (isLinkLocalV6(host)) return 'lan'; + return 'public'; + } + return 'public'; +} diff --git a/packages/server/src/services/auth/index.ts b/packages/server/src/services/auth/index.ts new file mode 100644 index 000000000..95ad8d826 --- /dev/null +++ b/packages/server/src/services/auth/index.ts @@ -0,0 +1,6 @@ +export * from './privateFiles'; +export * from './tokenStore'; +export * from './password'; +export * from './authTokenService'; +export * from './bindClassify'; +export * from './securityHeaders'; diff --git a/packages/server/src/services/auth/password.ts b/packages/server/src/services/auth/password.ts new file mode 100644 index 000000000..0a60f8bec --- /dev/null +++ b/packages/server/src/services/auth/password.ts @@ -0,0 +1,23 @@ +import { compare, hash } from 'bcryptjs'; + +const BCRYPT_COST = 12; + +export async function resolvePasswordHash( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const plaintext = env['KIMI_CODE_PASSWORD']; + if (!plaintext) { + return undefined; + } + return hash(plaintext, BCRYPT_COST); +} + +export async function verifyPassword( + candidate: string, + passwordHash: string | undefined, +): Promise { + if (passwordHash === undefined) { + return false; + } + return compare(candidate, passwordHash); +} diff --git a/packages/server/src/services/auth/persistentToken.ts b/packages/server/src/services/auth/persistentToken.ts new file mode 100644 index 000000000..2846dbf74 --- /dev/null +++ b/packages/server/src/services/auth/persistentToken.ts @@ -0,0 +1,79 @@ +/** + * Persistent server bearer token. + * + * The token lives at `/server.token` (mode 0600) and is reused + * across restarts, so a reboot does NOT rotate it. It is generated once on + * first boot and only changes when the operator explicitly runs + * `kimi server rotate-token` (which calls {@link rotateServerToken}). + * + * All writes go through {@link writePrivateFile} (atomic rename, 0700 dir, + * 0600 file) and reads through {@link readPrivateFile} (refuses files looser + * than 0600), so the on-disk token is never world/group-readable and a + * rotation is never observed half-written. + */ + +import { randomBytes } from 'node:crypto'; +import { join } from 'node:path'; + +import { readPrivateFile, writePrivateFile } from './privateFiles'; + +/** On-disk filename for the persistent token, relative to KIMI_CODE_HOME. */ +export const SERVER_TOKEN_FILE = 'server.token'; + +/** Absolute path of the persistent token file for a given home dir. */ +export function serverTokenPath(homeDir: string): string { + return join(homeDir, SERVER_TOKEN_FILE); +} + +/** Fresh 256-bit token, base64url-encoded (43 chars, URL-safe). */ +export function generateServerToken(): string { + return randomBytes(32).toString('base64url'); +} + +/** Atomically write `token` to `/server.token` (0600). */ +export async function writeServerToken(homeDir: string, token: string): Promise { + await writePrivateFile(serverTokenPath(homeDir), token); +} + +/** + * Read the persistent token, or `undefined` when no token file exists yet. + * Throws if the file exists but is too permissive (not 0600). + */ +export async function readServerToken(homeDir: string): Promise { + try { + const buf = await readPrivateFile(serverTokenPath(homeDir)); + return buf.toString('utf8').trim(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw err; + } +} + +/** + * Return the existing persistent token, generating and persisting one on first + * boot. An empty/unreadable file is treated as missing and regenerated. + */ +export async function loadOrCreateServerToken(homeDir: string): Promise { + const existing = await readServerToken(homeDir); + if (existing !== undefined && existing.length > 0) { + return existing; + } + const token = generateServerToken(); + await writeServerToken(homeDir, token); + return token; +} + +/** + * Generate and persist a brand-new token, invalidating the previous one. + * + * A running server picks the new token up on its next auth check (the token + * store re-reads the file when its mtime changes), so rotation takes effect + * immediately without a restart. + */ +export async function rotateServerToken(homeDir: string): Promise { + const token = generateServerToken(); + await writeServerToken(homeDir, token); + return token; +} diff --git a/packages/server/src/services/auth/privateFiles.ts b/packages/server/src/services/auth/privateFiles.ts new file mode 100644 index 000000000..2197ef72f --- /dev/null +++ b/packages/server/src/services/auth/privateFiles.ts @@ -0,0 +1,61 @@ +import { randomBytes } from 'node:crypto'; +import { + chmod, + mkdir, + open, + readFile, + rename, + rm, + stat, +} from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export class PrivateFileTooPermissiveError extends Error { + readonly code = 'EPRIVATE_FILE_TOO_PERMISSIVE'; + + constructor( + readonly filePath: string, + readonly mode: number, + ) { + super( + `private file ${filePath} is too permissive (mode ${mode.toString(8).padStart(3, '0')}); expected 0600`, + ); + this.name = 'PrivateFileTooPermissiveError'; + } +} + +export async function writePrivateFile( + filePath: string, + data: string | Buffer, +): Promise { + const dir = dirname(filePath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await chmod(dir, 0o700); + + const tmp = `${filePath}.tmp.${randomBytes(8).toString('hex')}`; + + let handle: Awaited> | undefined; + try { + handle = await open(tmp, 'w', 0o600); + await handle.chmod(0o600); + await handle.writeFile(data); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(tmp, filePath); + } catch (err) { + if (handle) { + await handle.close().catch(() => {}); + } + await rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +export async function readPrivateFile(filePath: string): Promise { + const info = await stat(filePath); + if ((info.mode & 0o077) !== 0) { + throw new PrivateFileTooPermissiveError(filePath, info.mode & 0o777); + } + return readFile(filePath); +} diff --git a/packages/server/src/services/auth/securityHeaders.ts b/packages/server/src/services/auth/securityHeaders.ts new file mode 100644 index 000000000..21f27767d --- /dev/null +++ b/packages/server/src/services/auth/securityHeaders.ts @@ -0,0 +1,45 @@ +/** + * Security response headers (ROADMAP M6.6). + * + * `createSecurityHeadersHook` builds a Fastify `onSend` hook that stamps a + * small set of defensive headers on every response once the server is exposed + * beyond loopback. Wired from `start.ts` only on non-loopback binds so the + * loopback default keeps its lean response headers. + * + * Headers: + * - `X-Content-Type-Options: nosniff` — stop MIME sniffing. + * - `Referrer-Policy: no-referrer` — never leak the URL to third parties. + * - `Content-Security-Policy: default-src 'self'` — the bundled Web UI is + * same-origin, so `'self'` covers it; tighten later if needed. + * - `Strict-Transport-Security` — ONLY when `opts.tls === true`. In this + * phase TLS is terminated by a reverse proxy (Caddy/nginx), so `start.ts` + * passes `tls: false` and HSTS is omitted here; the proxy is responsible + * for setting HSTS. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +export interface SecurityHeadersOptions { + /** When true, also emit `Strict-Transport-Security`. */ + readonly tls: boolean; +} + +const HSTS_VALUE = 'max-age=31536000'; + +/** + * Build the `onSend` hook. Returns the payload unchanged so Fastify continues + * the response pipeline with the headers applied. + */ +export function createSecurityHeadersHook( + opts: SecurityHeadersOptions, +): (req: FastifyRequest, reply: FastifyReply, payload: unknown) => Promise { + return async (_req, reply, payload) => { + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('Referrer-Policy', 'no-referrer'); + reply.header('Content-Security-Policy', "default-src 'self'"); + if (opts.tls === true) { + reply.header('Strict-Transport-Security', HSTS_VALUE); + } + return payload; + }; +} diff --git a/packages/server/src/services/auth/tokenStore.ts b/packages/server/src/services/auth/tokenStore.ts new file mode 100644 index 000000000..e12d860b0 --- /dev/null +++ b/packages/server/src/services/auth/tokenStore.ts @@ -0,0 +1,80 @@ +import { timingSafeEqual } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; + +import { loadOrCreateServerToken, serverTokenPath } from './persistentToken'; + +export interface TokenStore { + readonly tokenPath: string; + getToken(): string; + isValid(candidate: string): boolean; + dispose(): Promise; +} + +/** + * Persistent token store over `/server.token`. + * + * The token is loaded (or generated) once at boot and reused across restarts. + * `getToken()`/`isValid()` re-read the file whenever its mtime changes, so a + * `kimi server rotate-token` (which rewrites the file) takes effect on a + * running server immediately — no restart, no extra API. The file is small + * (43 bytes) and the common path is a single `statSync` per check. + * + * `dispose()` is intentionally a no-op: the token must survive shutdown. + */ +export async function createTokenStore(homeDir: string): Promise { + const tokenPath = serverTokenPath(homeDir); + const initial = await loadOrCreateServerToken(homeDir); + const initialStat = statSync(tokenPath); + let cache: { token: string; mtimeMs: number; ino: number } = { + token: initial, + mtimeMs: initialStat.mtimeMs, + ino: initialStat.ino, + }; + + const currentToken = (): string => { + let st: ReturnType; + try { + st = statSync(tokenPath); + } catch { + // File temporarily unavailable — keep serving the last known token. + return cache.token; + } + // Detect a rewrite by mtime OR inode. `writePrivateFile` does an atomic + // rename, which always yields a new inode (POSIX) and a fresh mtime + // (Windows/NTFS, where `ino` is always 0). Checking both makes the reload + // robust even on filesystems with coarse (1s) mtime resolution. + if (st.mtimeMs === cache.mtimeMs && st.ino === cache.ino) { + return cache.token; + } + // Changed: re-read, but refuse a too-permissive file and never let an + // empty/partial read clobber the last good token. + if ((st.mode & 0o077) !== 0) { + return cache.token; + } + try { + const token = readFileSync(tokenPath, 'utf8').trim(); + if (token.length > 0) { + cache = { token, mtimeMs: st.mtimeMs, ino: st.ino }; + } + } catch { + // keep last known token + } + return cache.token; + }; + + return { + tokenPath, + getToken: currentToken, + isValid(candidate: string): boolean { + const tokenBuf = Buffer.from(currentToken()); + const candidateBuf = Buffer.from(candidate); + if (candidateBuf.length !== tokenBuf.length) { + return false; + } + return timingSafeEqual(candidateBuf, tokenBuf); + }, + async dispose(): Promise { + // Persistent token: intentionally left on disk so it survives restarts. + }, + }; +} diff --git a/packages/server/src/services/gateway/wsGateway.ts b/packages/server/src/services/gateway/wsGateway.ts index 11d7b4b8f..7308be6fe 100644 --- a/packages/server/src/services/gateway/wsGateway.ts +++ b/packages/server/src/services/gateway/wsGateway.ts @@ -1,9 +1,19 @@ import { createDecorator, type TelemetryClient } from '@moonshot-ai/agent-core'; +import type { HostCheckOptions } from '#/middleware/hostnames'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; import type { AbortHandler, FsWatchHandler, TerminalHandler } from '#/ws/connection'; export const WS_PATH = '/api/v1/ws'; +/** + * `Sec-WebSocket-Protocol` subprotocol prefix used by browser clients to carry + * the bearer token during the WS upgrade handshake (browsers cannot set + * arbitrary headers on a WebSocket, so the token rides in a subprotocol). The + * full offered subprotocol is `${WS_BEARER_PROTOCOL_PREFIX}`. + */ +export const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + export interface IWSGateway { readonly _serviceBrand: undefined; @@ -14,11 +24,47 @@ export interface IWSGateway { setFsWatchHandler(handler: FsWatchHandler): void; setTerminalHandler(handler: TerminalHandler): void; + + /** + * Install the `IAuthTokenService` used to validate bearer tokens on the WS + * upgrade path. Wired by `start.ts` (M5.1) AFTER construction so the + * ix-resolved, override-aware impl (not the constructor options) is what + * enforces auth — letting test overrides via `serviceOverrides` take effect + * for WS too. `WSGatewayOptions.authTokenService?` is retained only for the + * M3/M4 unit tests that construct the gateway directly. + */ + setAuthTokenService(service: IAuthTokenService): void; } // eslint-disable-next-line @typescript-eslint/no-redeclare export const IWSGateway = createDecorator('wsGateway'); +/** + * Extract the bearer token from a `Sec-WebSocket-Protocol` request header. + * + * The header is a comma-separated list of offered subprotocols (e.g. + * `"kimi-code.bearer.abc, other"`). Returns the token portion of the first + * entry whose subprotocol starts with {@link WS_BEARER_PROTOCOL_PREFIX}, or + * `undefined` when the header is missing/empty, no entry matches, or the + * matching entry carries an empty token. + */ +export function extractWsBearerToken(protocolHeader: string | undefined): string | undefined { + if (protocolHeader === undefined || protocolHeader.length === 0) { + return undefined; + } + for (const rawEntry of protocolHeader.split(',')) { + const entry = rawEntry.trim(); + if (entry.startsWith(WS_BEARER_PROTOCOL_PREFIX)) { + const token = entry.slice(WS_BEARER_PROTOCOL_PREFIX.length); + if (token.length === 0) { + return undefined; + } + return token; + } + } + return undefined; +} + export interface WSGatewayOptions { pingIntervalMs?: number; @@ -38,4 +84,28 @@ export interface WSGatewayOptions { * web-path events share one sink + context. */ telemetry?: TelemetryClient; + + /** + * When set, the WS upgrade path requires a valid bearer token (via the + * `Authorization` header or the `kimi-code.bearer.` subprotocol). + * When unset (e.g. tests / pre-M5.1 boots), upgrade auth is skipped. + */ + authTokenService?: IAuthTokenService; + + /** + * Optional Host-header allowlist enforced on the WS upgrade path (ROADMAP + * M4.3). When set, upgrades whose `Host` is not allowed are rejected with + * `403 Forbidden` before token validation. When unset (tests / pre-M5.1 + * boots), the WS Host check is skipped — HTTP-level Host enforcement in + * `start.ts` still covers non-upgrade requests. + */ + hostCheck?: HostCheckOptions; + + /** + * Optional Origin allowlist enforced on the WS upgrade path (ROADMAP M4.3). + * When set, a present browser `Origin` must be same-origin or listed here; + * an absent `Origin` (Node `ws` clients) is allowed. When unset (tests / + * pre-M5.1 boots), the WS Origin check is skipped. + */ + allowedOrigins?: readonly string[]; } diff --git a/packages/server/src/services/gateway/wsGatewayService.ts b/packages/server/src/services/gateway/wsGatewayService.ts index 7c06b98f0..d349081c1 100644 --- a/packages/server/src/services/gateway/wsGatewayService.ts +++ b/packages/server/src/services/gateway/wsGatewayService.ts @@ -4,6 +4,9 @@ import type { Socket } from 'node:net'; import { Disposable, ILogService } from '@moonshot-ai/agent-core'; import { WebSocketServer, type WebSocket } from 'ws'; +import { isAllowedHost } from '#/middleware/hostnames'; +import { isOriginAllowed } from '#/middleware/origin'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; import { WsConnection, type AbortHandler, @@ -15,7 +18,13 @@ import { IConnectionRegistry } from './connectionRegistry'; import { IRestGateway } from './restGateway'; import { ISessionClientsService } from './sessionClients'; import { IWSBroadcastService } from './wsBroadcast'; -import { IWSGateway, type WSGatewayOptions, WS_PATH } from './wsGateway'; +import { + extractWsBearerToken, + IWSGateway, + type WSGatewayOptions, + WS_BEARER_PROTOCOL_PREFIX, + WS_PATH, +} from './wsGateway'; export class WSGateway extends Disposable implements IWSGateway { readonly _serviceBrand: undefined; @@ -26,6 +35,7 @@ export class WSGateway extends Disposable implements IWSGateway { private abortHandler: AbortHandler | undefined; private fsWatchHandler: FsWatchHandler | undefined; private terminalHandler: TerminalHandler | undefined; + private authTokenService: IAuthTokenService | undefined; private detached = false; constructor( @@ -37,9 +47,23 @@ export class WSGateway extends Disposable implements IWSGateway { @ILogService private readonly logger: ILogService, ) { super(); - this.wss = new WebSocketServer({ noServer: true }); + this.authTokenService = options.authTokenService; + this.wss = new WebSocketServer({ + noServer: true, + // Browsers require the server to select one of the offered subprotocols; + // echo back the `kimi-code.bearer.` subprotocol when present so + // token-carrying browser clients complete the handshake. + handleProtocols: (protocols: Set) => { + for (const p of protocols) { + if (p.startsWith(WS_BEARER_PROTOCOL_PREFIX)) return p; + } + return false; + }, + }); this.server = this.restGateway.app.server; - this.upgradeListener = (req, sock, head) => this.onUpgrade(req, sock, head); + this.upgradeListener = (req, sock, head) => { + void this.onUpgrade(req, sock, head); + }; this.server.on('upgrade', this.upgradeListener); this.logger.debug({ path: WS_PATH }, 'ws gateway attached upgrade listener'); } @@ -56,7 +80,11 @@ export class WSGateway extends Disposable implements IWSGateway { this.terminalHandler = handler; } - private onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void { + setAuthTokenService(service: IAuthTokenService): void { + this.authTokenService = service; + } + + private async onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): Promise { const url = req.url ?? ''; const path = url.split('?', 1)[0]; if (path !== WS_PATH) { @@ -68,6 +96,49 @@ export class WSGateway extends Disposable implements IWSGateway { // ~40 ms clusters, making the stream look stuttery. Trade a little bandwidth // for lower latency. socket.setNoDelay(true); + + // Host / Origin checks (ROADMAP M4.3) — enforced BEFORE token validation + // and only when the corresponding option is provided. When unset (tests / + // pre-M5.1 boots) the checks are skipped so existing clients (incl. Node + // `ws`, which sends no `Origin`) keep working. Origin is present-only: a + // missing `Origin` is treated as a non-browser client and allowed. + const hostCheck = this.options.hostCheck; + if (hostCheck !== undefined && !isAllowedHost(req.headers.host, hostCheck)) { + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + const allowedOrigins = this.options.allowedOrigins; + if ( + allowedOrigins !== undefined && + !isOriginAllowed(req.headers.origin, req.headers.host, allowedOrigins) + ) { + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + + const authTokenService = this.authTokenService; + if (authTokenService !== undefined) { + const authorization = req.headers.authorization; + const token = authorization?.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : extractWsBearerToken(req.headers['sec-websocket-protocol']); + // `isValid` is the only await on this path; wrap it so a rejection + // destroys the socket instead of escaping as an unhandled rejection. + let ok = false; + try { + ok = token !== undefined && (await authTokenService.isValid(token)); + } catch { + ok = false; + } + if (!ok) { + socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + } this.wss.handleUpgrade(req, socket, head, (ws) => this.onConnect(ws, req)); } diff --git a/packages/server/src/services/serviceCollection.ts b/packages/server/src/services/serviceCollection.ts index 085721114..203641d3c 100644 --- a/packages/server/src/services/serviceCollection.ts +++ b/packages/server/src/services/serviceCollection.ts @@ -64,6 +64,12 @@ export function createServerServiceCollection( new SyncDescriptor(Services.CoreProcessService, [server.coreProcessOptions ?? {}], false), ); + // `IAuthTokenService` (ROADMAP M2.1) is intentionally NOT registered here: + // its real instance needs an async-built `TokenStore` + `passwordHash` that + // are only available in `start.ts` (M5.1). It is therefore supplied via + // `server.serviceOverrides` (last-wins) — the same seam tests use to inject + // a fixed-token impl. A silent default would be a security hole, so the + // absence is deliberate: an unconfigured server has no auth token service. for (const [id, override] of server.serviceOverrides ?? []) { services.set(id, override); } diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 17df42d4a..9adc10d83 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -10,6 +10,14 @@ import { import { installErrorHandler } from './error-handler'; import { transformOpenApiDocument } from './openapi/transforms'; import { acquireLock, ServerLockedError } from './lock'; +import { createAuthHook } from '#/middleware/auth'; +import { createAuthFailureLimiter } from '#/middleware/rateLimit'; +import { + createHostCheck, + isHostCheckDisabled, + parseAllowedHosts, +} from '#/middleware/hostnames'; +import { createOriginHook, parseCorsOrigins } from '#/middleware/origin'; import { createServerLogger, type ServerLogLevel, @@ -28,6 +36,14 @@ import { } from '#/services/gateway'; import { createServerServiceCollection } from '#/services/serviceCollection'; import { ISnapshotService, loadSnapshotConfig } from '#/services/snapshot'; +import { + createAuthTokenService, + IAuthTokenService, +} from '#/services/auth/authTokenService'; +import { classify } from '#/services/auth/bindClassify'; +import { resolvePasswordHash } from '#/services/auth/password'; +import { createSecurityHeadersHook } from '#/services/auth/securityHeaders'; +import { createTokenStore } from '#/services/auth/tokenStore'; import { getServerVersion } from './version'; import { registerWebAssetRoutes } from './routes/webAssets'; @@ -46,6 +62,36 @@ export interface ServerStartOptions { debugEndpoints?: boolean; + /** + * Override the classification of a wildcard bind (`0.0.0.0` / `::` / empty). + * Default (unset) treats wildcards as `public` (most strict); set to `lan` + * to relax to LAN-tier hardening. See `services/auth/bindClassify.ts`. + */ + bindClass?: 'lan' | 'public'; + + /** + * Allow a non-loopback bind WITHOUT a TLS-terminating reverse proxy. Default + * false: binding beyond loopback refuses to start unless this is set, so a + * public/LAN bind is never served over plain HTTP by accident. Pass + * `--insecure-no-tls` (or set this) only when you accept the risk. + */ + insecureNoTls?: boolean; + + /** + * Allow `POST /api/v1/shutdown` on a non-loopback bind. Default false: the + * shutdown route is NOT registered (404) on a public/LAN bind unless this is + * set. Loopback always mounts it. + */ + allowRemoteShutdown?: boolean; + + /** + * Allow the PTY `/api/v1/terminals/*` routes on a non-loopback bind. Default + * false: terminals routes are NOT registered (404) on a public/LAN bind + * unless this is set (remote shell is the highest-risk surface). Loopback + * always mounts them. + */ + allowRemoteTerminals?: boolean; + webAssetsDir?: string; serviceOverrides?: ReadonlyArray, unknown]>; @@ -88,6 +134,21 @@ export async function startServer(opts: ServerStartOptions): Promise (data) => JSON.stringify(data)); installErrorHandler(app); + // Host / Origin checks (ROADMAP M4.3). Registered before any route so they + // run ahead of every handler and ahead of the (future, M5.1) auth hook. + // Host is evaluated before Origin; both are uniform across bindings (PLAN + // 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:` working. + const hostCheck = createHostCheck({ + boundHost: opts.host, + extra: parseAllowedHosts(process.env), + disable: isHostCheckDisabled(process.env), + }); + const originHook = createOriginHook({ allowedOrigins: parseCorsOrigins(process.env) }); + app.addHook('onRequest', hostCheck.onRequest); + app.addHook('onRequest', originHook); + const serverVersion = opts.coreProcessOptions?.identity?.version ?? getServerVersion(); async function registerOpenApi(): Promise { @@ -138,26 +199,138 @@ export async function startServer(opts: ServerStartOptions): Promise/server.token` + // (0600; generated once on first boot and reused across restarts) and an + // optional bcrypt password hash — both awaited here, then supplied to the + // collection via `serviceOverrides` so tests can inject a fixed-token impl + // that wins (last-wins) over this default. The store re-reads the file when + // its mtime changes, so `kimi server rotate-token` takes effect without a + // restart; the file is intentionally kept on shutdown (dispose is a no-op). + const tokenStore = await createTokenStore(envService.homeDir); + const passwordHash = await resolvePasswordHash(process.env); + const defaultAuth = createAuthTokenService({ tokenStore, passwordHash }); + + // Public-bind hardening gate (ROADMAP M6.3). Classify the bind host and, for + // any non-loopback tier (LAN or public), refuse to start unless the operator + // explicitly acknowledged that TLS is terminated elsewhere (`insecureNoTls`). + // Auth is bearer-token based: the persistent token is printed in the startup + // banner and reused across restarts, so a password is no longer mandatory for + // a non-loopback bind — `KIMI_CODE_PASSWORD` remains an optional additional + // credential. Failing here (before the container is built and before we + // listen) keeps a public/LAN bind from ever serving plain HTTP by accident. + // On refusal we release the lock so the operator can retry cleanly. + const bindClass = classify(opts.host, { bindClass: opts.bindClass }); + if (bindClass !== 'loopback') { + const refusePublicBind = async (message: string): Promise => { + try { + await tokenStore.dispose(); + } catch { + // best-effort cleanup of the token file on boot refusal + } + lockHandle.release(); + throw new Error(message); + }; + if (opts.insecureNoTls !== true) { + await refusePublicBind( + 'Refusing to bind a non-loopback host without TLS. ' + + 'Put the server behind a TLS-terminating reverse proxy (Caddy/nginx), ' + + 'or pass --insecure-no-tls to acknowledge the risk.', + ); + } + if (passwordHash === undefined) { + pinoLogger.warn( + { host: opts.host, bindClass }, + 'binding non-loopback host with token-only auth (no KIMI_CODE_PASSWORD) — the bearer token printed in the startup banner is the only credential protecting this server', + ); + } + pinoLogger.warn( + { host: opts.host, bindClass }, + 'binding non-loopback host without TLS — use a reverse proxy or tunnel in production', + ); + } + const services = createServerServiceCollection({ - server: opts, + server: { + ...opts, + // WS Host/Origin defaults (ROADMAP M4.3 / M5.1): mirror the HTTP checks + // on the upgrade path. Caller-supplied values win (used by the + // host-origin e2e tests). `authTokenService` is NOT threaded here — it + // reaches the WS gateway via `setAuthTokenService` below so the + // override-aware impl enforces auth. + wsGatewayOptions: { + ...opts.wsGatewayOptions, + hostCheck: opts.wsGatewayOptions?.hostCheck ?? { + boundHost: opts.host, + extra: parseAllowedHosts(process.env), + disable: isHostCheckDisabled(process.env), + }, + allowedOrigins: + opts.wsGatewayOptions?.allowedOrigins ?? parseCorsOrigins(process.env), + }, + serviceOverrides: [ + [IAuthTokenService, defaultAuth], + ...(opts.serviceOverrides ?? []), + ], + }, app, pinoLogger, envService, }); const ix = new InstantiationService(services); + // Auth hook (ROADMAP M5.1). Registered after Host/Origin (above) and before + // routes, so a rejected request never reaches a handler. Resolved from the + // container so a test-injected fixed-token impl is what enforces auth. + // + // Auth-failure rate limit (ROADMAP M6.4): only on a non-loopback bind, where + // brute-force attempts are reachable from the network. Loopback keeps the + // original "no limiter" behavior so local retries are never throttled. + const authTokenService = ix.invokeFunction((a) => a.get(IAuthTokenService)); + const authFailureLimiter = + bindClass !== 'loopback' ? createAuthFailureLimiter() : undefined; + app.addHook('onRequest', createAuthHook(authTokenService, { limiter: authFailureLimiter })); + + // Security response headers (ROADMAP M6.6): only on a non-loopback bind. + // TLS is terminated by a reverse proxy in this phase, so HSTS is omitted + // here (`tls: false`) — the proxy is responsible for setting it. + if (bindClass !== 'loopback') { + app.addHook('onSend', createSecurityHeadersHook({ tls: false })); + } + + // Bind classification (`bindClass`, computed above next to the password/TLS + // gate) drives every hardening decision from here on: debug routes now; + // rate limit, dangerous endpoints, and security headers in M6.4–M6.6. + + // Debug routes (ROADMAP M5.3): only mount `/api/v1/debug/*` when bound to a + // loopback interface. On a non-loopback bind these introspection/mutation + // endpoints would be reachable from the network, so suppress them even if + // the caller asked for them, and warn so the operator knows. + if (opts.debugEndpoints === true && bindClass !== 'loopback') { + pinoLogger.warn( + { host: opts.host, bindClass }, + 'debug endpoints suppressed: refusing to mount /api/v1/debug/* on a non-loopback bind', + ); + } + + // Dangerous-endpoint downgrade (ROADMAP M6.5): on a non-loopback bind the + // shutdown + terminals routes are NOT registered (404) unless the operator + // explicitly opts in. Loopback always mounts them (backward compatible). + const allowRemoteShutdown = opts.allowRemoteShutdown === true; + const allowRemoteTerminals = opts.allowRemoteTerminals === true; await registerApiV1Routes(app, ix, { serverVersion, - debugEndpoints: opts.debugEndpoints, + debugEndpoints: opts.debugEndpoints === true && bindClass === 'loopback', + enableShutdown: bindClass === 'loopback' || allowRemoteShutdown, + enableTerminals: bindClass === 'loopback' || allowRemoteTerminals, }); - app.get('/asyncapi.json', async (req, reply) => { - const host = typeof req.headers.host === 'string' ? req.headers.host : undefined; + app.get('/asyncapi.json', async (_req, reply) => { + // Reflect the bound host, never the caller-supplied `Host` header (PLAN + // §3.6-3: Host-header reflection is an information-leak / SSRF-adjacent + // hole once the server is reachable beyond localhost). return reply.type('application/json').send( - createAsyncApiDocument({ - version: serverVersion, - serverHost: host, - }), + createAsyncApiDocument({ version: serverVersion, serverHost: opts.host }), ); }); app.get('/openapi.json', async (_req, reply) => { @@ -172,6 +345,11 @@ export async function startServer(opts: ServerStartOptions): Promise matches the documented v1 route table and meta endpoints 1`] = ` +{ + "meta": [ + [ + "GET", + "/", + 404, + ], + [ + "GET", + "/asyncapi.json", + 200, + ], + [ + "GET", + "/healthz", + 404, + ], + [ + "GET", + "/openapi.json", + 200, + ], + ], + "routes": [ + [ + "DELETE", + "/api/v1/files/{file_id}", + ], + [ + "DELETE", + "/api/v1/oauth/login", + ], + [ + "DELETE", + "/api/v1/workspaces/{workspace_id}", + ], + [ + "GET", + "/api/v1/auth", + ], + [ + "GET", + "/api/v1/config", + ], + [ + "GET", + "/api/v1/connections", + ], + [ + "GET", + "/api/v1/files/{file_id}", + ], + [ + "GET", + "/api/v1/fs:browse", + ], + [ + "GET", + "/api/v1/fs:home", + ], + [ + "GET", + "/api/v1/healthz", + ], + [ + "GET", + "/api/v1/mcp/servers", + ], + [ + "GET", + "/api/v1/meta", + ], + [ + "GET", + "/api/v1/models", + ], + [ + "GET", + "/api/v1/oauth/login", + ], + [ + "GET", + "/api/v1/providers", + ], + [ + "GET", + "/api/v1/providers/{provider_id}", + ], + [ + "GET", + "/api/v1/sessions", + ], + [ + "GET", + "/api/v1/sessions/{session_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/approvals", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/children", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/fs/{*}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/messages", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/messages/{message_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/profile", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/prompts", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/questions", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/skills", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/snapshot", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/status", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/tasks", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/tasks/{task_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/terminals", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/terminals/{terminal_id}", + ], + [ + "GET", + "/api/v1/sessions/{session_id}/warnings", + ], + [ + "GET", + "/api/v1/tools", + ], + [ + "GET", + "/api/v1/workspaces", + ], + [ + "GET", + "/asyncapi.json", + ], + [ + "GET", + "/openapi.json", + ], + [ + "PATCH", + "/api/v1/workspaces/{workspace_id}", + ], + [ + "POST", + "/api/v1/config", + ], + [ + "POST", + "/api/v1/files", + ], + [ + "POST", + "/api/v1/mcp/servers/{tail}", + ], + [ + "POST", + "/api/v1/models/{tail}", + ], + [ + "POST", + "/api/v1/oauth/login", + ], + [ + "POST", + "/api/v1/oauth/logout", + ], + [ + "POST", + "/api/v1/providers{refresh_oauth}", + ], + [ + "POST", + "/api/v1/sessions", + ], + [ + "POST", + "/api/v1/sessions/{session_id}:compact", + ], + [ + "POST", + "/api/v1/sessions/{session_id}:fork", + ], + [ + "POST", + "/api/v1/sessions/{session_id}:undo", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/approvals/{approval_id}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/children", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/profile", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts:steer", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/prompts/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/questions/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/skills/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/tasks/{tail}", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/terminals", + ], + [ + "POST", + "/api/v1/sessions/{session_id}/terminals/{tail}", + ], + [ + "POST", + "/api/v1/shutdown", + ], + [ + "POST", + "/api/v1/workspaces", + ], + ], +} +`; diff --git a/packages/server/test/api-surface.snapshot.test.ts b/packages/server/test/api-surface.snapshot.test.ts new file mode 100644 index 000000000..ecf971da6 --- /dev/null +++ b/packages/server/test/api-surface.snapshot.test.ts @@ -0,0 +1,113 @@ +/** + * API surface snapshot guardrail (ROADMAP M0.1). + * + * Boots `startServer` on port 0 with a tmpdir lock + isolated home dir, then + * records a stable, sorted snapshot of the documented API surface: + * + * - `routes`: every `[METHOD, path]` pair derived from `/openapi.json` + * `paths` (the documented v1 REST surface). This is the guardrail's target: + * later phases that add / remove / hide routes show an intentional diff. + * - `meta`: the `(method, url, status)` of doc/meta endpoints that sit + * outside `paths` (`/healthz`, `/openapi.json`, `/asyncapi.json`, `/`). + * Status codes prove reachability. + * + * `startServer` does not expose the Fastify `app`, so the surface is read + * through the public `/openapi.json` endpoint rather than by inspecting the + * route table directly — keeping M0 a no-behavior-change phase. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { startServer, type RunningServer } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; + +/** OpenAPI path-item keys that are HTTP methods (skip `parameters`, etc.). */ +const HTTP_METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]); + +/** Meta endpoints outside the OpenAPI `paths` map to probe for reachability. */ +const META_ENDPOINTS = ['/healthz', '/openapi.json', '/asyncapi.json', '/']; + +describe('API surface snapshot', () => { + let tmpDir: string | undefined; + let bridgeHome: string | undefined; + let server: RunningServer | undefined; + + afterEach(async () => { + if (server !== undefined) { + try { + await server.close(); + } catch { + // ignore — best-effort teardown + } + server = undefined; + } + if (tmpDir !== undefined) { + rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + if (bridgeHome !== undefined) { + rmSync(bridgeHome, { recursive: true, force: true }); + bridgeHome = undefined; + } + }); + + it('matches the documented v1 route table and meta endpoints', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-api-surface-')); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-api-surface-home-')); + const lockPath = join(tmpDir, 'lock'); + + server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + }); + + const address = server.address; + + // 1) Documented v1 REST surface, derived from /openapi.json `paths`. + const openApiRes = await fetch(`${address}/openapi.json`, { headers: authHeaders() }); + expect(openApiRes.status).toBe(200); + const openApi = (await openApiRes.json()) as { + paths?: Record>; + }; + const paths = openApi.paths ?? {}; + expect(Object.keys(paths).length).toBeGreaterThan(0); + + const routes: Array<[string, string]> = []; + for (const [path, item] of Object.entries(paths)) { + for (const key of Object.keys(item)) { + if (HTTP_METHODS.has(key.toLowerCase())) { + routes.push([key.toUpperCase(), path]); + } + } + } + routes.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])); + + // 2) Doc/meta endpoints that are not part of the OpenAPI `paths` map. + const meta: Array<[string, string, number]> = []; + for (const endpoint of META_ENDPOINTS) { + const res = await fetch(`${address}${endpoint}`, { headers: authHeaders() }); + meta.push(['GET', endpoint, res.status]); + } + meta.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]) || a[2] - b[2]); + + expect({ routes, meta }).toMatchSnapshot(); + }); +}); diff --git a/packages/server/test/approval.e2e.test.ts b/packages/server/test/approval.e2e.test.ts index cf9bb8f06..011075eae 100644 --- a/packages/server/test/approval.e2e.test.ts +++ b/packages/server/test/approval.e2e.test.ts @@ -34,6 +34,7 @@ import { } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; import { ApprovalService, @@ -63,6 +64,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -76,12 +78,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -123,7 +137,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(rawDataToString(data)) as Record); diff --git a/packages/server/test/auth-middleware.e2e.test.ts b/packages/server/test/auth-middleware.e2e.test.ts new file mode 100644 index 000000000..15f33dfb0 --- /dev/null +++ b/packages/server/test/auth-middleware.e2e.test.ts @@ -0,0 +1,188 @@ +import { request as httpRequest } from 'node:http'; + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createAuthHook } from '#/middleware/auth'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; + +import { boot, closeAll } from './helpers/serverHarness'; + +const TOKEN = 'test-token'; + +function fixedImpl(): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => TOKEN, + isValid: async (candidate) => candidate === TOKEN, + }; +} + +describe('createAuthHook (onRequest middleware)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createAuthHook(fixedImpl())); + + app.get('/api/v1/healthz', async () => ({ ok: true })); + app.get('/api/v1/sessions', async () => ({ ok: true })); + app.options('/api/v1/sessions', async (_req, reply) => + reply.code(204).send(), + ); + app.get('/', async () => ({ ok: true })); + app.get('/openapi.json', async () => ({ openapi: '3.1.0' })); + // Probe surfaces the post-hook header view so we can assert redaction. + app.get('/api/v1/probe', async (req) => ({ + authorization: req.headers.authorization ?? null, + })); + + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('bypasses GET /api/v1/healthz with no token', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/healthz' }); + expect(res.statusCode).toBe(200); + }); + + it('rejects GET /api/v1/sessions with no token (40101 envelope)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/sessions' }); + expect(res.statusCode).toBe(401); + const body = res.json() as Record; + expect(body['code']).toBe(40101); + expect(body['msg']).toBe('Unauthorized'); + expect(body['data']).toBeNull(); + expect(typeof body['request_id']).toBe('string'); + }); + + it('rejects GET /api/v1/sessions with a wrong token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { authorization: 'Bearer wrong-token' }, + }); + expect(res.statusCode).toBe(401); + expect((res.json() as Record)['code']).toBe(40101); + }); + + it('accepts GET /api/v1/sessions with the correct bearer token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.statusCode).toBe(200); + }); + + it('rejects a malformed Authorization header', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { authorization: TOKEN }, + }); + expect(res.statusCode).toBe(401); + }); + + it('bypasses OPTIONS /api/v1/sessions with no token', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/sessions', + }); + expect(res.statusCode).toBe(204); + }); + + it('bypasses GET / (static asset) with no token', async () => { + const res = await app.inject({ method: 'GET', url: '/' }); + expect(res.statusCode).toBe(200); + }); + + it('does NOT bypass GET /openapi.json (meta doc stays gated)', async () => { + const res = await app.inject({ method: 'GET', url: '/openapi.json' }); + expect(res.statusCode).toBe(401); + expect((res.json() as Record)['code']).toBe(40101); + }); + + it('redacts the Authorization header view before the handler runs', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { authorization: string | null }; + expect(body.authorization).toBe('[redacted]'); + }); +}); + +/** + * Raw HTTP GET that lets us spoof the `Host` header. Node's `http.request` + * honors an explicit `headers.Host`, whereas `fetch` (undici) treats `Host` as + * a forbidden header and drops it. + */ +function rawGet( + port: number, + path: string, + headers: Record, +): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + const req = httpRequest( + { host: '127.0.0.1', port, path, method: 'GET', headers }, + (res) => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve({ + status: res.statusCode ?? 0, + body: JSON.parse(data) as unknown, + }); + } catch (err) { + reject(err); + } + }); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +describe('/asyncapi.json serverHost (M2.3 Host-reflection fix)', () => { + afterEach(async () => { + await closeAll(); + }); + + it('rejects a spoofed Host header (M4.3) and otherwise reflects the bound host', async () => { + const harness = await boot({ host: '127.0.0.1', port: 0 }); + const port = Number(new URL(harness.address).port); + + // M4.3: a spoofed / disallowed Host is rejected by the global Host check + // before it ever reaches the route — a stronger guarantee than the M2.3 + // "reflect bound host" fix, since the spoofed value can no longer leak. + const spoofed = await rawGet(port, '/asyncapi.json', { + Host: 'evil.com', + }); + expect(spoofed.status).toBe(403); + + // With an allowed Host, the route responds and still reflects the BOUND + // host, never the caller-supplied Host header (the original M2.3 fix). + // /asyncapi.json is gated by the M5.1 auth hook, so carry the fixed token + // the harness injected via `boot()`. + const { status, body } = await rawGet(port, '/asyncapi.json', { + Host: '127.0.0.1', + Authorization: 'Bearer test-token', + }); + expect(status).toBe(200); + + const servers = (body as { servers: { local: { host: string } } }).servers; + expect(servers.local.host).toBe('127.0.0.1'); + expect(servers.local.host).not.toContain('evil.com'); + }); +}); diff --git a/packages/server/test/auth-wiring.e2e.test.ts b/packages/server/test/auth-wiring.e2e.test.ts new file mode 100644 index 000000000..249fd58c4 --- /dev/null +++ b/packages/server/test/auth-wiring.e2e.test.ts @@ -0,0 +1,208 @@ +/** + * Production auth wiring end-to-end (ROADMAP M5.1). + * + * Unlike the override-driven tests (which inject a fixed-token + * `IAuthTokenService`), this file boots `startServer` with NO auth override so + * the REAL `defaultAuth` is built: a persistent token written to + * `/server.token` (0600) plus the HTTP/WS auth hooks. The token + * is read back from disk — exactly what the CLI does (M5.4) — and exercised + * against a gated HTTP route and the WS upgrade path. This proves the + * production wiring, not just the override seam. + */ + +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { startServer, type RunningServer } from '../src'; +import { rawDataToString } from '../src/ws/rawData'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningServer[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-auth-wiring-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-auth-wiring-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +function tokenPath(): string { + return join(bridgeHome, 'server.token'); +} + +function readToken(): string { + return readFileSync(tokenPath(), 'utf8').trim(); +} + +async function bootReal(): Promise { + const r = await startServer({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; +} + +interface WsFrame { + type: string; + [k: string]: unknown; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; + closed: Promise<{ code: number; reason: string }>; +} + +function openConn(url: string, protocols?: string[]): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, protocols); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + let closedResolve: (v: { code: number; reason: string }) => void; + const closed = new Promise<{ code: number; reason: string }>((res) => { + closedResolve = res; + }); + // Attach the message listener BEFORE 'open' can fire so the immediate + // `server_hello` frame is never dropped. + ws.on('message', (data: RawData) => { + try { + const frame = JSON.parse(rawDataToString(data)) as WsFrame; + if (waiters.length > 0) waiters.shift()?.(frame); + else queue.push(frame); + } catch { + // ignore non-JSON frames + } + }); + ws.on('close', (code, reason) => closedResolve({ code, reason: String(reason) })); + ws.once('open', () => resolve({ ws, queue, waiters, closed })); + ws.once('error', reject); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message within ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +function expectRejected(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + // ignore + } + if (err === undefined) { + resolve(); + } else { + reject(err); + } + }; + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('production auth wiring (M5.1)', () => { + it('writes a 0600 token file at boot and keeps it on close (persistent)', async () => { + const r = await bootReal(); + const p = tokenPath(); + + const info = statSync(p); + expect(info.mode & 0o777).toBe(0o600); + + const token = readToken(); + expect(token.length).toBeGreaterThan(0); + + await r.close(); + // Persistent token: the file survives shutdown so the next start reuses it. + expect(statSync(p).mode & 0o777).toBe(0o600); + }); + + it('gates HTTP: 200 with the token, 401 without', async () => { + const r = await bootReal(); + const token = readToken(); + + const ok = await fetch(`${r.address}/openapi.json`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(ok.status).toBe(200); + + const bad = await fetch(`${r.address}/openapi.json`); + expect(bad.status).toBe(401); + const body = (await bad.json()) as { code: number }; + expect(body.code).toBe(40101); + }); + + it('gates WS: server_hello with the token, rejected without', async () => { + const r = await bootReal(); + const token = readToken(); + + const conn = await openConn(wsUrl(r.address), [`kimi-code.bearer.${token}`]); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + conn.ws.close(); + await conn.closed; + + await expectRejected(wsUrl(r.address)); + }); +}); diff --git a/packages/server/test/auth.e2e.test.ts b/packages/server/test/auth.e2e.test.ts index 93d5a1d41..8c99f6915 100644 --- a/packages/server/test/auth.e2e.test.ts +++ b/packages/server/test/auth.e2e.test.ts @@ -32,6 +32,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { authSummarySchema, type AuthSummary } from '@moonshot-ai/protocol'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -57,6 +58,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -69,12 +71,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/authTokenService.test.ts b/packages/server/test/authTokenService.test.ts new file mode 100644 index 000000000..e7a7ca1ff --- /dev/null +++ b/packages/server/test/authTokenService.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import type { Server as HttpServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + InstantiationService, + type IEnvironmentService, +} from '@moonshot-ai/agent-core'; +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createAuthTokenService, + IAuthTokenService, +} from '#/services/auth/authTokenService'; +import { resolvePasswordHash } from '#/services/auth/password'; +import { createTokenStore, type TokenStore } from '#/services/auth/tokenStore'; +import type { FastifyLike } from '#/services/gateway/restGateway'; +import { createServerServiceCollection } from '#/services/serviceCollection'; + +function tmpEnv(): { dir: string; env: IEnvironmentService } { + const dir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-test-')); + return { + dir, + env: { + _serviceBrand: undefined, + homeDir: dir, + configPath: join(dir, 'config.toml'), + }, + }; +} + +/** Minimal Fastify shape — `FastifyRestGateway` only stores it at construction. */ +function appStub(): FastifyLike { + return { + server: {} as unknown as HttpServer, + listen: async () => 'http://127.0.0.1:0', + close: async () => {}, + }; +} + +describe('createAuthTokenService', () => { + let homeDir: string; + let store: TokenStore; + + beforeEach(async () => { + homeDir = mkdtempSync(join(tmpdir(), 'kimi-auth-token-store-')); + store = await createTokenStore(homeDir); + }); + + afterEach(async () => { + await store.dispose(); + rmSync(homeDir, { recursive: true, force: true }); + }); + + it('getToken() returns the tokenStore token', () => { + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(svc.getToken()).toBe(store.getToken()); + }); + + it('isValid accepts the token', async () => { + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(await svc.isValid(store.getToken())).toBe(true); + }); + + it('isValid accepts the password when a hash is configured', async () => { + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct horse battery staple', + }); + const svc = createAuthTokenService({ tokenStore: store, passwordHash }); + expect(await svc.isValid('correct horse battery staple')).toBe(true); + }); + + it('isValid rejects a wrong candidate', async () => { + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct horse battery staple', + }); + const svc = createAuthTokenService({ tokenStore: store, passwordHash }); + expect(await svc.isValid('wrong')).toBe(false); + }); + + it('isValid accepts only the token when passwordHash is undefined', async () => { + const svc = createAuthTokenService({ tokenStore: store, passwordHash: undefined }); + expect(await svc.isValid(store.getToken())).toBe(true); + expect(await svc.isValid('any-password')).toBe(false); + }); +}); + +describe('IAuthTokenService via serviceCollection override', () => { + let env: IEnvironmentService; + let dir: string; + let ix: InstantiationService; + + const fixed: IAuthTokenService = { + _serviceBrand: undefined, + getToken: () => 'fixed-token', + isValid: async (candidate) => candidate === 'fixed-token', + }; + + beforeEach(() => { + ({ env, dir } = tmpEnv()); + const collection = createServerServiceCollection({ + server: { + host: '127.0.0.1', + port: 0, + serviceOverrides: [[IAuthTokenService, fixed] as const], + }, + app: appStub(), + pinoLogger: pino({ level: 'silent' }), + envService: env, + }); + ix = new InstantiationService(collection); + }); + + afterEach(() => { + ix.dispose(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('retrieves the injected impl and validates its own token', async () => { + const resolved = ix.invokeFunction((a) => a.get(IAuthTokenService)); + expect(resolved).toBe(fixed); + expect(await resolved.isValid(resolved.getToken())).toBe(true); + expect(await resolved.isValid('nope')).toBe(false); + }); +}); diff --git a/packages/server/test/bindClassify.test.ts b/packages/server/test/bindClassify.test.ts new file mode 100644 index 000000000..a9e91544c --- /dev/null +++ b/packages/server/test/bindClassify.test.ts @@ -0,0 +1,85 @@ +/** + * `classify(host)` tier classification (ROADMAP M6.1). + * + * Pins the loopback / lan / public boundaries that gate every M6 hardening + * decision, including the wildcard defaults and the RFC1918 / link-local + * edges (e.g. `172.31.255.255` inside `172.16/12`, `172.32.0.1` just outside). + */ + +import { describe, expect, it } from 'vitest'; + +import { classify } from '../src/services/auth/bindClassify'; + +describe('classify', () => { + describe('loopback', () => { + it.each([ + ['127.0.0.1'], + ['127.255.255.255'], + ['::1'], + ['localhost'], + ])('%s → loopback', (host) => { + expect(classify(host)).toBe('loopback'); + }); + }); + + describe('lan', () => { + it.each([ + ['192.168.1.5'], + ['10.0.0.1'], + ['172.16.0.1'], + ['172.31.255.255'], + ['169.254.1.1'], + ['fe80::1'], + ['fe80:0000:0000:0000:0000:0000:0000:0001'], + ['febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff'], + ])('%s → lan', (host) => { + expect(classify(host)).toBe('lan'); + }); + }); + + describe('public', () => { + it.each([ + ['8.8.8.8'], + ['172.32.0.1'], + ['203.0.113.5'], + ['2001:4860:4860::8888'], + ['fec0::1'], + ['example.com'], + ])('%s → public', (host) => { + expect(classify(host)).toBe('public'); + }); + }); + + describe('wildcard binds default to public unless relaxed', () => { + it('0.0.0.0 → public by default', () => { + expect(classify('0.0.0.0')).toBe('public'); + }); + + it('0.0.0.0 → lan when bindClass=lan', () => { + expect(classify('0.0.0.0', { bindClass: 'lan' })).toBe('lan'); + }); + + it('0.0.0.0 → public when bindClass=public (explicit)', () => { + expect(classify('0.0.0.0', { bindClass: 'public' })).toBe('public'); + }); + + it(':: → public by default', () => { + expect(classify('::')).toBe('public'); + }); + + it(':: → lan when bindClass=lan', () => { + expect(classify('::', { bindClass: 'lan' })).toBe('lan'); + }); + + it('empty string → public by default', () => { + expect(classify('')).toBe('public'); + }); + }); + + it('bindClass override does not reclassify a concrete loopback/lan host', () => { + // The override applies only to wildcard binds; a real loopback stays + // loopback even if a caller passes bindClass=public by mistake. + expect(classify('127.0.0.1', { bindClass: 'public' })).toBe('loopback'); + expect(classify('192.168.1.5', { bindClass: 'public' })).toBe('lan'); + }); +}); diff --git a/packages/server/test/config.e2e.test.ts b/packages/server/test/config.e2e.test.ts index 6a8c24e01..dab1713ab 100644 --- a/packages/server/test/config.e2e.test.ts +++ b/packages/server/test/config.e2e.test.ts @@ -6,6 +6,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -31,6 +32,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -43,12 +45,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/connections.e2e.test.ts b/packages/server/test/connections.e2e.test.ts index 7856a5638..d5c7c3644 100644 --- a/packages/server/test/connections.e2e.test.ts +++ b/packages/server/test/connections.e2e.test.ts @@ -29,6 +29,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; let tmpDir: string; @@ -56,6 +57,7 @@ afterEach(async () => { async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,12 +73,24 @@ async function spawn(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function wsUrl(http: string): string { @@ -100,7 +114,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; let closedResolve: (v: { code: number; reason: string }) => void; diff --git a/packages/server/test/debug-nonloopback.e2e.test.ts b/packages/server/test/debug-nonloopback.e2e.test.ts new file mode 100644 index 000000000..418ffd2d7 --- /dev/null +++ b/packages/server/test/debug-nonloopback.e2e.test.ts @@ -0,0 +1,93 @@ +/** + * Debug-route loopback gating (ROADMAP M5.3). + * + * `/api/v1/debug/*` routes are test-only introspection/mutation endpoints. + * They must only be mounted when the server is bound to a loopback interface; + * on a non-loopback bind (e.g. `0.0.0.0`) they are suppressed even when the + * caller passes `debugEndpoints: true`. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { startServer, type RunningServer } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let prevPassword: string | undefined; +const running: RunningServer[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-debug-loopback-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-debug-loopback-home-')); + // M6.3: a non-loopback bind (0.0.0.0) now refuses to start without a + // password + TLS opt-out. Set a password so the 0.0.0.0 case can boot; the + // fixed-token override still governs auth (password ≠ token). + prevPassword = process.env['KIMI_CODE_PASSWORD']; + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + if (prevPassword === undefined) { + delete process.env['KIMI_CODE_PASSWORD']; + } else { + process.env['KIMI_CODE_PASSWORD'] = prevPassword; + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function boot(host: string): Promise { + const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host, + port: 0, + lockPath: host === '0.0.0.0' ? join(tmpDir, `lock-${host}`) : lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + debugEndpoints: true, + // M6.3: acknowledge the lack of a TLS proxy so the 0.0.0.0 bind is allowed + // (loopback ignores this — the gate only fires for non-loopback). + insecureNoTls: true, + }); + running.push(r); + return r; +} + +/** Probe a debug route on `127.0.0.1:` regardless of the bound host. */ +async function probeDebug(r: RunningServer): Promise { + const port = new URL(r.address).port; + const res = await fetch(`http://127.0.0.1:${port}/api/v1/debug/prompts/some-session/state`, { + headers: authHeaders(), + }); + return res.status; +} + +describe('debug endpoints are loopback-only (M5.3)', () => { + it('does NOT mount /api/v1/debug/* on a non-loopback bind (0.0.0.0)', async () => { + const r = await boot('0.0.0.0'); + // Route suppressed → Fastify 404 (the auth hook would 401 if the route + // existed without a token, but with a token a missing route is 404). + expect(await probeDebug(r)).toBe(404); + }); + + it('mounts /api/v1/debug/* on a loopback bind (127.0.0.1)', async () => { + const r = await boot('127.0.0.1'); + // Route mounted + valid token → 200 (data is null for an unknown session). + expect(await probeDebug(r)).toBe(200); + }); +}); diff --git a/packages/server/test/files.e2e.test.ts b/packages/server/test/files.e2e.test.ts index 7291e60c8..2f6f51fa4 100644 --- a/packages/server/test/files.e2e.test.ts +++ b/packages/server/test/files.e2e.test.ts @@ -28,6 +28,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -53,6 +54,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -76,10 +78,22 @@ interface FastifyAppLike { } function appOf(r: RunningServer): FastifyAppLike { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as FastifyAppLike; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } interface Envelope { diff --git a/packages/server/test/fs-basic.e2e.test.ts b/packages/server/test/fs-basic.e2e.test.ts index 70323a2c0..5ae590821 100644 --- a/packages/server/test/fs-basic.e2e.test.ts +++ b/packages/server/test/fs-basic.e2e.test.ts @@ -28,6 +28,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -56,6 +57,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,15 +73,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-batch.e2e.test.ts b/packages/server/test/fs-batch.e2e.test.ts index 588a59bc8..ab081be68 100644 --- a/packages/server/test/fs-batch.e2e.test.ts +++ b/packages/server/test/fs-batch.e2e.test.ts @@ -28,6 +28,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -56,6 +57,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,15 +73,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-browse.e2e.test.ts b/packages/server/test/fs-browse.e2e.test.ts index 471cc40b8..d217fecd1 100644 --- a/packages/server/test/fs-browse.e2e.test.ts +++ b/packages/server/test/fs-browse.e2e.test.ts @@ -18,6 +18,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import type { FsBrowseEntry, FsBrowseResponse, @@ -55,6 +56,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -67,12 +69,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-download.e2e.test.ts b/packages/server/test/fs-download.e2e.test.ts index 444afcce6..88d658d56 100644 --- a/packages/server/test/fs-download.e2e.test.ts +++ b/packages/server/test/fs-download.e2e.test.ts @@ -30,6 +30,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -58,6 +59,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -79,12 +81,24 @@ interface InjectResponse { function appOf(r: RunningServer): { inject: (req: unknown) => Promise; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise; - }; + inject: (req: unknown) => Promise; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-git.e2e.test.ts b/packages/server/test/fs-git.e2e.test.ts index 70b98b30c..9f63efdde 100644 --- a/packages/server/test/fs-git.e2e.test.ts +++ b/packages/server/test/fs-git.e2e.test.ts @@ -14,6 +14,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { parsePorcelain, parseNumstat } from '@moonshot-ai/agent-core'; let tmpDir: string; @@ -43,6 +44,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -58,15 +60,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-mkdir.e2e.test.ts b/packages/server/test/fs-mkdir.e2e.test.ts index 8be7a6297..6624bcc4d 100644 --- a/packages/server/test/fs-mkdir.e2e.test.ts +++ b/packages/server/test/fs-mkdir.e2e.test.ts @@ -19,6 +19,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -47,6 +48,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -62,15 +64,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-search.e2e.test.ts b/packages/server/test/fs-search.e2e.test.ts index 12471b6db..4a887dfb2 100644 --- a/packages/server/test/fs-search.e2e.test.ts +++ b/packages/server/test/fs-search.e2e.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ISessionService, FsSearchService, ILogService } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -43,6 +44,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -58,15 +60,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/fs-watch.e2e.test.ts b/packages/server/test/fs-watch.e2e.test.ts index 6535443be..ab34d5cbf 100644 --- a/packages/server/test/fs-watch.e2e.test.ts +++ b/packages/server/test/fs-watch.e2e.test.ts @@ -43,6 +43,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; let tmpDir: string; @@ -74,6 +75,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -90,15 +92,27 @@ function appOf(r: RunningServer): { json: () => unknown; }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ - statusCode: number; - json: () => unknown; - }>; - }; + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } async function createSession(r: RunningServer): Promise { @@ -136,7 +150,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; ws.on('message', (data) => { diff --git a/packages/server/test/helpers/serverHarness.ts b/packages/server/test/helpers/serverHarness.ts new file mode 100644 index 000000000..ddc6cbe03 --- /dev/null +++ b/packages/server/test/helpers/serverHarness.ts @@ -0,0 +1,229 @@ +/** + * Reusable e2e server harness with token support (ROADMAP M0.2 / M5.1). + * + * Wraps `startServer` with an isolated tmp lock + home dir and exposes helpers + * (`authedFetch` / `authedWs`) that carry an `Authorization: Bearer `. + * + * From M5.1 the server enforces bearer auth, so `boot()` injects a fixed-token + * `IAuthTokenService` (default token `test-token`) via `serviceOverrides` by + * default — keeping harness-based tests transparent. A caller-supplied + * `IAuthTokenService` override still wins (serviceOverrides are last-wins), so + * tests that need a custom impl can pass one explicitly. For tests that boot + * `startServer` directly, use the exported {@link fixedTokenAuth} / + * {@link withAuth} / {@link authHeaders} helpers to inject the same fixed token + * and carry it on each request. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ServiceIdentifier } from '@moonshot-ai/agent-core'; +import { pino } from 'pino'; +import { WebSocket } from 'ws'; + +import { startServer, type RunningServer, type ServerStartOptions } from '../../src'; +import { IAuthTokenService } from '../../src/services/auth/authTokenService'; +import type { IAuthTokenService as IAuthTokenServiceType } from '../../src/services/auth/authTokenService'; + +type ServiceOverride = readonly [ServiceIdentifier, unknown]; + +/** Default deterministic token used when a test does not supply one. */ +const DEFAULT_TOKEN = 'test-token'; + +/** + * Build a `[IAuthTokenService, impl]` override pair that accepts a single fixed + * `token`. Pass it into `startServer({ serviceOverrides: [fixedTokenAuth()] })` + * (or `boot({ serviceOverrides: [fixedTokenAuth()] })`) so a test can + * authenticate with `Authorization: Bearer test-token` — or any custom `token`. + * + * `getToken` returns the fixed token so callers that read it back (e.g. WS + * subprotocol helpers) stay consistent; `isValid` accepts only that token. + */ +export function fixedTokenAuth(token: string = DEFAULT_TOKEN): ServiceOverride { + const impl: IAuthTokenServiceType = { + _serviceBrand: undefined, + getToken: () => token, + isValid: async (candidate) => candidate === token, + }; + return [IAuthTokenService, impl]; +} + +/** An `Authorization: Bearer ` header bag, for spreading into `headers`. */ +export function authHeaders(token: string = DEFAULT_TOKEN): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} + +/** + * Merge `Authorization: Bearer ` into a `fetch` `RequestInit`, preserving + * caller-supplied headers. A caller-supplied `Authorization` wins, so tests that + * need to send a wrong/no token can still do so explicitly. + */ +export function withAuth(init: RequestInit = {}, token: string = DEFAULT_TOKEN): RequestInit { + const headers = new Headers(init.headers); + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + return { ...init, headers }; +} + +/** + * WS subprotocol prefix that carries the bearer token during the upgrade. + * Hardcoded here as a literal for M0; `WS_BEARER_PROTOCOL_PREFIX` is introduced + * in M3.1 and may replace this literal when the WS auth seam lands. + */ +const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + +export interface BootOptions { + /** Bearer token attached by `authedFetch` / `authedWs`. Defaults to `'test-token'`. */ + token?: string; + /** + * Generic pass-through to `startServer({ serviceOverrides })`. The auth seam: + * from M2.1 on, callers inject `[IAuthTokenService, fixedTokenImpl]` here. + * Defaults to `[]`. + */ + serviceOverrides?: ServerStartOptions['serviceOverrides']; + /** Bind host. Defaults to `'127.0.0.1'`. */ + host?: string; + /** Bind port. Defaults to `0` (ephemeral). */ + port?: number; +} + +export interface ServerHarness { + /** The underlying `startServer` result. */ + readonly server: RunningServer; + /** Raw address returned by `startServer`, e.g. `http://127.0.0.1:51234`. */ + readonly address: string; + /** HTTP base URL, e.g. `http://127.0.0.1:51234`. */ + readonly baseUrl: string; + /** WebSocket URL for the v1 endpoint, e.g. `ws://127.0.0.1:51234/api/v1/ws`. */ + readonly wsUrl: string; + /** The bearer token this harness attaches to requests. */ + readonly token: string; + /** + * `fetch` against `baseUrl + path`, merging `Authorization: Bearer ` + * into the request headers. Caller-supplied headers win on conflict. + */ + authedFetch(path: string, init?: RequestInit): Promise; + /** + * Open a `ws` WebSocket to `wsUrl`, offering subprotocol + * `kimi-code.bearer.` and an `Authorization: Bearer ` header. + * In M0 the server ignores both; the connection still opens. + */ + authedWs(): WebSocket; + /** Tear down this server and terminate any sockets opened via `authedWs`. */ + close(): Promise; +} + +/** Every harness produced by `boot()`, for suite-level cleanup via `closeAll()`. */ +const opened = new Set(); + +function deriveHttpPort(address: string): string { + const port = new URL(address).port; + if (port === '') { + throw new Error(`cannot derive port from server address: ${address}`); + } + return port; +} + +/** + * Boot an isolated `startServer` for e2e use. + * + * Creates a tmp `lockPath` + isolated home dir (mirroring `start.test.ts`), + * binds to `127.0.0.1:0` by default, and tracks the result for `closeAll()`. + */ +export async function boot(opts: BootOptions = {}): Promise { + const token = opts.token ?? DEFAULT_TOKEN; + const host = opts.host ?? '127.0.0.1'; + const port = opts.port ?? 0; + // Inject a fixed-token `IAuthTokenService` by default so harness-based tests + // stay transparent (`authedFetch` / `authedWs` already carry `test-token`). + // The fixed impl is placed FIRST so an explicit caller-supplied + // `IAuthTokenService` override still wins (serviceOverrides are last-wins). + const serviceOverrides: ServerStartOptions['serviceOverrides'] = [ + fixedTokenAuth(token), + ...(opts.serviceOverrides ?? []), + ]; + + const tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-harness-')); + const homeDir = mkdtempSync(join(tmpdir(), 'kimi-server-harness-home-')); + const lockPath = join(tmpDir, 'lock'); + + const server = await startServer({ + host, + port, + lockPath, + serviceOverrides, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + + const httpPort = deriveHttpPort(server.address); + const baseUrl = `http://${host}:${httpPort}`; + const wsUrl = `ws://${host}:${httpPort}/api/v1/ws`; + + const sockets = new Set(); + let closed = false; + + const harness: ServerHarness = { + server, + address: server.address, + baseUrl, + wsUrl, + token, + + authedFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + // Caller-supplied Authorization wins on conflict. + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + return fetch(`${baseUrl}${path}`, { ...init, headers }); + }, + + authedWs(): WebSocket { + const ws = new WebSocket(wsUrl, [`${WS_BEARER_PROTOCOL_PREFIX}${token}`], { + headers: { Authorization: `Bearer ${token}` }, + }); + sockets.add(ws); + const drop = (): void => { + sockets.delete(ws); + }; + ws.once('close', drop); + ws.once('error', drop); + return ws; + }, + + async close(): Promise { + if (closed) return; + closed = true; + opened.delete(harness); + + for (const ws of sockets) { + try { + ws.terminate(); + } catch { + // ignore — best-effort teardown + } + } + sockets.clear(); + + try { + await server.close(); + } catch { + // ignore — best-effort teardown + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + }, + }; + + opened.add(harness); + return harness; +} + +/** Close every harness produced by `boot()`. Intended for `afterEach` cleanup. */ +export async function closeAll(): Promise { + const pending = [...opened]; + await Promise.all(pending.map((harness) => harness.close())); +} diff --git a/packages/server/test/host-exposure.e2e.test.ts b/packages/server/test/host-exposure.e2e.test.ts new file mode 100644 index 000000000..0acc2ca1c --- /dev/null +++ b/packages/server/test/host-exposure.e2e.test.ts @@ -0,0 +1,345 @@ +/** + * Host-exposure hardening (ROADMAP M6.3–M6.7). + * + * End-to-end coverage of the §3.5 public-bind hardening stack on a + * `host: '0.0.0.0'` + `KIMI_CODE_PASSWORD` + `insecureNoTls: true` server: + * - M6.3 public-bind gate (no `--insecure-no-tls` → refuse; token-only + * (no password) + `insecureNoTls` → boot + token-only warning logged; + * password + `insecureNoTls` → boot + warn logged). + * - Real password auth path (`Authorization: Bearer ` → 200 via + * `verifyPassword`; wrong/missing credentials → 401). + * - M6.4 auth-failure rate limit (N bad tokens → 429 on the (N+1)th). + * - M6.5 dangerous-endpoint downgrade (shutdown/terminals 404 by default; + * 200 with the allow flags; loopback mounts shutdown by default). + * - Host allowlist (spoofed Host → 403; bound host → 200). + * - M6.6 security response headers present on a non-loopback response. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; + +import { pino, type Logger } from 'pino'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IServerShutdownService, startServer, type RunningServer, type ServerStartOptions } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; + +const createdDirs: string[] = []; +const running: RunningServer[] = []; +let prevPassword: string | undefined; + +function tmpPaths(): { lockPath: string; homeDir: string } { + const dir = mkdtempSync(join(tmpdir(), 'kimi-host-exposure-')); + const home = mkdtempSync(join(tmpdir(), 'kimi-host-exposure-home-')); + createdDirs.push(dir, home); + return { lockPath: join(dir, 'lock'), homeDir: home }; +} + +function capturingLogger(): { logger: Logger; lines: string[] } { + const lines: string[] = []; + const dest = new Writable({ + write(chunk, _enc, cb) { + lines.push(String(chunk)); + cb(); + }, + }); + return { logger: pino({ level: 'info' }, dest), lines }; +} + +beforeEach(() => { + prevPassword = process.env['KIMI_CODE_PASSWORD']; +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore — best-effort teardown + } + } + for (const dir of createdDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + if (prevPassword === undefined) { + delete process.env['KIMI_CODE_PASSWORD']; + } else { + process.env['KIMI_CODE_PASSWORD'] = prevPassword; + } +}); + +describe('non-loopback bind gate (M6.3)', () => { + it('boots 0.0.0.0 without a password (token-only) and logs the token-only warning', async () => { + delete process.env['KIMI_CODE_PASSWORD']; + const { lockPath, homeDir } = tmpPaths(); + const { logger, lines } = capturingLogger(); + + const server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger, + coreProcessOptions: { homeDir }, + }); + running.push(server); + + // The server is up with token-only auth: a gated route answers 200. + const res = await fetch(`${server.address}/api/v1/healthz`, { headers: authHeaders() }); + expect(res.status).toBe(200); + + // The token-only warning was logged so the operator knows the bearer token + // is the only credential protecting the exposed server. + expect(lines.join('')).toContain('token-only auth'); + }); + + it('refuses to bind 0.0.0.0 with a password but without --insecure-no-tls', async () => { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + + await expect( + startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '0.0.0.0', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }), + ).rejects.toThrow(/without TLS/); + }); + + it('boots 0.0.0.0 with a password + insecureNoTls and logs the public warning', async () => { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + const { logger, lines } = capturingLogger(); + + const server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger, + coreProcessOptions: { homeDir }, + }); + running.push(server); + + // The server is up: a gated route answers 200 with a valid token. + const res = await fetch(`${server.address}/api/v1/healthz`, { headers: authHeaders() }); + expect(res.status).toBe(200); + + // The public-bind warning was logged so the operator knows TLS is off. + const combined = lines.join(''); + expect(combined).toContain('binding non-loopback host without TLS'); + }); +}); + +describe('dangerous-endpoint downgrade on a public bind (M6.5)', () => { + interface BootExposureOpts { + host?: string; + allowRemoteShutdown?: boolean; + allowRemoteTerminals?: boolean; + } + + async function bootExposure(opts: BootExposureOpts = {}): Promise<{ + server: RunningServer; + shutdownCalls: string[]; + }> { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + const shutdownCalls: string[] = []; + // Capture shutdown requests instead of exiting the process. + const noopShutdown = [ + IServerShutdownService, + { + _serviceBrand: undefined, + requestShutdown: async (reason: string) => { + shutdownCalls.push(reason); + }, + }, + ] as const; + const serviceOverrides: ServerStartOptions['serviceOverrides'] = [ + fixedTokenAuth(), + noopShutdown, + ]; + const server = await startServer({ + serviceOverrides, + host: opts.host ?? '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + allowRemoteShutdown: opts.allowRemoteShutdown, + allowRemoteTerminals: opts.allowRemoteTerminals, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + running.push(server); + return { server, shutdownCalls }; + } + + const terminalsUrl = (server: RunningServer): string => + `${server.address}/api/v1/sessions/some-session/terminals`; + + it('returns 404 for shutdown and terminals on a public bind without the allow flags', async () => { + const { server } = await bootExposure(); + + const shutdown = await fetch(`${server.address}/api/v1/shutdown`, { + method: 'POST', + headers: authHeaders(), + }); + expect(shutdown.status).toBe(404); + + const terminals = await fetch(terminalsUrl(server), { headers: authHeaders() }); + expect(terminals.status).toBe(404); + }); + + it('returns 200 for shutdown on a public bind when allowRemoteShutdown is set', async () => { + const { server, shutdownCalls } = await bootExposure({ allowRemoteShutdown: true }); + + const shutdown = await fetch(`${server.address}/api/v1/shutdown`, { + method: 'POST', + headers: authHeaders(), + }); + expect(shutdown.status).toBe(200); + // The handler replies before triggering shutdown (setImmediate); the noop + // override captures it so the process does not exit. + await vi.waitFor(() => expect(shutdownCalls).toContain('api')); + }); + + it('mounts shutdown on a loopback bind by default', async () => { + const { server, shutdownCalls } = await bootExposure({ host: '127.0.0.1' }); + + const shutdown = await fetch(`${server.address}/api/v1/shutdown`, { + method: 'POST', + headers: authHeaders(), + }); + expect(shutdown.status).toBe(200); + await vi.waitFor(() => expect(shutdownCalls).toContain('api')); + }); +}); + +/** + * Raw HTTP GET that lets us set an arbitrary `Host` header. Node's `fetch` + * (undici) treats `Host` as a forbidden header and silently replaces it with + * the URL host, so the Host-allowlist test drives `node:http` directly. + */ +function rawHttpGet( + url: string, + headers: Record, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = httpRequest( + { + hostname: u.hostname, + port: u.port, + path: `${u.pathname}${u.search}`, + method: 'GET', + headers, + }, + (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +describe('public-bind §3.5 end-to-end (M6.7)', () => { + /** + * Boot a 0.0.0.0 server using the REAL auth impl (no fixed-token override) so + * the password `verifyPassword` path is exercised. `KIMI_CODE_PASSWORD` is set + * so the M6.3 gate passes and the password is itself a valid bearer. + */ + async function bootPublicReal(): Promise { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + const server = await startServer({ + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + running.push(server); + return server; + } + + /** Boot a 0.0.0.0 server with a deterministic fixed token. */ + async function bootPublicFixed(token = 'real-token'): Promise { + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + const { lockPath, homeDir } = tmpPaths(); + const server = await startServer({ + serviceOverrides: [fixedTokenAuth(token)], + host: '0.0.0.0', + port: 0, + lockPath, + insecureNoTls: true, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + running.push(server); + return server; + } + + it('accepts the user password as a bearer token (verifyPassword path)', async () => { + const server = await bootPublicReal(); + const ok = await fetch(`${server.address}/api/v1/sessions`, { + headers: { Authorization: 'Bearer test-pw' }, + }); + expect(ok.status).toBe(200); + // Security headers ride on every non-loopback response (M6.6). + expect(ok.headers.get('x-content-type-options')).toBe('nosniff'); + expect(ok.headers.get('content-security-policy')).toBe("default-src 'self'"); + }); + + it('rejects wrong and missing credentials with 401', async () => { + const server = await bootPublicReal(); + const wrong = await fetch(`${server.address}/api/v1/sessions`, { + headers: { Authorization: 'Bearer wrong-password' }, + }); + expect(wrong.status).toBe(401); + const missing = await fetch(`${server.address}/api/v1/sessions`); + expect(missing.status).toBe(401); + }); + + it('rate-limits repeated auth failures to 429 on a real bind (M6.4)', async () => { + const server = await bootPublicFixed('real-token'); + const url = `${server.address}/api/v1/sessions`; + let lastStatus = 0; + // Default threshold is 10 failures; the 11th must be 429. + for (let i = 0; i < 11; i += 1) { + const res = await fetch(url, { headers: { Authorization: 'Bearer wrong' } }); + lastStatus = res.status; + if (i < 10) { + expect(res.status).toBe(401); + } + } + expect(lastStatus).toBe(429); + }); + + it('rejects a spoofed Host with 403 and accepts the bound host', async () => { + const server = await bootPublicFixed(); + // Bound host (0.0.0.0) is a literal IP → allowed by the Host allowlist. + const bound = await rawHttpGet(`${server.address}/api/v1/healthz`, { + Host: `0.0.0.0:${new URL(server.address).port}`, + }); + expect(bound.status).toBe(200); + // A spoofed Host is rejected before auth (Host check runs first). + const spoofed = await rawHttpGet(`${server.address}/api/v1/healthz`, { + Host: 'evil.example.com', + }); + expect(spoofed.status).toBe(403); + }); +}); diff --git a/packages/server/test/host-origin.e2e.test.ts b/packages/server/test/host-origin.e2e.test.ts new file mode 100644 index 000000000..b8788518a --- /dev/null +++ b/packages/server/test/host-origin.e2e.test.ts @@ -0,0 +1,262 @@ +/** + * Host / Origin checks wired into HTTP + WS (ROADMAP M4.3). + * + * HTTP: the global `onRequest` Host hook rejects `Host: evil.com` with 403 + * before any route handler (here `/api/v1/healthz`), while the default + * `127.0.0.1:` Host from a real `fetch` is allowed. + * + * WS: `wsGatewayOptions.hostCheck` / `allowedOrigins` gate the upgrade path + * before token validation. `authTokenService` is intentionally left unset so + * these cases isolate Host/Origin from token auth. The Node `ws` client sends + * no `Origin` by default, which is treated as a non-browser client and + * allowed; a spoofed browser `Origin: http://evil.com` is rejected. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { startServer, type RunningServer } from '../src'; +import type { WSGatewayOptions } from '../src/services/gateway/wsGateway'; +import { rawDataToString } from '../src/ws/rawData'; +import { fixedTokenAuth } from './helpers/serverHarness'; + +interface WsFrame { + type: string; + payload?: unknown; + [k: string]: unknown; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; + closed: Promise<{ code: number; reason: string }>; +} + +interface ConnectOptions { + protocols?: string[]; + headers?: Record; +} + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningServer[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-host-origin-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-host-origin-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function spawn(wsGatewayOptions?: WSGatewayOptions): Promise { + const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + ...(wsGatewayOptions !== undefined ? { wsGatewayOptions } : {}), + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; +} + +/** + * Raw HTTP GET that lets us set an arbitrary `Host` header. Node's `fetch` + * (undici) treats `Host` as a forbidden header and silently replaces it with + * the URL host, so we drive `node:http` directly to exercise the Host check. + */ +function rawHttpGet( + url: string, + headers: Record, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = httpRequest( + { + hostname: u.hostname, + port: u.port, + path: `${u.pathname}${u.search}`, + method: 'GET', + headers, + }, + (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +function openConn(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + // Offer the fixed bearer token so the M5.1 WS auth passes; the Host/Origin + // cases that expect rejection use `expectRejected` (no token) instead. + const protocols = [...(opts?.protocols ?? []), 'kimi-code.bearer.test-token']; + const ws = new WebSocket(url, protocols, { headers: opts?.headers }); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + let closedResolve: (v: { code: number; reason: string }) => void; + const closed = new Promise<{ code: number; reason: string }>((res) => { + closedResolve = res; + }); + ws.on('message', (data) => { + let parsed: WsFrame; + try { + parsed = JSON.parse(rawDataToString(data)) as WsFrame; + } catch { + return; + } + if (waiters.length > 0) { + waiters.shift()?.(parsed); + } else { + queue.push(parsed); + } + }); + ws.on('close', (code, reason) => { + closedResolve({ code, reason: String(reason) }); + }); + ws.once('open', () => resolve({ ws, queue, waiters, closed })); + ws.once('error', (err) => reject(err)); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message in ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +function expectRejected(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + // ignore + } + if (err !== undefined) reject(err); + else resolve(); + }; + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('HTTP Host check (start.ts)', () => { + it('rejects Host: evil.com with 403 before the route handler', async () => { + const r = await spawn(); + const res = await rawHttpGet(`${r.address}/api/v1/healthz`, { Host: 'evil.com' }); + 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'); + }); + + it('allows the default 127.0.0.1: Host', async () => { + const r = await spawn(); + const res = await rawHttpGet(`${r.address}/api/v1/healthz`, {}); + expect(res.status).toBe(200); + }); +}); + +describe('WS Host/Origin checks (wsGatewayService)', () => { + it('rejects a spoofed Host before token validation', async () => { + const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] }); + await expectRejected(wsUrl(r.address), { headers: { Host: 'evil.com' } }); + }); + + it('accepts a normal Host and delivers server_hello', async () => { + const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] }); + const conn = await openConn(wsUrl(r.address)); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + conn.ws.close(); + await conn.closed; + }); + + it('rejects a disallowed browser Origin', async () => { + const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] }); + await expectRejected(wsUrl(r.address), { + headers: { origin: 'http://evil.com' }, + }); + }); + + it('allows a Node client with no Origin (present-only check)', async () => { + const r = await spawn({ hostCheck: { boundHost: '127.0.0.1' }, allowedOrigins: [] }); + const conn = await openConn(wsUrl(r.address)); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + conn.ws.close(); + await conn.closed; + }); + + it('skips the checks when the options are unset', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address)); + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + conn.ws.close(); + await conn.closed; + }); +}); diff --git a/packages/server/test/hostnames.test.ts b/packages/server/test/hostnames.test.ts new file mode 100644 index 000000000..e5cc1a5c6 --- /dev/null +++ b/packages/server/test/hostnames.test.ts @@ -0,0 +1,150 @@ +/** + * Host-header allowlist (ROADMAP M4.1). + * + * Pure unit cases for `stripPort` / `isAllowedHost` / the env parsers, plus a + * minimal Fastify integration test that exercises the `onRequest` hook through + * `app.inject` (which sends `Host: localhost:80` by default). + */ + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createHostCheck, + isAllowedHost, + isHostCheckDisabled, + parseAllowedHosts, + stripPort, +} from '#/middleware/hostnames'; + +describe('stripPort', () => { + it('strips the port from a hostname', () => { + expect(stripPort('localhost:80')).toBe('localhost'); + }); + + it('strips the port from bracketed IPv6', () => { + expect(stripPort('[::1]:80')).toBe('[::1]'); + }); + + it('strips the port from an IPv4 literal', () => { + expect(stripPort('1.2.3.4:5678')).toBe('1.2.3.4'); + }); + + it('lowercases bare hosts', () => { + expect(stripPort('LOCALHOST')).toBe('localhost'); + }); +}); + +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']; + + for (const host of allow) { + it(`allows ${host}`, () => { + expect(isAllowedHost(host, {})).toBe(true); + }); + } + + const deny = ['evil.com', 'evil.com:80', '127.0.0.1.evil.com']; + + for (const host of deny) { + it(`denies ${host}`, () => { + expect(isAllowedHost(host, {})).toBe(false); + }); + } + + it('denies a missing Host header', () => { + expect(isAllowedHost(undefined, {})).toBe(false); + }); +}); + +describe('isAllowedHost (boundHost)', () => { + it('allows the bound host', () => { + expect(isAllowedHost('myhost', { boundHost: 'myhost' })).toBe(true); + }); + + it('strips the port on both sides', () => { + expect(isAllowedHost('myhost:1234', { boundHost: 'myhost:8080' })).toBe(true); + }); + + it('still denies unrelated hosts', () => { + expect(isAllowedHost('otherhost', { boundHost: 'myhost' })).toBe(false); + }); +}); + +describe('isAllowedHost (extra)', () => { + it('matches a subdomain wildcard', () => { + expect(isAllowedHost('a.example.com', { extra: ['.example.com'] })).toBe(true); + }); + + it('matches the bare domain of a wildcard', () => { + expect(isAllowedHost('example.com', { extra: ['.example.com'] })).toBe(true); + }); + + it('does not match a partial suffix', () => { + expect(isAllowedHost('baddexample.com', { extra: ['.example.com'] })).toBe(false); + }); + + it('matches an exact entry', () => { + expect(isAllowedHost('foo', { extra: ['foo'] })).toBe(true); + }); +}); + +describe('isAllowedHost (disable)', () => { + it('allows everything when disabled', () => { + expect(isAllowedHost('evil.com', { disable: true })).toBe(true); + }); +}); + +describe('parseAllowedHosts', () => { + it('splits, trims, and drops empties', () => { + expect(parseAllowedHosts({ KIMI_CODE_ALLOWED_HOSTS: ' a, .b.com, ' })).toEqual(['a', '.b.com']); + }); + + it('returns [] when unset', () => { + expect(parseAllowedHosts({})).toEqual([]); + }); +}); + +describe('isHostCheckDisabled', () => { + it('is true when set to "1"', () => { + expect(isHostCheckDisabled({ KIMI_CODE_DISABLE_HOST_CHECK: '1' })).toBe(true); + }); + + it('is false when unset', () => { + expect(isHostCheckDisabled({})).toBe(false); + }); +}); + +describe('createHostCheck (onRequest hook)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createHostCheck({}).onRequest); + app.get('/api/v1/probe', async () => ({ ok: true })); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('rejects a disallowed Host with the 40301 envelope', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { host: 'evil.com' }, + }); + 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['data']).toBeNull(); + expect(typeof body['request_id']).toBe('string'); + }); + + it('allows the default app.inject Host (localhost:80)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/probe' }); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/packages/server/test/lock.test.ts b/packages/server/test/lock.test.ts index ed7bea3b0..29ff1bb71 100644 --- a/packages/server/test/lock.test.ts +++ b/packages/server/test/lock.test.ts @@ -8,7 +8,7 @@ * 0)` returns ESRCH for any unallocated pid on Linux/macOS). */ -import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -78,6 +78,14 @@ describe('acquireLock — basic acquire / release', () => { rmSync(lockPath); expect(() => handle.release()).not.toThrow(); }); + + it('creates the lock file with 0600 permissions (ROADMAP M5.2)', () => { + const handle = acquireLock({ lockPath, port: 58627 }); + // The lock file lives next to the per-pid bearer token; it must not be + // group/world readable. + expect(statSync(lockPath).mode & 0o777).toBe(0o600); + handle.release(); + }); }); describe('acquireLock — concurrent-instance protection', () => { diff --git a/packages/server/test/messages.e2e.test.ts b/packages/server/test/messages.e2e.test.ts index 0db71cc75..cc3d3248e 100644 --- a/packages/server/test/messages.e2e.test.ts +++ b/packages/server/test/messages.e2e.test.ts @@ -40,6 +40,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -65,6 +66,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -77,12 +79,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/meta.e2e.test.ts b/packages/server/test/meta.e2e.test.ts index 6a4039be5..c98715bb7 100644 --- a/packages/server/test/meta.e2e.test.ts +++ b/packages/server/test/meta.e2e.test.ts @@ -33,6 +33,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -59,6 +60,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -75,16 +77,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - // We use the same accessor pattern start.ts uses internally. IRestGateway - // is registered with the `FastifyLike` structural type; `.inject()` is the - // Fastify-specific method we need for hermetic tests — it lives on the - // underlying instance, not on FastifyLike. The cast is local to this test. - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { @@ -137,6 +147,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { const homeA = mkdtempSync(join(tmpdir(), 'kimi-server-meta-home-a-')); const homeB = mkdtempSync(join(tmpdir(), 'kimi-server-meta-home-b-')); const r1 = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath: lockA, @@ -144,6 +155,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { coreProcessOptions: { homeDir: homeA }, }); const r2 = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath: lockB, @@ -168,6 +180,7 @@ describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { describe('GET /api/v1/meta — server_version precedence', () => { it('reports the host kimi-code identity version when provided', async () => { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, diff --git a/packages/server/test/model-catalog.e2e.test.ts b/packages/server/test/model-catalog.e2e.test.ts index 3492bd40d..95c89ab3f 100644 --- a/packages/server/test/model-catalog.e2e.test.ts +++ b/packages/server/test/model-catalog.e2e.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IModelCatalogService, type IModelCatalogService as ModelCatalogServiceShape } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer, type ServerStartOptions } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -40,7 +41,7 @@ async function bootDaemon( lockPath, logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, - serviceOverrides, + serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])], }); return server; } @@ -48,12 +49,24 @@ async function bootDaemon( function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/oauth.e2e.test.ts b/packages/server/test/oauth.e2e.test.ts index f0d5b9d70..560c57c92 100644 --- a/packages/server/test/oauth.e2e.test.ts +++ b/packages/server/test/oauth.e2e.test.ts @@ -40,6 +40,7 @@ import type { } from '@moonshot-ai/protocol'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -128,7 +129,7 @@ async function bootDaemon(stub: StubOAuth): Promise { lockPath, logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, - serviceOverrides: [[IOAuthService, stub]], + serviceOverrides: [fixedTokenAuth(), [IOAuthService, stub]], }); return server; } @@ -136,12 +137,24 @@ async function bootDaemon(stub: StubOAuth): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/origin.test.ts b/packages/server/test/origin.test.ts new file mode 100644 index 000000000..af5228fcc --- /dev/null +++ b/packages/server/test/origin.test.ts @@ -0,0 +1,143 @@ +/** + * Origin / CORS middleware (ROADMAP M4.2). + * + * Pure unit cases for `originHost` / `isOriginAllowed` / `parseCorsOrigins`, + * plus a minimal Fastify integration test that drives the `onRequest` hook + * through `app.inject` and asserts the emitted (or withheld) CORS headers. + */ + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createOriginHook, + isOriginAllowed, + originHost, + parseCorsOrigins, +} from '#/middleware/origin'; + +describe('originHost', () => { + it('returns the host for a valid origin', () => { + expect(originHost('https://foo.com')).toBe('foo.com'); + }); + + it('drops the default port', () => { + expect(originHost('http://localhost:80')).toBe('localhost'); + }); + + it('keeps a non-default port', () => { + expect(originHost('http://127.0.0.1:58627')).toBe('127.0.0.1:58627'); + }); + + it('returns undefined for a missing origin', () => { + expect(originHost(undefined)).toBeUndefined(); + }); + + it('returns undefined for a malformed origin', () => { + expect(originHost('not a url')).toBeUndefined(); + }); +}); + +describe('isOriginAllowed', () => { + it('allows same-origin', () => { + expect(isOriginAllowed('http://localhost:80', 'localhost:80', [])).toBe(true); + }); + + it('denies cross-origin that is not whitelisted', () => { + expect(isOriginAllowed('http://evil.com', 'localhost:80', [])).toBe(false); + }); + + it('allows cross-origin that is whitelisted', () => { + expect(isOriginAllowed('https://foo.com', 'localhost:80', ['https://foo.com'])).toBe(true); + }); + + it('allows an absent origin', () => { + expect(isOriginAllowed(undefined, 'localhost:80', [])).toBe(true); + }); + + it('treats a malformed origin as absent (allowed)', () => { + expect(isOriginAllowed('not a url', 'h', [])).toBe(true); + }); +}); + +describe('parseCorsOrigins', () => { + it('splits, trims, and drops empties', () => { + expect(parseCorsOrigins({ KIMI_CODE_CORS_ORIGINS: ' https://a.com, https://b.com, ' })).toEqual([ + 'https://a.com', + 'https://b.com', + ]); + }); + + it('returns [] when unset', () => { + expect(parseCorsOrigins({})).toEqual([]); + }); +}); + +describe('createOriginHook (onRequest hook)', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + app.addHook('onRequest', createOriginHook({ allowedOrigins: ['https://foo.com'] })); + app.get('/api/v1/probe', async () => ({ ok: true })); + app.options('/api/v1/probe', async () => ({ ok: true })); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('echoes CORS headers for a same-origin request', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { origin: 'http://localhost:80', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('http://localhost:80'); + }); + + it('echoes the whitelisted cross-origin and short-circuits OPTIONS to 204', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/probe', + headers: { origin: 'https://foo.com', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe('https://foo.com'); + expect(res.headers['access-control-allow-methods']).toBe( + 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + ); + }); + + it('withholds CORS headers for a non-whitelisted cross-origin', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { origin: 'http://evil.com', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('returns 204 without CORS headers for a non-whitelisted OPTIONS', async () => { + const res = await app.inject({ + method: 'OPTIONS', + url: '/api/v1/probe', + headers: { origin: 'http://evil.com', host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('emits no CORS headers when Origin is absent', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/probe', + headers: { host: 'localhost:80' }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); +}); diff --git a/packages/server/test/prompt.e2e.test.ts b/packages/server/test/prompt.e2e.test.ts index 8cfe3f4a0..c99fb9e2f 100644 --- a/packages/server/test/prompt.e2e.test.ts +++ b/packages/server/test/prompt.e2e.test.ts @@ -33,6 +33,7 @@ import type { Event, PromptSubmission } from '@moonshot-ai/protocol'; import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type ServerStartOptions, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -66,7 +67,7 @@ async function bootDaemon( logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, - serviceOverrides, + serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])], }); return server; } @@ -74,12 +75,24 @@ async function bootDaemon( function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -204,7 +217,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(wsDataToString(data)) as Record); diff --git a/packages/server/test/question.e2e.test.ts b/packages/server/test/question.e2e.test.ts index 833daf0f6..669682ce1 100644 --- a/packages/server/test/question.e2e.test.ts +++ b/packages/server/test/question.e2e.test.ts @@ -25,6 +25,7 @@ import { } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; import { QuestionService } from '#/services/question/questionService'; @@ -52,6 +53,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -65,12 +67,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -112,7 +126,7 @@ async function openSubscriber( const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(rawDataToString(data)) as Record); diff --git a/packages/server/test/rate-limit.test.ts b/packages/server/test/rate-limit.test.ts new file mode 100644 index 000000000..a70aee17f --- /dev/null +++ b/packages/server/test/rate-limit.test.ts @@ -0,0 +1,165 @@ +/** + * Auth-failure rate limiting (ROADMAP M6.4). + * + * Two layers: + * 1. `createAuthFailureLimiter` unit behavior — failure counting, ban on + * threshold, ban expiry (fake timers), and window reset. + * 2. `createAuthHook` integration — a banned source gets `429` (even with a + * valid token), a different source still gets `401`, and a hook without a + * limiter (the loopback wiring) never returns `429`. + * + * Distinct source IPs are expressed via `X-Forwarded-For` with Fastify + * `trustProxy: true`, which is what `req.ip` reads behind a reverse proxy. + */ + +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAuthHook } from '#/middleware/auth'; +import { + createAuthFailureLimiter, + type AuthFailureLimiter, +} from '#/middleware/rateLimit'; +import type { IAuthTokenService } from '#/services/auth/authTokenService'; + +const TOKEN = 'test-token'; +const IP_A = '203.0.113.10'; +const IP_B = '203.0.113.11'; + +function fixedImpl(): IAuthTokenService { + return { + _serviceBrand: undefined, + getToken: () => TOKEN, + isValid: async (candidate) => candidate === TOKEN, + }; +} + +function buildApp(limiter?: AuthFailureLimiter): FastifyInstance { + const app = Fastify({ trustProxy: true }); + app.addHook('onRequest', createAuthHook(fixedImpl(), { limiter })); + app.get('/api/v1/sessions', async () => ({ ok: true })); + return app; +} + +function badToken(ip: string): { method: 'GET'; url: string; headers: Record } { + return { + method: 'GET', + url: '/api/v1/sessions', + headers: { 'x-forwarded-for': ip, authorization: 'Bearer wrong-token' }, + }; +} + +describe('createAuthHook rate limiting (M6.4)', () => { + let app: FastifyInstance; + let limiter: AuthFailureLimiter; + + beforeEach(async () => { + limiter = createAuthFailureLimiter({ maxFailures: 3, windowMs: 60_000, banMs: 60_000 }); + app = buildApp(limiter); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + limiter.dispose(); + }); + + it('returns 401 for the first N failures, then 429 on the (N+1)th from the same IP', async () => { + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(401); + const fourth = await app.inject(badToken(IP_A)); + expect(fourth.statusCode).toBe(429); + const body = fourth.json() as Record; + expect(body['code']).toBe(42901); + expect(body['msg']).toBe('Too many failed auth attempts'); + }); + + it('does not ban a different IP that has not hit the threshold', async () => { + // Push IP_A over the threshold. + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(429); + + // IP_B is a fresh source — still 401, not 429. + expect((await app.inject(badToken(IP_B))).statusCode).toBe(401); + }); + + it('returns 429 to a banned IP even when it presents a valid token', async () => { + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + await app.inject(badToken(IP_A)); + expect((await app.inject(badToken(IP_A))).statusCode).toBe(429); + + const valid = await app.inject({ + method: 'GET', + url: '/api/v1/sessions', + headers: { 'x-forwarded-for': IP_A, authorization: `Bearer ${TOKEN}` }, + }); + expect(valid.statusCode).toBe(429); + }); + + it('never returns 429 when no limiter is wired (loopback behavior)', async () => { + const noLimiterApp = buildApp(undefined); + await noLimiterApp.ready(); + try { + for (let i = 0; i < 10; i += 1) { + expect((await noLimiterApp.inject(badToken(IP_A))).statusCode).toBe(401); + } + } finally { + await noLimiterApp.close(); + } + }); +}); + +describe('createAuthFailureLimiter (unit)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('bans a source at the threshold and clears the ban after banMs', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 }); + try { + expect(limiter.isBanned('1.2.3.4')).toBe(false); + limiter.recordFailure('1.2.3.4'); + expect(limiter.isBanned('1.2.3.4')).toBe(false); + limiter.recordFailure('1.2.3.4'); + expect(limiter.isBanned('1.2.3.4')).toBe(true); + + vi.advanceTimersByTime(499); + expect(limiter.isBanned('1.2.3.4')).toBe(true); + vi.advanceTimersByTime(1); + expect(limiter.isBanned('1.2.3.4')).toBe(false); + } finally { + limiter.dispose(); + } + }); + + it('resets the failure count once the window elapses', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 }); + try { + limiter.recordFailure('5.5.5.5'); + vi.advanceTimersByTime(1_001); // window expired → next failure starts fresh + limiter.recordFailure('5.5.5.5'); + // Only one failure in the new window → not banned. + expect(limiter.isBanned('5.5.5.5')).toBe(false); + } finally { + limiter.dispose(); + } + }); + + it('tracks sources independently', () => { + vi.useFakeTimers(); + const limiter = createAuthFailureLimiter({ maxFailures: 1, windowMs: 1_000, banMs: 500 }); + try { + limiter.recordFailure('9.9.9.9'); + expect(limiter.isBanned('9.9.9.9')).toBe(true); + expect(limiter.isBanned('8.8.8.8')).toBe(false); + } finally { + limiter.dispose(); + } + }); +}); diff --git a/packages/server/test/security-headers.test.ts b/packages/server/test/security-headers.test.ts new file mode 100644 index 000000000..6c1696a92 --- /dev/null +++ b/packages/server/test/security-headers.test.ts @@ -0,0 +1,95 @@ +/** + * Security response headers (ROADMAP M6.6). + * + * Verifies the `onSend` hook is registered only on a non-loopback bind and + * that HSTS is omitted while TLS is terminated elsewhere (`tls: false`). + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { startServer, type RunningServer } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; + +const createdDirs: string[] = []; +const running: RunningServer[] = []; +let prevPassword: string | undefined; + +function tmpPaths(): { lockPath: string; homeDir: string } { + const dir = mkdtempSync(join(tmpdir(), 'kimi-sec-headers-')); + const home = mkdtempSync(join(tmpdir(), 'kimi-sec-headers-home-')); + createdDirs.push(dir, home); + return { lockPath: join(dir, 'lock'), homeDir: home }; +} + +beforeEach(() => { + prevPassword = process.env['KIMI_CODE_PASSWORD']; +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore — best-effort teardown + } + } + for (const dir of createdDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + if (prevPassword === undefined) { + delete process.env['KIMI_CODE_PASSWORD']; + } else { + process.env['KIMI_CODE_PASSWORD'] = prevPassword; + } +}); + +async function boot(host: string): Promise { + const { lockPath, homeDir } = tmpPaths(); + if (host !== '127.0.0.1') { + // Non-loopback binds require a password + TLS opt-out (M6.3). + process.env['KIMI_CODE_PASSWORD'] = 'test-pw'; + } + const server = await startServer({ + serviceOverrides: [fixedTokenAuth()], + host, + port: 0, + lockPath, + insecureNoTls: host !== '127.0.0.1', + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir }, + }); + running.push(server); + return server; +} + +describe('security response headers (M6.6)', () => { + it('sets nosniff / Referrer-Policy / CSP on a non-loopback bind, without HSTS', async () => { + const server = await boot('0.0.0.0'); + const res = await fetch(`${server.address}/api/v1/sessions`, { + headers: authHeaders(), + }); + expect(res.status).toBe(200); + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + expect(res.headers.get('referrer-policy')).toBe('no-referrer'); + expect(res.headers.get('content-security-policy')).toBe("default-src 'self'"); + // TLS is terminated by the reverse proxy in this phase → no HSTS here. + expect(res.headers.get('strict-transport-security')).toBeNull(); + }); + + it('does NOT set the security headers on a loopback bind', async () => { + const server = await boot('127.0.0.1'); + const res = await fetch(`${server.address}/api/v1/sessions`, { + headers: authHeaders(), + }); + expect(res.status).toBe(200); + expect(res.headers.get('x-content-type-options')).toBeNull(); + expect(res.headers.get('referrer-policy')).toBeNull(); + expect(res.headers.get('content-security-policy')).toBeNull(); + expect(res.headers.get('strict-transport-security')).toBeNull(); + }); +}); diff --git a/packages/server/test/services-auth.test.ts b/packages/server/test/services-auth.test.ts new file mode 100644 index 000000000..1221a2fa2 --- /dev/null +++ b/packages/server/test/services-auth.test.ts @@ -0,0 +1,182 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + PrivateFileTooPermissiveError, + readPrivateFile, + writePrivateFile, +} from '#/services/auth/privateFiles'; +import { loadOrCreateServerToken, rotateServerToken } from '#/services/auth/persistentToken'; +import { createTokenStore } from '#/services/auth/tokenStore'; +import { resolvePasswordHash, verifyPassword } from '#/services/auth/password'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-auth-test-')); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('privateFiles', () => { + it('writes a file with mode 0600', async () => { + const p = join(tmpDir, 'secret'); + await writePrivateFile(p, 'hello'); + expect(statSync(p).mode & 0o777).toBe(0o600); + }); + + it('creates an absent parent dir with mode 0700', async () => { + const p = join(tmpDir, 'nested', 'dir', 'secret'); + await writePrivateFile(p, 'hello'); + expect(statSync(join(tmpDir, 'nested', 'dir')).mode & 0o777).toBe(0o700); + }); + + it('round-trips string content through readPrivateFile', async () => { + const p = join(tmpDir, 'secret'); + await writePrivateFile(p, 's3cr3t-value'); + const buf = await readPrivateFile(p); + expect(buf.toString('utf8')).toBe('s3cr3t-value'); + }); + + it('round-trips Buffer content through readPrivateFile', async () => { + const p = join(tmpDir, 'bin'); + const data = Buffer.from([0, 1, 2, 254, 255]); + await writePrivateFile(p, data); + const buf = await readPrivateFile(p); + expect(buf.equals(data)).toBe(true); + }); + + it('readPrivateFile throws on a 0644 file', async () => { + const p = join(tmpDir, 'leaky'); + writeFileSync(p, 'x', { mode: 0o644 }); + chmodSync(p, 0o644); + await expect(readPrivateFile(p)).rejects.toThrowError( + PrivateFileTooPermissiveError, + ); + }); +}); + +describe('tokenStore', () => { + it('returns the same token from repeated getToken() calls', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + expect(store.getToken()).toBe(store.getToken()); + await store.dispose(); + }); + + it('produces different tokens for different home dirs', async () => { + const a = await createTokenStore(join(tmpDir, 'home-a')); + const b = await createTokenStore(join(tmpDir, 'home-b')); + expect(a.getToken()).not.toBe(b.getToken()); + await a.dispose(); + await b.dispose(); + }); + + it('reuses the same persistent token across stores in one home dir', async () => { + const home = join(tmpDir, 'home'); + const a = await createTokenStore(home); + const token = a.getToken(); + await a.dispose(); + const b = await createTokenStore(home); + expect(b.getToken()).toBe(token); + await b.dispose(); + }); + + it('writes the token file with mode 0600 at server.token', async () => { + const home = join(tmpDir, 'home'); + const store = await createTokenStore(home); + expect(store.tokenPath).toBe(join(home, 'server.token')); + expect(statSync(store.tokenPath).mode & 0o777).toBe(0o600); + await store.dispose(); + }); + + it('isValid accepts the token and rejects wrong / empty / same-length candidates', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + const token = store.getToken(); + expect(store.isValid(token)).toBe(true); + expect(store.isValid('wrong')).toBe(false); + expect(store.isValid('')).toBe(false); + + const other = await createTokenStore(join(tmpDir, 'home-other')); + expect(other.getToken().length).toBe(token.length); + expect(store.isValid(other.getToken())).toBe(false); + await store.dispose(); + await other.dispose(); + }); + + it('dispose() keeps the persistent token file on disk', async () => { + const store = await createTokenStore(join(tmpDir, 'home')); + expect(existsSync(store.tokenPath)).toBe(true); + await store.dispose(); + expect(existsSync(store.tokenPath)).toBe(true); + }); + + it('re-reads the token after the file is rewritten (live rotation)', async () => { + const home = join(tmpDir, 'home'); + const store = await createTokenStore(home); + const original = store.getToken(); + + // Rewrite the same way `rotateServerToken` does (atomic rename → new + // inode/mtime). Use a distinct, same-length value so the length check in + // isValid does not short-circuit. + const rotated = 'r'.repeat(original.length); + await writePrivateFile(store.tokenPath, rotated); + + expect(store.getToken()).toBe(rotated); + expect(store.isValid(rotated)).toBe(true); + expect(store.isValid(original)).toBe(false); + await store.dispose(); + }); +}); + +describe('persistentToken', () => { + it('loadOrCreateServerToken generates once and reuses thereafter', async () => { + const home = join(tmpDir, 'home'); + const a = await loadOrCreateServerToken(home); + const b = await loadOrCreateServerToken(home); + expect(a).toBe(b); + expect(statSync(join(home, 'server.token')).mode & 0o777).toBe(0o600); + }); + + it('rotateServerToken writes a new, different token to server.token', async () => { + const home = join(tmpDir, 'home'); + const original = await loadOrCreateServerToken(home); + const rotated = await rotateServerToken(home); + expect(rotated).not.toBe(original); + expect(readFileSync(join(home, 'server.token'), 'utf8').trim()).toBe(rotated); + }); +}); + +describe('password', () => { + it('resolvePasswordHash returns undefined when env is unset or empty', async () => { + expect(await resolvePasswordHash({})).toBeUndefined(); + expect(await resolvePasswordHash({ KIMI_CODE_PASSWORD: '' })).toBeUndefined(); + }); + + it('hashes a set password with bcrypt and verifies correctly', async () => { + const passwordHash = await resolvePasswordHash({ + KIMI_CODE_PASSWORD: 'correct-horse-battery-staple', + }); + expect(passwordHash?.startsWith('$2')).toBe(true); + expect(await verifyPassword('correct-horse-battery-staple', passwordHash)).toBe( + true, + ); + expect(await verifyPassword('wrong-password', passwordHash)).toBe(false); + }); + + it('verifyPassword returns false when the hash is undefined', async () => { + expect(await verifyPassword('anything', undefined)).toBe(false); + }); +}); diff --git a/packages/server/test/sessions-workspace.e2e.test.ts b/packages/server/test/sessions-workspace.e2e.test.ts index 8911b32a4..f1b211dfe 100644 --- a/packages/server/test/sessions-workspace.e2e.test.ts +++ b/packages/server/test/sessions-workspace.e2e.test.ts @@ -21,6 +21,7 @@ import type { Session, Workspace } from '@moonshot-ai/protocol'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -46,6 +47,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -58,12 +60,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 090edf346..12ae9b761 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -36,6 +36,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -67,6 +68,7 @@ afterEach(async () => { async function bootDaemon(options: { telemetry?: TelemetryClient } = {}): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -92,12 +94,24 @@ function recordingTelemetry(records: TelemetryRecord[]): TelemetryClient { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { code: number; msg: string; data: T | null; request_id: string; details?: unknown } { @@ -118,7 +132,7 @@ async function openSessionListListener(r: RunningServer): Promise<{ const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(wsDataToString(data)) as Record); diff --git a/packages/server/test/skills.e2e.test.ts b/packages/server/test/skills.e2e.test.ts index 619e772d4..0db590320 100644 --- a/packages/server/test/skills.e2e.test.ts +++ b/packages/server/test/skills.e2e.test.ts @@ -36,6 +36,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -66,6 +67,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -78,12 +80,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/snapshot.e2e.test.ts b/packages/server/test/snapshot.e2e.test.ts index e12d657b1..b9f107d2c 100644 --- a/packages/server/test/snapshot.e2e.test.ts +++ b/packages/server/test/snapshot.e2e.test.ts @@ -25,6 +25,7 @@ import type { Event, SessionSnapshotResponse } from '@moonshot-ai/protocol'; import { IEventService, IPromptService, PromptService } from '@moonshot-ai/agent-core'; import { IRestGateway, IWSBroadcastService, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { WSBroadcastService } from '#/services/gateway/wsBroadcastService'; let tmpDir: string; @@ -51,6 +52,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/snapshot.perf.test.ts b/packages/server/test/snapshot.perf.test.ts index d40381237..709cf46f3 100644 --- a/packages/server/test/snapshot.perf.test.ts +++ b/packages/server/test/snapshot.perf.test.ts @@ -20,6 +20,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { startServer, type RunningServer } from '../src'; +import { fixedTokenAuth, withAuth } from './helpers/serverHarness'; let tmpDir: string; let bridgeHome: string; @@ -42,11 +43,14 @@ afterEach(async () => { }); async function postSession(baseUrl: string, cwd: string): Promise { - const res = await fetch(`${baseUrl}/api/v1/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ metadata: { cwd } }), - }); + const res = await fetch( + `${baseUrl}/api/v1/sessions`, + withAuth({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd } }), + }), + ); const env = (await res.json()) as { code: number; data: { id: string } | null }; if (env.code !== 0 || env.data === null) throw new Error(JSON.stringify(env)); return env.data.id; @@ -54,7 +58,7 @@ async function postSession(baseUrl: string, cwd: string): Promise { async function timeSnapshot(baseUrl: string, sid: string): Promise { const t = performance.now(); - const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`); + const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`, withAuth()); await res.text(); return performance.now() - t; } @@ -71,6 +75,7 @@ describe('SnapshotReader perf (real HTTP, 50 sessions)', () => { host: '127.0.0.1', port: 0, lockPath: join(tmpDir, 'lock'), + serviceOverrides: [fixedTokenAuth()], logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, }); diff --git a/packages/server/test/snapshot.smoke.test.ts b/packages/server/test/snapshot.smoke.test.ts index 5d29e9502..5020b4762 100644 --- a/packages/server/test/snapshot.smoke.test.ts +++ b/packages/server/test/snapshot.smoke.test.ts @@ -22,6 +22,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { startServer, type RunningServer } from '../src'; +import { fixedTokenAuth, withAuth } from './helpers/serverHarness'; let tmpDir: string; let bridgeHome: string; @@ -48,6 +49,7 @@ async function boot(): Promise<{ baseUrl: string }> { host: '127.0.0.1', port: 0, lockPath: join(tmpDir, 'lock'), + serviceOverrides: [fixedTokenAuth()], logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, }); @@ -55,11 +57,14 @@ async function boot(): Promise<{ baseUrl: string }> { } async function postSession(baseUrl: string): Promise { - const res = await fetch(`${baseUrl}/api/v1/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ metadata: { cwd: join(tmpDir, 'workspace') } }), - }); + const res = await fetch( + `${baseUrl}/api/v1/sessions`, + withAuth({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: join(tmpDir, 'workspace') } }), + }), + ); const env = (await res.json()) as { code: number; data: { id: string } | null }; if (env.code !== 0 || env.data === null) throw new Error(`createSession failed: ${JSON.stringify(env)}`); return env.data.id; @@ -71,7 +76,7 @@ async function fetchSnapshot(baseUrl: string, sid: string): Promise<{ ms: number; }> { const t0 = performance.now(); - const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`); + const res = await fetch(`${baseUrl}/api/v1/sessions/${sid}/snapshot`, withAuth()); const envelope = (await res.json()) as { code: number; data: unknown }; return { status: res.status, envelope, ms: performance.now() - t0 }; } diff --git a/packages/server/test/start.test.ts b/packages/server/test/start.test.ts index cd25d5750..3ed2fa39f 100644 --- a/packages/server/test/start.test.ts +++ b/packages/server/test/start.test.ts @@ -47,6 +47,7 @@ import { type LockContents, type RunningServer, } from '../src'; +import { authHeaders, fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -123,6 +124,7 @@ function addrInUse(): NodeJS.ErrnoException { async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -171,6 +173,7 @@ describe('startServer — lock + healthz smoke', () => { const thirdPartyLockPath = join(tmpDir, 'lock-third-party'); try { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port, lockPath: thirdPartyLockPath, @@ -303,6 +306,7 @@ describe('startServer — web assets', () => { writeFileSync(join(assetsDir, 'app.js'), 'console.log("kimi web");', 'utf8'); const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -325,7 +329,7 @@ describe('startServer — web assets', () => { const health = await fetch(`${r.address}/api/v1/healthz`); await expect(health.json()).resolves.toMatchObject({ code: 0 }); - const openApi = await fetch(`${r.address}/openapi.json`); + const openApi = await fetch(`${r.address}/openapi.json`, { headers: authHeaders() }); expect(openApi.status).toBe(200); expect(openApi.headers.get('content-type')).toContain('application/json'); await expect(openApi.json()).resolves.toMatchObject({ @@ -338,7 +342,7 @@ describe('startServer — web assets', () => { }, }); - const asyncApi = await fetch(`${r.address}/asyncapi.json`); + const asyncApi = await fetch(`${r.address}/asyncapi.json`, { headers: authHeaders() }); expect(asyncApi.status).toBe(200); expect(asyncApi.headers.get('content-type')).toContain('application/json'); await expect(asyncApi.json()).resolves.toMatchObject({ @@ -362,6 +366,7 @@ describe('startServer — web assets', () => { it('does not expose the Swagger UI while keeping /openapi.json available', async () => { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -370,7 +375,7 @@ describe('startServer — web assets', () => { }); running.push(r); - const openApi = await fetch(`${r.address}/openapi.json`); + const openApi = await fetch(`${r.address}/openapi.json`, { headers: authHeaders() }); expect(openApi.status).toBe(200); const res = await fetch(`${r.address}/documentation`); @@ -433,11 +438,14 @@ describe('POST /api/v1/shutdown', () => { coreProcessOptions: { homeDir: bridgeHome }, // Override the real shutdown service so the route does not exit the // test runner via `process.exit(0)`. - serviceOverrides: [[IServerShutdownService, fake] as const], + serviceOverrides: [fixedTokenAuth(), [IServerShutdownService, fake] as const], }); running.push(r); - const res = await fetch(`${r.address}/api/v1/shutdown`, { method: 'POST' }); + const res = await fetch(`${r.address}/api/v1/shutdown`, { + method: 'POST', + headers: authHeaders(), + }); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body['code']).toBe(0); diff --git a/packages/server/test/swagger.e2e.test.ts b/packages/server/test/swagger.e2e.test.ts index 2327bb9df..a68a2da7e 100644 --- a/packages/server/test/swagger.e2e.test.ts +++ b/packages/server/test/swagger.e2e.test.ts @@ -13,6 +13,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -38,6 +39,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -50,12 +52,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function asRecord(value: unknown): Record { diff --git a/packages/server/test/tasks.e2e.test.ts b/packages/server/test/tasks.e2e.test.ts index 68c45bf5d..864ad7c7b 100644 --- a/packages/server/test/tasks.e2e.test.ts +++ b/packages/server/test/tasks.e2e.test.ts @@ -40,6 +40,7 @@ import { } from '@moonshot-ai/protocol'; import { IRestGateway, startServer, type ServerStartOptions, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -72,7 +73,7 @@ async function bootDaemon( lockPath, logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, - serviceOverrides, + serviceOverrides: [fixedTokenAuth(), ...(serviceOverrides ?? [])], }); return server; } @@ -80,12 +81,24 @@ async function bootDaemon( function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/terminals.e2e.test.ts b/packages/server/test/terminals.e2e.test.ts index 5b2be145f..6fd012ead 100644 --- a/packages/server/test/terminals.e2e.test.ts +++ b/packages/server/test/terminals.e2e.test.ts @@ -8,6 +8,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { FakeTerminalBackend } from './terminalTestBackend'; let tmpDir: string; @@ -41,6 +42,7 @@ async function bootServer(): Promise { logger: pino({ level: 'silent' }), coreProcessOptions: { homeDir: bridgeHome }, serviceOverrides: [ + fixedTokenAuth(), [ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)], ], }); @@ -50,12 +52,24 @@ async function bootServer(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/tools.e2e.test.ts b/packages/server/test/tools.e2e.test.ts index ead6967f0..ef946498c 100644 --- a/packages/server/test/tools.e2e.test.ts +++ b/packages/server/test/tools.e2e.test.ts @@ -26,6 +26,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; let tmpDir: string; let lockPath: string; @@ -51,6 +52,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/workspaces.e2e.test.ts b/packages/server/test/workspaces.e2e.test.ts index da2c269f2..08a348856 100644 --- a/packages/server/test/workspaces.e2e.test.ts +++ b/packages/server/test/workspaces.e2e.test.ts @@ -25,6 +25,7 @@ import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import type { Workspace } from '@moonshot-ai/protocol'; let tmpDir: string; @@ -51,6 +52,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -63,12 +65,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { diff --git a/packages/server/test/ws-abort.e2e.test.ts b/packages/server/test/ws-abort.e2e.test.ts index 6a59e0000..a7e5cbaef 100644 --- a/packages/server/test/ws-abort.e2e.test.ts +++ b/packages/server/test/ws-abort.e2e.test.ts @@ -32,6 +32,7 @@ import { WebSocket } from 'ws'; import { IPromptService, PromptService } from '@moonshot-ai/agent-core'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; let tmpDir: string; @@ -58,6 +59,7 @@ afterEach(async () => { async function bootDaemon(): Promise { server = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -71,12 +73,24 @@ async function bootDaemon(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -129,7 +143,7 @@ async function openSubscriber(r: RunningServer, sid: string): Promise[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(wsUrl); + const sock = new WebSocket(wsUrl, ['kimi-code.bearer.test-token']); sock.on('message', (data) => { try { received.push(JSON.parse(rawDataToString(data)) as Record); diff --git a/packages/server/test/ws-auth.e2e.test.ts b/packages/server/test/ws-auth.e2e.test.ts new file mode 100644 index 000000000..01853cf6b --- /dev/null +++ b/packages/server/test/ws-auth.e2e.test.ts @@ -0,0 +1,252 @@ +/** + * WS upgrade auth (ROADMAP M3). + * + * M3.1 adds the `kimi-code.bearer.` subprotocol parser; M3.2 wires it + * into the upgrade path. The parser is exercised as pure unit cases first; the + * upgrade-path cases boot `startServer` with a fixed-token + * `IAuthTokenService` injected through `wsGatewayOptions.authTokenService`. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { startServer, type RunningServer } from '../src'; +import { IAuthTokenService } from '../src/services/auth/authTokenService'; +import { extractWsBearerToken } from '../src/services/gateway/wsGateway'; +import { rawDataToString } from '../src/ws/rawData'; + +describe('extractWsBearerToken', () => { + it('returns undefined for a missing header', () => { + expect(extractWsBearerToken(undefined)).toBeUndefined(); + }); + + it('returns undefined for an empty header', () => { + expect(extractWsBearerToken('')).toBeUndefined(); + }); + + it('extracts the token from a single bearer subprotocol', () => { + expect(extractWsBearerToken('kimi-code.bearer.TOKEN')).toBe('TOKEN'); + }); + + it('finds the bearer subprotocol among a comma-separated list', () => { + expect(extractWsBearerToken('other, kimi-code.bearer.TOKEN2')).toBe('TOKEN2'); + }); + + it('returns undefined for an empty token', () => { + expect(extractWsBearerToken('kimi-code.bearer.')).toBeUndefined(); + }); + + it('returns undefined when no subprotocol matches', () => { + expect(extractWsBearerToken('unrelated')).toBeUndefined(); + }); +}); + +interface WsFrame { + type: string; + payload?: unknown; + [k: string]: unknown; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; + closed: Promise<{ code: number; reason: string }>; +} + +interface ConnectOptions { + protocols?: string[]; + headers?: Record; +} + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningServer[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-ws-auth-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-ws-auth-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +/** Accepts exactly `'test-token'`; mirrors the fixed-token seam used in M2. */ +const fixedTokenAuth: IAuthTokenService = { + _serviceBrand: undefined, + getToken: () => 'test-token', + isValid: async (candidate) => candidate === 'test-token', +}; + +async function spawn(): Promise { + const r = await startServer({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { + pingIntervalMs: 60, + pongTimeoutMs: 200, + }, + // Inject the fixed token via the DI seam (M5.1 reads it through the WS + // gateway's `setAuthTokenService`, no longer via `wsGatewayOptions`). + serviceOverrides: [[IAuthTokenService, fixedTokenAuth]], + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; +} + +function openConn(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + let closedResolve: (v: { code: number; reason: string }) => void; + const closed = new Promise<{ code: number; reason: string }>((res) => { + closedResolve = res; + }); + ws.on('message', (data) => { + let parsed: WsFrame; + try { + parsed = JSON.parse(rawDataToString(data)) as WsFrame; + } catch { + return; + } + if (waiters.length > 0) { + waiters.shift()?.(parsed); + } else { + queue.push(parsed); + } + }); + ws.on('close', (code, reason) => { + closedResolve({ code, reason: String(reason) }); + }); + ws.once('open', () => resolve({ ws, queue, waiters, closed })); + ws.once('error', (err) => reject(err)); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message in ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +/** + * Asserts the upgrade is rejected (the socket errors/closes without ever + * opening, so no `server_hello` is received). Resolves on the first `error` or + * `close`; rejects if the socket opens or nothing happens within the timeout. + */ +function expectRejected(url: string, opts?: ConnectOptions): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, opts?.protocols, { headers: opts?.headers }); + const done = (err?: Error): void => { + clearTimeout(t); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + // ignore + } + if (err !== undefined) reject(err); + else resolve(); + }; + const t = setTimeout( + () => done(new Error('connection was not rejected within timeout')), + 1500, + ); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); +} + +describe('ws upgrade auth', () => { + it('accepts a valid bearer subprotocol and echoes it', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address), { + protocols: ['kimi-code.bearer.test-token'], + }); + + await receiveType(conn, 'server_hello', 1000); + expect(conn.ws.protocol).toBe('kimi-code.bearer.test-token'); + + conn.ws.close(); + await conn.closed; + }); + + it('accepts a valid Authorization bearer header', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address), { + headers: { Authorization: 'Bearer test-token' }, + }); + + const hello = await receiveType(conn, 'server_hello', 1000); + expect(hello.type).toBe('server_hello'); + + conn.ws.close(); + await conn.closed; + }); + + it('rejects a wrong bearer token without server_hello', async () => { + const r = await spawn(); + await expectRejected(wsUrl(r.address), { + protocols: ['kimi-code.bearer.wrong'], + }); + }); + + it('rejects a connection with no token', async () => { + const r = await spawn(); + await expectRejected(wsUrl(r.address)); + }); + + it('rejects upgrades to a non-/api/v1/ws path', async () => { + const r = await spawn(); + const badUrl = r.address.replace(/^http:\/\//, 'ws://') + '/api/v1/other'; + await expectRejected(badUrl, { protocols: ['kimi-code.bearer.test-token'] }); + }); +}); diff --git a/packages/server/test/ws-broadcast.e2e.test.ts b/packages/server/test/ws-broadcast.e2e.test.ts index a544906b3..23f28c50c 100644 --- a/packages/server/test/ws-broadcast.e2e.test.ts +++ b/packages/server/test/ws-broadcast.e2e.test.ts @@ -31,6 +31,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; let tmpDir: string; @@ -52,12 +53,15 @@ afterEach(async () => { // ignore } } - rmSync(tmpDir, { recursive: true, force: true }); - rmSync(bridgeHome, { recursive: true, force: true }); + // The server's core process may still flush files into the sandboxed home + // briefly after close(), so retry removals to ride out EBUSY/ENOTEMPTY races. + rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }); async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -91,7 +95,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; ws.on('message', (data) => { diff --git a/packages/server/test/ws-handshake.e2e.test.ts b/packages/server/test/ws-handshake.e2e.test.ts index 10b5d7aa1..49e3c60ea 100644 --- a/packages/server/test/ws-handshake.e2e.test.ts +++ b/packages/server/test/ws-handshake.e2e.test.ts @@ -25,6 +25,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { IConnectionRegistry, IWSGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; interface TelemetryRecord { @@ -84,6 +85,7 @@ afterEach(async () => { async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -122,7 +124,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; let closedResolve: (v: { code: number; reason: string }) => void; @@ -284,6 +286,7 @@ describe('WS gateway connection-count observer', () => { it('reports size 1 after a connect and size 0 after the last disconnect', async () => { const counts: number[] = []; const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -313,6 +316,7 @@ describe('WS gateway connection-count observer', () => { it('tracks multiple concurrent connections independently', async () => { const counts: number[] = []; const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -350,6 +354,7 @@ describe('WS gateway telemetry (ws_connected / ws_disconnected)', () => { it('emits ws_connected on connect and ws_disconnected on close', async () => { const records: TelemetryRecord[] = []; const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, diff --git a/packages/server/test/ws-resync.e2e.test.ts b/packages/server/test/ws-resync.e2e.test.ts index 5d21f2fed..d5ddb6361 100644 --- a/packages/server/test/ws-resync.e2e.test.ts +++ b/packages/server/test/ws-resync.e2e.test.ts @@ -41,6 +41,7 @@ import { startServer, type RunningServer, } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; import { WSBroadcastService } from '#/services/gateway/wsBroadcastService'; @@ -69,6 +70,7 @@ afterEach(async () => { async function spawn(): Promise { const r = await startServer({ + serviceOverrides: [fixedTokenAuth()], host: '127.0.0.1', port: 0, lockPath, @@ -104,7 +106,7 @@ interface Conn { function openConn(url: string): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocket(url, ['kimi-code.bearer.test-token']); const queue: WsFrame[] = []; const waiters: Array<(frame: WsFrame) => void> = []; ws.on('message', (data) => { diff --git a/packages/server/test/ws-terminal.e2e.test.ts b/packages/server/test/ws-terminal.e2e.test.ts index 8b41f5871..353793cc0 100644 --- a/packages/server/test/ws-terminal.e2e.test.ts +++ b/packages/server/test/ws-terminal.e2e.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { IRestGateway, startServer, type RunningServer } from '../src'; +import { fixedTokenAuth } from './helpers/serverHarness'; import { rawDataToString } from '../src/ws/rawData'; import { FakeTerminalBackend } from './terminalTestBackend'; @@ -44,6 +45,7 @@ async function bootServer(): Promise { coreProcessOptions: { homeDir: bridgeHome }, wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, serviceOverrides: [ + fixedTokenAuth(), [ITerminalService, new SyncDescriptor(TerminalService, [{ backend }], false)], ], }); @@ -53,12 +55,24 @@ async function bootServer(): Promise { function appOf(r: RunningServer): { inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; } { - return r.services.invokeFunction((a) => { + const app = r.services.invokeFunction((a) => { const gw = a.get(IRestGateway); return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +}; }); + // Auto-attach the fixed bearer token so the M5.1 auth hook passes. A + // caller-supplied `authorization` header wins, so explicit token tests keep + // working; every other header (Range, content-type, …) is preserved. + return { + inject(req: unknown) { + const q = req as { headers?: Record }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; } function envelopeOf(body: unknown): { @@ -112,7 +126,9 @@ async function openSocket(r: RunningServer): Promise<{ }> { const received: Record[] = []; const ws = await new Promise((resolve, reject) => { - const sock = new WebSocket(r.address.replace('http://', 'ws://') + '/api/v1/ws'); + const sock = new WebSocket(r.address.replace('http://', 'ws://') + '/api/v1/ws', [ + 'kimi-code.bearer.test-token', + ]); sock.on('message', (data) => { received.push(JSON.parse(rawDataToString(data)) as Record); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8af62e766..bd0208433 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -553,6 +553,9 @@ importers: '@moonshot-ai/protocol': specifier: workspace:^ version: link:../protocol + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 fastify: specifier: ^5.1.0 version: 5.8.5 @@ -575,6 +578,9 @@ importers: specifier: ^3.25.2 version: 3.25.2(zod@4.3.6) devDependencies: + '@types/bcryptjs': + specifier: ^2.4.6 + version: 2.4.6 '@types/ws': specifier: ^8.18.0 version: 8.18.1 @@ -2561,6 +2567,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3070,6 +3079,9 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -8018,6 +8030,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcryptjs@2.4.6': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8595,6 +8609,8 @@ snapshots: dependencies: tweetnacl: 0.14.5 + bcryptjs@2.4.3: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2