From 07aaa80bc4021e9e3062378f29d12d8fd5ea4fe3 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Wed, 12 Aug 2026 21:00:50 -0600 Subject: [PATCH] chore(hardening): pin the plugin catalog and stop unattended native updates The plugin catalog decides which plugins are offered and where their archives come from, and an installed plugin can declare a spawnable mcpServers command, so whoever serves that file effectively chooses code that runs on the machine. Require https and an allowed host, defaulting to code.kimi.com and cdn.kimi.com, with KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS for a self-hosted catalog. Require https for remote plugin archives too. Plaintext to loopback stays allowed: a local dev or test server has no network path to tamper with, and several suites rely on it. Stop auto-running the native updater. It is curl | bash against the CDN with nothing verifying the response, so running it unattended in the background turns a bad day at the CDN into local code execution. The command is surfaced for the user to run instead, which is what the Windows path already did. Tests that asserted the old behaviour now assert the new one, and the two catalog tests that use a placeholder host name it through the allowlist env the way an operator would. Co-Authored-By: Claude Opus 4.8 --- .changeset/hardening-supply-chain.md | 22 ++++++++ apps/kimi-code/src/cli/update/preflight.ts | 8 ++- .../kimi-code/src/utils/plugin-marketplace.ts | 50 +++++++++++++++++++ .../test/cli/update/preflight.test.ts | 23 +++------ .../test/tui/kimi-tui-message-flow.test.ts | 4 ++ .../test/utils/plugin-marketplace.test.ts | 49 ++++++++++++++++++ .../agent-core-v2/src/app/plugin/source.ts | 28 ++++++++++- packages/agent-core/src/plugin/source.ts | 24 ++++++++- .../agent-core/test/plugin/source.test.ts | 17 ++++--- 9 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 .changeset/hardening-supply-chain.md diff --git a/.changeset/hardening-supply-chain.md b/.changeset/hardening-supply-chain.md new file mode 100644 index 00000000..1eea4a3d --- /dev/null +++ b/.changeset/hardening-supply-chain.md @@ -0,0 +1,22 @@ +--- +'@moonshot-ai/kimi-code': minor +'@moonshot-ai/agent-core-v2': minor +'@moonshot-ai/agent-core': minor +--- + +Tighten how code arrives on the machine. + +The plugin catalog now has to be served over https from an allowed host +(`code.kimi.com` / `cdn.kimi.com` by default). The catalog picks which plugins +are offered and where their archives come from, and an installed plugin can +declare a spawnable `mcpServers` command, so whoever serves it effectively picks +code that runs locally. A self-hosted catalog is still possible by naming its +host in `KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS`. + +Remote plugin archives must likewise use https. Plaintext to loopback stays +allowed for local dev and test servers, where there is no network path to +tamper with. + +The native (`curl … install.sh | bash`) updater is no longer run unattended. +Nothing verifies what the CDN returns, so the command is now surfaced for the +user to run deliberately, matching what Windows already did. diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 09889903..1e0b6c1e 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -87,7 +87,7 @@ export function installCommandFor( } } -export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { +export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform): boolean { switch (source) { case 'npm-global': case 'pnpm-global': @@ -99,7 +99,11 @@ export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform) // behind the CDN release — prompt the user to run `brew upgrade` manually. return false; case 'native': - return platform !== 'win32'; + // The native updater is `curl … install.sh | bash` against the CDN, + // with nothing verifying what comes back. Running that unattended in + // the background turns a bad day at the CDN into local code execution, + // so surface the command and let the user run it deliberately. + return false; case 'unsupported': return false; } diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index 2ad4caa8..81dfe89a 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -182,12 +182,62 @@ export function parsePluginMarketplace(raw: string, location: MarketplaceLocatio }; } +/** + * Hosts the catalog may be fetched from over the network. + * + * The catalog decides which plugins are offered and where their archives come + * from, and an installed plugin can declare an `mcpServers` command that gets + * spawned — so whoever serves this file chooses code that runs on the machine. + * Anything beyond these hosts has to be named deliberately through + * `KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS` (comma-separated), which keeps a + * self-hosted internal catalog possible without leaving the default open. + */ +const DEFAULT_MARKETPLACE_HOSTS = ['code.kimi.com', 'cdn.kimi.com']; +const MARKETPLACE_ALLOWED_HOSTS_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS'; + +function allowedMarketplaceHosts(env: NodeJS.ProcessEnv = process.env): readonly string[] { + const extra = (env[MARKETPLACE_ALLOWED_HOSTS_ENV] ?? '') + .split(',') + .map((host) => host.trim().toLowerCase()) + .filter((host) => host.length > 0); + return [...DEFAULT_MARKETPLACE_HOSTS, ...extra]; +} + +const LOOPBACK_MARKETPLACE_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); + +function assertAllowedMarketplaceUrl(raw: string): void { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error(`Plugin marketplace URL is not a valid URL: ${raw}`); + } + // A catalog served from this machine has no network path to tamper with. + if (LOOPBACK_MARKETPLACE_HOSTS.has(url.hostname.toLowerCase())) return; + if (url.protocol !== 'https:') { + throw new Error( + `Plugin marketplace must be served over https (got "${url.protocol}//"). ` + + `The catalog selects code that will run locally, so it is not fetched over plaintext.`, + ); + } + const host = url.hostname.toLowerCase(); + const allowed = allowedMarketplaceHosts(); + if (!allowed.includes(host)) { + throw new Error( + `Plugin marketplace host "${host}" is not allowed. ` + + `Allowed: ${allowed.join(', ')}. ` + + `Add it to ${MARKETPLACE_ALLOWED_HOSTS_ENV} to use a self-hosted catalog.`, + ); + } +} + function resolveMarketplaceLocation(source: string, workDir: string): MarketplaceLocation { const trimmed = source.trim(); if (trimmed.length === 0) { throw new Error(`${KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV} cannot be empty.`); } if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + assertAllowedMarketplaceUrl(trimmed); return { raw: trimmed, kind: 'remote', resolved: trimmed }; } if (trimmed.startsWith('file://')) { diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2f7439f5..4dcae7ca 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -491,28 +491,21 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); - it('native on darwin: spawns bash -c with pipefail-guarded curl|bash', async () => { + it('native on darwin: prints the manual install command, does not spawn', async () => { + // The native updater is an unverified `curl … | bash` against the CDN, so + // it is never run unattended: the command is surfaced for the user to run. disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('native'); - mocks.promptForInstallChoice.mockResolvedValue('install'); - mockSpawnExit(0); const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'darwin' }); try { - const { options } = captureOutput(); - await runUpdatePreflight('0.4.0', options); - const call = mocks.spawn.mock.calls[0]; - expect(call?.[0]).toBe('bash'); - expect(call?.[2]).toEqual({ stdio: 'inherit' }); - const [flag, script] = call?.[1] as string[]; - expect(flag).toBe('-c'); - // pipefail must come before the pipeline so a failed `curl` is not masked - // by the trailing `bash` exiting 0 (see "surfaces a failed curl" below). - expect(script).toContain('set -o pipefail'); - expect(script).toContain('curl -fsSL https://code.kimi.com/kimi-code/install.sh'); - expect(script).toContain('| bash'); + const { stdout, options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(stdout.join('')).toContain('curl -fsSL https://code.kimi.com/kimi-code/install.sh'); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index acd25f34..4a178f8a 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -5638,6 +5638,9 @@ command = "vim" it('shows an inline Official error when the marketplace is unreachable, keeping the panel open', async () => { const originalFetch = globalThis.fetch; process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = 'https://example.test/marketplace.json'; + // Allow the host so this stays a test about an unreachable catalog rather + // than one rejected by the host allowlist. + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS'] = 'example.test'; vi.stubGlobal( 'fetch', vi.fn(async () => { @@ -5665,6 +5668,7 @@ command = "vim" // The panel stays mounted; the failure does not close /plugins. expect(driver.state.editorContainer.children[0]).toBe(panel); } finally { + delete process.env['KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS']; vi.stubGlobal('fetch', originalFetch); } }); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 5bc803ec..413357fa 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -305,6 +305,9 @@ describe('loadPluginMarketplace', () => { }); it('keeps the built-in entries when the catalog is unreachable', async () => { + // Not a default catalog host; name it explicitly the way an operator + // would for a self-hosted catalog. + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS', 'example.test,example.com'); const fetchImpl = vi.fn(async () => { throw new Error('fetch failed'); }) as unknown as typeof fetch; @@ -524,6 +527,7 @@ describe('loadPluginMarketplace', () => { }); it('loads an explicit remote marketplace with injectable fetch', async () => { + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS', 'example.com'); const source = 'https://example.com/plugins/marketplace.json'; const fetchImpl = vi.fn(async () => ({ ok: true, @@ -588,4 +592,49 @@ describe('loadPluginMarketplace', () => { ); }); + }); + +describe('marketplace host policy', () => { + it('rejects a plaintext http catalog', async () => { + await expect( + loadPluginMarketplace({ workDir: '/tmp', source: 'http://evil.example/marketplace.json' }), + ).rejects.toThrow(/must be served over https/); + }); + + it('rejects an https catalog on a host that is not allowed', async () => { + await expect( + loadPluginMarketplace({ workDir: '/tmp', source: 'https://evil.example/marketplace.json' }), + ).rejects.toThrow(/is not allowed/); + }); + + it('allows a self-hosted catalog named in the allowlist env', async () => { + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS', 'internal.example'); + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ plugins: [] }), { status: 200 }), + ) as unknown as typeof fetch; + + await expect( + loadPluginMarketplace({ + workDir: '/tmp', + source: 'https://internal.example/marketplace.json', + fetchImpl, + }), + ).resolves.toMatchObject({ plugins: [] }); + }); + + it('allows a loopback catalog over http (local dev server)', async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ plugins: [] }), { status: 200 }), + ) as unknown as typeof fetch; + + await expect( + loadPluginMarketplace({ + workDir: '/tmp', + source: 'http://127.0.0.1:8787/marketplace.json', + fetchImpl, + }), + ).resolves.toMatchObject({ plugins: [] }); + }); +}); + diff --git a/packages/agent-core-v2/src/app/plugin/source.ts b/packages/agent-core-v2/src/app/plugin/source.ts index 7909e81a..e8cdd4d7 100644 --- a/packages/agent-core-v2/src/app/plugin/source.ts +++ b/packages/agent-core-v2/src/app/plugin/source.ts @@ -16,13 +16,39 @@ export type InstallSource = ResolvedSource; const SHA_RE = /^[0-9a-f]{7,40}$/; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * Plaintext to the local machine has no network path to tamper with, so it + * stays allowed (local test servers, `pnpm dev:plugin-marketplace`). Plaintext + * to anything else does not. + */ +function isLoopbackUrl(raw: string): boolean { + try { + return LOOPBACK_HOSTS.has(new URL(raw).hostname.toLowerCase()); + } catch { + return false; + } +} + + export function resolveInstallSource(source: string): ResolvedSource { const trimmed = source.trim(); const github = parseGithubUrl(trimmed); if (github !== undefined) return github; - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + if (trimmed.startsWith('http://') && !isLoopbackUrl(trimmed)) { + // A plugin archive is executable content: it can ship an mcpServers + // command that gets spawned. Over plaintext there is nothing binding the + // bytes to the publisher, so refuse rather than trust the network. + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `Plugin source must use https (got "${trimmed}")`, + { details: { source } }, + ); + } + if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) { return { kind: 'zip-url', path: trimmed }; } if (!path.isAbsolute(trimmed)) { diff --git a/packages/agent-core/src/plugin/source.ts b/packages/agent-core/src/plugin/source.ts index 38a02ece..5f8e76d0 100644 --- a/packages/agent-core/src/plugin/source.ts +++ b/packages/agent-core/src/plugin/source.ts @@ -15,13 +15,35 @@ export type InstallSource = ResolvedSource; const SHA_RE = /^[0-9a-f]{7,40}$/; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * Plaintext to the local machine has no network path to tamper with, so it + * stays allowed (local test servers, `pnpm dev:plugin-marketplace`). Plaintext + * to anything else does not. + */ +function isLoopbackUrl(raw: string): boolean { + try { + return LOOPBACK_HOSTS.has(new URL(raw).hostname.toLowerCase()); + } catch { + return false; + } +} + + export function resolveInstallSource(source: string): ResolvedSource { const trimmed = source.trim(); const github = parseGithubUrl(trimmed); if (github !== undefined) return github; - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + if (trimmed.startsWith('http://') && !isLoopbackUrl(trimmed)) { + // A plugin archive is executable content: it can ship an mcpServers + // command that gets spawned. Over plaintext there is nothing binding the + // bytes to the publisher, so refuse rather than trust the network. + throw new Error(`Plugin source must use https (got "${source}")`); + } + if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) { return { kind: 'zip-url', path: trimmed }; } if (!path.isAbsolute(trimmed)) { diff --git a/packages/agent-core/test/plugin/source.test.ts b/packages/agent-core/test/plugin/source.test.ts index 12b76bc4..f3981fa1 100644 --- a/packages/agent-core/test/plugin/source.test.ts +++ b/packages/agent-core/test/plugin/source.test.ts @@ -8,9 +8,15 @@ describe('resolveInstallSource', () => { expect(result).toEqual({ kind: 'zip-url', path: 'https://example.com/plugin.zip' }); }); - it('recognizes http:// as zip-url', () => { - const result = resolveInstallSource('http://example.com/plugin.zip'); - expect(result).toEqual({ kind: 'zip-url', path: 'http://example.com/plugin.zip' }); + it('rejects plaintext http:// for a remote plugin archive', () => { + // A plugin archive can ship a spawnable mcpServers command, so plaintext + // delivery from a remote host is refused. + expect(() => resolveInstallSource('http://example.com/plugin.zip')).toThrow(/must use https/); + }); + + it('still allows http:// to loopback (local test/dev servers)', () => { + const url = 'http://127.0.0.1:8080/plugin.zip'; + expect(resolveInstallSource(url)).toEqual({ kind: 'zip-url', path: url }); }); it('recognizes absolute path as local-path', () => { @@ -169,10 +175,9 @@ describe('resolveInstallSource', () => { expect(result).toEqual({ kind: 'zip-url', path: url }); }); - it('treats http:// (non-https) github URL as plain zip-url', () => { + it('rejects a http:// github URL rather than treating it as a zip-url', () => { const url = 'http://github.com/wbxl2000/superpowers'; - const result = resolveInstallSource(url); - expect(result).toEqual({ kind: 'zip-url', path: url }); + expect(() => resolveInstallSource(url)).toThrow(/must use https/); }); it('percent-decodes %23 in /releases/tag/ so storage is human-readable', () => {