From 1b8e95d3dddb12cf7a217eed1cd9706b49b5f2a3 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Wed, 12 Aug 2026 16:38:20 -0600 Subject: [PATCH] chore(hardening): extend private-address blocking and verify downloaded artifacts Mirror each private IPv4 range into the NAT64 well-known prefix (RFC 6052 64:ff9b::/96) so a NAT64 network cannot be used to reach a private v4 target through an IPv6 literal, and block the RFC 8215 local-use prefix wholesale. Public IPv4 addresses reached over NAT64 stay allowed. Applied to both engine copies of the fetch provider. Give the capability downloader an optional expectedSha256: the digest is computed while streaming and a mismatch removes the file before throwing, so an unverified artifact is never left on disk for a later step to run. Leave the quarantine attribute on the downloaded macOS bundle in place so Gatekeeper still evaluates it on first launch. Co-Authored-By: Claude Opus 4.8 --- ...address-blocking-and-artifact-integrity.md | 10 +++++ .../src/app/capability/entries/kimiCu.ts | 7 +-- .../agent-core-v2/src/app/capability/host.ts | 19 +++++++- .../src/app/web/providers/local-fetch-url.ts | 38 +++++++++++++--- .../test/app/capability/host.test.ts | 43 ++++++++++++++++++ .../app/web/providers/local-fetch-url.test.ts | 44 +++++++++++++++++++ .../src/tools/providers/local-fetch-url.ts | 38 +++++++++++++--- .../tools/providers/local-fetch-url.test.ts | 44 +++++++++++++++++++ 8 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 .changeset/hardening-address-blocking-and-artifact-integrity.md diff --git a/.changeset/hardening-address-blocking-and-artifact-integrity.md b/.changeset/hardening-address-blocking-and-artifact-integrity.md new file mode 100644 index 00000000..cb8c0d1a --- /dev/null +++ b/.changeset/hardening-address-blocking-and-artifact-integrity.md @@ -0,0 +1,10 @@ +--- +'@moonshot-ai/agent-core-v2': patch +'@moonshot-ai/agent-core': patch +--- + +Extend the URL-fetch private-address blocklist to cover NAT64-embedded IPv4 +ranges (RFC 6052 `64:ff9b::/96` and the RFC 8215 local-use prefix), and give the +capability downloader optional SHA-256 verification that discards an artifact +whose digest does not match. The macOS computer-use bundle keeps its quarantine +attribute so Gatekeeper still evaluates it. diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts index af1c418e..bffca2d0 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts @@ -480,9 +480,10 @@ function createMacKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry { } await stopOldProcesses(); await moveAppIntoPlace(path.join(unzipDir, APP_BUNDLE)); - await runCommand(ctx.hostProcess, 'xattr', ['-dr', 'com.apple.quarantine', appPath], { - timeout: commandTimeoutMs, - }); + // The quarantine attribute is deliberately left in place: this bundle + // is fetched over the network and is not verified against a published + // checksum or signature here, so Gatekeeper stays the backstop and the + // user gets its prompt on first launch. } finally { await rm(workDir, { recursive: true, force: true }).catch(() => undefined); } diff --git a/packages/agent-core-v2/src/app/capability/host.ts b/packages/agent-core-v2/src/app/capability/host.ts index 04c5633f..e957f271 100644 --- a/packages/agent-core-v2/src/app/capability/host.ts +++ b/packages/agent-core-v2/src/app/capability/host.ts @@ -10,8 +10,9 @@ * fails the background install instead of wedging it. */ +import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; +import { mkdir, rm } from 'node:fs/promises'; import path from 'node:path'; import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; @@ -91,9 +92,11 @@ export async function downloadToFile( destPath: string, onPercent?: (percent: number) => void, fetchImpl: FetchLike = fetch as unknown as FetchLike, - options: { idleTimeoutMs?: number } = {}, + options: { idleTimeoutMs?: number; expectedSha256?: string } = {}, ): Promise { const idleTimeoutMs = options.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS; + const expectedSha256 = options.expectedSha256?.trim().toLowerCase(); + const digest = expectedSha256 === undefined ? undefined : createHash('sha256'); const headerController = new AbortController(); const headerTimer = setTimeout(() => { headerController.abort(); @@ -122,6 +125,7 @@ export async function downloadToFile( const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) { armIdleWatchdog(); + digest?.update(chunk); received += chunk.length; if (total > 0 && onPercent !== undefined) { onPercent(Math.min(99, Math.floor((received / total) * 100))); @@ -142,6 +146,17 @@ export async function downloadToFile( } finally { if (idleTimer !== undefined) clearTimeout(idleTimer); } + if (expectedSha256 !== undefined && digest !== undefined) { + const actual = digest.digest('hex'); + if (actual !== expectedSha256) { + // Never leave an unverified artifact on disk where a later step could + // pick it up and execute it. + await rm(destPath, { force: true }).catch(() => {}); + throw new Error( + `Checksum mismatch for ${url}: expected sha256 ${expectedSha256}, got ${actual}`, + ); + } + } onPercent?.(100); return received; } diff --git a/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts b/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts index 403f2efe..b5a61bfa 100644 --- a/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts @@ -218,15 +218,39 @@ export class LocalFetchURLProvider implements UrlFetcher { } } +/** + * NAT64 (RFC 6052) embeds an IPv4 address in the low 32 bits of the + * well-known prefix 64:ff9b::/96. Those are ordinary IPv6 addresses that the + * v4 rules do not cover, so on a NAT64 network they would translate straight + * through to the embedded v4 target. Each private v4 range is mirrored into + * NAT64 space (prefix 96 + the v4 prefix length); public v4 addresses reached + * over NAT64 stay allowed. The local-use prefix 64:ff9b:1::/48 (RFC 8215) has + * no fixed embedding offset, so it is blocked wholesale. + */ +const PRIVATE_IPV4_SUBNETS: readonly (readonly [string, number])[] = [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.168.0.0', 16], +]; + +const NAT64_WELL_KNOWN_PREFIX = '64:ff9b::'; +const NAT64_WELL_KNOWN_PREFIX_BITS = 96; + const PRIVATE_ADDRESS_BLOCKLIST = (() => { const list = new BlockList(); - list.addSubnet('0.0.0.0', 8, 'ipv4'); - list.addSubnet('10.0.0.0', 8, 'ipv4'); - list.addSubnet('100.64.0.0', 10, 'ipv4'); - list.addSubnet('127.0.0.0', 8, 'ipv4'); - list.addSubnet('169.254.0.0', 16, 'ipv4'); - list.addSubnet('172.16.0.0', 12, 'ipv4'); - list.addSubnet('192.168.0.0', 16, 'ipv4'); + for (const [address, prefixBits] of PRIVATE_IPV4_SUBNETS) { + list.addSubnet(address, prefixBits, 'ipv4'); + list.addSubnet( + `${NAT64_WELL_KNOWN_PREFIX}${address}`, + NAT64_WELL_KNOWN_PREFIX_BITS + prefixBits, + 'ipv6', + ); + } + list.addSubnet('64:ff9b:1::', 48, 'ipv6'); list.addSubnet('::', 128, 'ipv6'); list.addSubnet('::1', 128, 'ipv6'); list.addSubnet('fc00::', 7, 'ipv6'); diff --git a/packages/agent-core-v2/test/app/capability/host.test.ts b/packages/agent-core-v2/test/app/capability/host.test.ts index e88411d1..bab02eca 100644 --- a/packages/agent-core-v2/test/app/capability/host.test.ts +++ b/packages/agent-core-v2/test/app/capability/host.test.ts @@ -154,4 +154,47 @@ describe('capability host downloadToFile', () => { expect(received).toBe(11); expect(await readFile(dest, 'utf-8')).toBe('hello world'); }); + + function bodyOf(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); + } + + // sha256('hello world') + const HELLO_SHA256 = 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'; + + it('keeps a download whose checksum matches the expected digest', async () => { + const dest = path.join(root, 'verified.txt'); + + await downloadToFile( + 'https://cdn.example.test/hello', + dest, + undefined, + fakeFetchWith(bodyOf('hello world')) as never, + { expectedSha256: HELLO_SHA256 }, + ); + + expect(await readFile(dest, 'utf-8')).toBe('hello world'); + }); + + it('rejects and removes a download whose checksum does not match', async () => { + const dest = path.join(root, 'tampered.txt'); + + await expect( + downloadToFile( + 'https://cdn.example.test/hello', + dest, + undefined, + fakeFetchWith(bodyOf('hello wOrld')) as never, + { expectedSha256: HELLO_SHA256 }, + ), + ).rejects.toThrow(/Checksum mismatch/); + + // The unverified bytes must not survive on disk for a later step to run. + await expect(readFile(dest, 'utf-8')).rejects.toThrow(); + }); }); diff --git a/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts b/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts index 86013bba..59fe9503 100644 --- a/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts +++ b/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts @@ -63,6 +63,50 @@ describe('LocalFetchURLProvider SSRF guard', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it('rejects NAT64-embedded private IPv4 literals', async () => { + const fetchImpl = vi.fn(); + const provider = new LocalFetchURLProvider({ fetchImpl }); + + // 64:ff9b::/96 (RFC 6052) carries the v4 address in its low 32 bits. + await expect(provider.fetch('http://[64:ff9b::169.254.169.254]/latest/meta-data')).rejects.toThrow( + 'Refusing to fetch private address', + ); + await expect(provider.fetch('http://[64:ff9b::127.0.0.1]/')).rejects.toThrow( + 'Refusing to fetch private address', + ); + await expect(provider.fetch('http://[64:ff9b::10.0.0.1]/')).rejects.toThrow( + 'Refusing to fetch private address', + ); + // 64:ff9b:1::/48 (RFC 8215) has no fixed embedding offset: blocked whole. + await expect(provider.fetch('http://[64:ff9b:1::a9fe:a9fe]/')).rejects.toThrow( + 'Refusing to fetch private address', + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('still allows a NAT64 address that embeds a public IPv4', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(htmlResponse('ok', 'text/plain')); + const provider = new LocalFetchURLProvider({ fetchImpl }); + + const result = await provider.fetch('http://[64:ff9b::93.184.216.34]/'); + + expect(result).toEqual({ content: 'ok', kind: 'passthrough' }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('rejects a hostname that resolves to a NAT64-embedded private IPv4', async () => { + lookupMock.mockResolvedValue([{ address: '64:ff9b::169.254.169.254', family: 6 }]); + const fetchImpl = vi.fn(); + const provider = new LocalFetchURLProvider({ fetchImpl }); + + await expect(provider.fetch('http://nat64.example.test/')).rejects.toThrow( + 'resolves to private address', + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('rejects localhost and *.localhost aliases', async () => { const fetchImpl = vi.fn(); const provider = new LocalFetchURLProvider({ fetchImpl }); diff --git a/packages/agent-core/src/tools/providers/local-fetch-url.ts b/packages/agent-core/src/tools/providers/local-fetch-url.ts index 1b8e5084..2ac2d7b2 100644 --- a/packages/agent-core/src/tools/providers/local-fetch-url.ts +++ b/packages/agent-core/src/tools/providers/local-fetch-url.ts @@ -75,16 +75,40 @@ export interface LocalFetchURLProviderOptions { * "this network", for both address families. BlockList.check() maps * IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) onto the IPv4 * subnets, so mapped literals cannot slip past the v4 rules. + * + * NAT64 (RFC 6052) embeds an IPv4 address in the low 32 bits of the + * well-known prefix 64:ff9b::/96. Those are ordinary IPv6 addresses that + * the v4 rules above do not cover, so on a NAT64 network they would + * translate straight through to the embedded v4 target. Each private v4 + * range is therefore mirrored into NAT64 space (prefix 96 + the v4 prefix + * length); public v4 addresses reached over NAT64 stay allowed. The + * local-use prefix 64:ff9b:1::/48 (RFC 8215) has no fixed embedding + * offset, so it is blocked wholesale. */ +const PRIVATE_IPV4_SUBNETS: readonly (readonly [string, number])[] = [ + ['0.0.0.0', 8], // "this network" + ['10.0.0.0', 8], + ['100.64.0.0', 10], // CGNAT + ['127.0.0.0', 8], // loopback + ['169.254.0.0', 16], // link-local / cloud metadata + ['172.16.0.0', 12], + ['192.168.0.0', 16], +]; + +const NAT64_WELL_KNOWN_PREFIX = '64:ff9b::'; +const NAT64_WELL_KNOWN_PREFIX_BITS = 96; + const PRIVATE_ADDRESS_BLOCKLIST = (() => { const list = new BlockList(); - list.addSubnet('0.0.0.0', 8, 'ipv4'); // "this network" - list.addSubnet('10.0.0.0', 8, 'ipv4'); - list.addSubnet('100.64.0.0', 10, 'ipv4'); // CGNAT - list.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback - list.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local / cloud metadata - list.addSubnet('172.16.0.0', 12, 'ipv4'); - list.addSubnet('192.168.0.0', 16, 'ipv4'); + for (const [address, prefixBits] of PRIVATE_IPV4_SUBNETS) { + list.addSubnet(address, prefixBits, 'ipv4'); + list.addSubnet( + `${NAT64_WELL_KNOWN_PREFIX}${address}`, + NAT64_WELL_KNOWN_PREFIX_BITS + prefixBits, + 'ipv6', + ); + } + list.addSubnet('64:ff9b:1::', 48, 'ipv6'); // NAT64 local-use prefix list.addSubnet('::', 128, 'ipv6'); // unspecified list.addSubnet('::1', 128, 'ipv6'); // loopback list.addSubnet('fc00::', 7, 'ipv6'); // ULA diff --git a/packages/agent-core/test/tools/providers/local-fetch-url.test.ts b/packages/agent-core/test/tools/providers/local-fetch-url.test.ts index ff23ea1d..157a9331 100644 --- a/packages/agent-core/test/tools/providers/local-fetch-url.test.ts +++ b/packages/agent-core/test/tools/providers/local-fetch-url.test.ts @@ -112,6 +112,50 @@ describe('LocalFetchURLProvider SSRF guard', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it('rejects NAT64-embedded private IPv4 literals', async () => { + const fetchImpl = vi.fn(); + const provider = new LocalFetchURLProvider({ fetchImpl }); + + // 64:ff9b::/96 (RFC 6052) carries the v4 address in its low 32 bits. + await expect(provider.fetch('http://[64:ff9b::169.254.169.254]/latest/meta-data')).rejects.toThrow( + 'Refusing to fetch private address', + ); + await expect(provider.fetch('http://[64:ff9b::127.0.0.1]/')).rejects.toThrow( + 'Refusing to fetch private address', + ); + await expect(provider.fetch('http://[64:ff9b::10.0.0.1]/')).rejects.toThrow( + 'Refusing to fetch private address', + ); + // 64:ff9b:1::/48 (RFC 8215) has no fixed embedding offset: blocked whole. + await expect(provider.fetch('http://[64:ff9b:1::a9fe:a9fe]/')).rejects.toThrow( + 'Refusing to fetch private address', + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('still allows a NAT64 address that embeds a public IPv4', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(htmlResponse('ok', 'text/plain')); + const provider = new LocalFetchURLProvider({ fetchImpl }); + + const result = await provider.fetch('http://[64:ff9b::93.184.216.34]/'); + + expect(result).toEqual({ content: 'ok', kind: 'passthrough' }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('rejects a hostname that resolves to a NAT64-embedded private IPv4', async () => { + lookupMock.mockResolvedValue([{ address: '64:ff9b::169.254.169.254', family: 6 }]); + const fetchImpl = vi.fn(); + const provider = new LocalFetchURLProvider({ fetchImpl }); + + await expect(provider.fetch('http://nat64.example.test/')).rejects.toThrow( + 'resolves to private address', + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('rejects localhost and *.localhost aliases', async () => { const fetchImpl = vi.fn(); const provider = new LocalFetchURLProvider({ fetchImpl });