Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/hardening-address-blocking-and-artifact-integrity.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions packages/agent-core-v2/src/app/capability/entries/kimiCu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
19 changes: 17 additions & 2 deletions packages/agent-core-v2/src/app/capability/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<number> {
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();
Expand Down Expand Up @@ -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)));
Expand All @@ -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;
}
Expand Down
38 changes: 31 additions & 7 deletions packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
43 changes: 43 additions & 0 deletions packages/agent-core-v2/test/app/capability/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,50 @@ describe('LocalFetchURLProvider SSRF guard', () => {
expect(fetchImpl).not.toHaveBeenCalled();
});

it('rejects NAT64-embedded private IPv4 literals', async () => {
const fetchImpl = vi.fn<typeof fetch>();
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<typeof fetch>()
.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<typeof fetch>();
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<typeof fetch>();
const provider = new LocalFetchURLProvider({ fetchImpl });
Expand Down
38 changes: 31 additions & 7 deletions packages/agent-core/src/tools/providers/local-fetch-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/agent-core/test/tools/providers/local-fetch-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,50 @@ describe('LocalFetchURLProvider SSRF guard', () => {
expect(fetchImpl).not.toHaveBeenCalled();
});

it('rejects NAT64-embedded private IPv4 literals', async () => {
const fetchImpl = vi.fn<typeof fetch>();
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<typeof fetch>()
.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<typeof fetch>();
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<typeof fetch>();
const provider = new LocalFetchURLProvider({ fetchImpl });
Expand Down
Loading