diff --git a/package.json b/package.json index 7d786cf..1e7d3ee 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "kernelbot": "./bin/kernel.js" }, "scripts": { - "start": "node bin/kernel.js start" + "start": "node bin/kernel.js start", + "test": "node --test" }, "keywords": [ "ai", diff --git a/src/security/network-policy.js b/src/security/network-policy.js new file mode 100644 index 0000000..f6d5726 --- /dev/null +++ b/src/security/network-policy.js @@ -0,0 +1,84 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { BlockList, isIP } from 'node:net'; + +// Conservative outbound policy: exclude special-purpose IPv4 ranges and allow +// only ordinary IPv6 global unicast (no mapped, translation or tunnel ranges). +// Registries: https://www.iana.org/assignments/iana-ipv4-special-registry/ +// https://www.iana.org/assignments/iana-ipv6-special-registry/ +const blocked = new BlockList(); +for (const [address, prefix] of [ + ['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.0.0.0', 24], + ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], + ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], + ['224.0.0.0', 4], ['240.0.0.0', 4], +]) blocked.addSubnet(address, prefix, 'ipv4'); +for (const [address, prefix] of [ + ['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20], +]) blocked.addSubnet(address, prefix, 'ipv6'); +const globalV6 = new BlockList(); +globalV6.addSubnet('2000::', 3, 'ipv6'); + +export function isPublicAddress(address) { + if (typeof address !== 'string' || address.includes('%')) return false; + const family = isIP(address); + if (family === 4) return !blocked.check(address, 'ipv4'); + return family === 6 && globalV6.check(address, 'ipv6') && !blocked.check(address, 'ipv6'); +} + +export async function resolvePublicHost(host, { lookup = dnsLookup, timeoutMs = 5000 } = {}) { + if (typeof host !== 'string' || !host) throw new Error('Invalid host'); + if (host.startsWith('[') && host.endsWith(']')) { + host = host.slice(1, -1); + if (isIP(host) !== 6) throw new Error('Invalid host'); + } + host = host.toLowerCase().replace(/\.$/, ''); + if (host === 'localhost' || host.endsWith('.localhost') || host === 'metadata.google.internal') { + throw new Error('Blocked: cannot access internal or cloud metadata addresses'); + } + + let addresses; + if (isIP(host)) { + addresses = [{ address: host }]; + } else { + // Also prevents curl --resolve control syntax (+, *, :, comma) in names. + if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(host)) throw new Error('Invalid host'); + let timer; + try { + addresses = await Promise.race([ + lookup(host, { all: true, verbatim: true }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('DNS lookup timed out')), timeoutMs); + }), + ]); + } catch { + throw new Error('Unable to resolve host'); + } finally { + clearTimeout(timer); + } + } + if (!Array.isArray(addresses) || !addresses.length || + addresses.some(result => !isPublicAddress(result?.address))) { + throw new Error('Blocked: destination must resolve only to public addresses'); + } + // Execution must use this numeric address, never resolve the hostname again. + return { host, address: addresses[0].address, family: isIP(addresses[0].address) }; +} + +export async function validateHttpUrl(url, options) { + let parsed; + try { + if (typeof url !== 'string') throw new Error(); + parsed = new URL(url); + } catch { + throw new Error('Invalid URL'); + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('Blocked: only HTTP and HTTPS URLs are allowed'); + } + const destination = await resolvePublicHost(parsed.hostname, options); + // Use the parsed URL at execution too, including canonical numeric hosts. + parsed.hostname = destination.family === 6 && isIP(destination.host) + ? `[${destination.host}]` : destination.host; + return { ...destination, url: parsed.href, port: parsed.port || (parsed.protocol === 'https:' ? '443' : '80') }; +} diff --git a/src/tools/network.js b/src/tools/network.js index c8eb186..ca3a253 100644 --- a/src/tools/network.js +++ b/src/tools/network.js @@ -1,5 +1,7 @@ import { shellRun, shellEscape } from '../utils/shell.js'; import { getLogger } from '../utils/logger.js'; +import { resolvePublicHost, validateHttpUrl } from '../security/network-policy.js'; +import { isIP } from 'node:net'; const run = (cmd, timeout = 15000) => shellRun(cmd, timeout); @@ -37,43 +39,28 @@ export const definitions = [ }, ]; -// SSRF protection: block requests to internal/cloud metadata addresses -const BLOCKED_HOSTS = [ - /^127\./, - /^10\./, - /^172\.(1[6-9]|2\d|3[01])\./, - /^192\.168\./, - /^169\.254\./, // AWS/cloud metadata endpoint - /^0\./, - /^localhost$/i, - /^metadata\.google\.internal$/i, - /^\[::1\]$/, - /^\[fd/i, // IPv6 private - /^\[fe80:/i, // IPv6 link-local -]; - -function isBlockedHost(host) { - return BLOCKED_HOSTS.some(pattern => pattern.test(host)); -} - -export const handlers = { +// Dependency injection keeps policy/execution regression tests off the network. +export const createNetworkHandlers = ({ lookup, execute = run, logger = getLogger } = {}) => ({ check_port: async (params) => { - const logger = getLogger(); const host = params.host || 'localhost'; const port = parseInt(params.port, 10); if (!Number.isFinite(port) || port <= 0 || port > 65535) return { error: 'Invalid port number' }; - // SSRF protection: block internal network probing - if (host !== 'localhost' && isBlockedHost(host)) { - return { error: 'Blocked: cannot probe internal or cloud metadata addresses' }; + let destination; + try { + // Keep the explicit local diagnostic exception, without trusting DNS for it. + destination = host === 'localhost' ? { address: '127.0.0.1', family: 4 } + : await resolvePublicHost(host, { lookup }); + } catch (error) { + return { error: error.message }; } - logger.debug(`check_port: checking ${host}:${port}`); + logger().debug(`check_port: checking ${host}:${port}`); // Use nc (netcat) for port check — works on both macOS and Linux - const result = await run(`nc -z -w 3 ${shellEscape(host)} ${port} 2>&1 && echo "OPEN" || echo "CLOSED"`, 5000); + const result = await execute(`nc -${destination.family} -n -z -w 3 ${shellEscape(destination.address)} ${port} 2>&1 && echo "OPEN" || echo "CLOSED"`, 5000); if (result.error) { - logger.error(`check_port failed for ${host}:${port}: ${result.error}`); + logger().error(`check_port failed for ${host}:${port}: ${result.error}`); return { port, host, status: 'closed', detail: result.error }; } @@ -84,17 +71,20 @@ export const handlers = { curl_url: async (params) => { const { url, method = 'GET', headers, body } = params; - // SSRF protection: block requests to internal networks and cloud metadata + let destination; try { - const parsed = new URL(url); - if (isBlockedHost(parsed.hostname)) { - return { error: 'Blocked: cannot access internal or cloud metadata addresses' }; - } - } catch { - return { error: 'Invalid URL' }; + destination = await validateHttpUrl(url, { lookup }); + } catch (error) { + return { error: error.message }; } - let cmd = `curl -s -w "\\n---HTTP_STATUS:%{http_code}" -X ${shellEscape(method)}`; + // -q must be first: curlrc, proxies and URL expansion must not override the + // validated destination. Redirects remain disabled (curl's default). + let cmd = `curl -q --globoff --proto '=http,https' --noproxy '*' -s -w "\\n---HTTP_STATUS:%{http_code}" -X ${shellEscape(method)}`; + if (!isIP(destination.host)) { + const address = destination.family === 6 ? `[${destination.address}]` : destination.address; + cmd += ` --resolve ${shellEscape(`${destination.host}:${destination.port}:${address}`)}`; + } if (headers) { for (const [key, val] of Object.entries(headers)) { @@ -106,9 +96,9 @@ export const handlers = { cmd += ` -d ${shellEscape(body)}`; } - cmd += ` ${shellEscape(url)}`; + cmd += ` --url ${shellEscape(destination.url)}`; - const result = await run(cmd); + const result = await execute(cmd); if (result.error) return result; @@ -138,4 +128,6 @@ export const handlers = { logger.debug('nginx_reload: successfully reloaded'); return { success: true, test_output: test.output }; }, -}; +}); + +export const handlers = createNetworkHandlers(); diff --git a/test/network-policy.test.js b/test/network-policy.test.js new file mode 100644 index 0000000..655f5a7 --- /dev/null +++ b/test/network-policy.test.js @@ -0,0 +1,156 @@ +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { isPublicAddress, resolvePublicHost, validateHttpUrl } from '../src/security/network-policy.js'; +import { createNetworkHandlers } from '../src/tools/network.js'; +import { shellEscape } from '../src/utils/shell.js'; + +const publicV4 = '8.8.8.8'; +const publicV6 = '2606:4700:4700::1111'; +const forbidden = [ + '0.0.0.0', '10.0.0.5', '100.64.0.1', '100.127.255.255', '127.0.0.1', + '169.254.169.254', '172.16.0.1', '172.31.255.255', '192.168.1.1', + '192.0.0.1', '192.0.2.1', '192.88.99.1', '198.18.0.1', '198.19.255.255', + '198.51.100.1', '203.0.113.1', '224.0.0.1', '240.0.0.1', '255.255.255.255', + '::', '::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:8.8.8.8', + '64:ff9b::a00:1', '64:ff9b:1::1', '100::1', '2001::1', '2001:db8::1', + '2002:0a00:0001::1', '3fff::1', 'fc00::1', 'fd00::1', 'fe80::1', + 'fe80::1%eth0', 'fec0::1', 'ff02::1', +]; +const resolver = (...addresses) => async () => addresses.map(address => ({ address })); +let unexpectedLookups = 0; +const noLookup = async () => { + unexpectedLookups++; + assert.fail('Unexpected DNS lookup'); +}; +after(() => assert.equal(unexpectedLookups, 0, 'literal/invalid destinations must not invoke DNS')); + +for (const address of forbidden) { + test(`rejects non-public address ${address}`, async () => { + assert.equal(isPublicAddress(address), false); + await assert.rejects(resolvePublicHost(address, { lookup: noLookup })); + await assert.rejects(resolvePublicHost('public.example', { lookup: resolver(address) })); + }); +} + +test('accepts public IPv4 and IPv6 and checks CIDR boundaries', async () => { + for (const address of [publicV4, publicV6, '100.63.255.255', '100.128.0.0', + '172.15.255.255', '172.32.0.0', '198.17.255.255', '198.20.0.0']) { + assert.equal(isPublicAddress(address), true, address); + assert.equal((await resolvePublicHost(address, { lookup: noLookup })).address, address); + } +}); + +test('requests all DNS candidates and rejects mixed results in either order', async () => { + let calls = 0; + await assert.rejects(resolvePublicHost('public.example', { lookup: async (host, options) => { + calls++; + assert.equal(host, 'public.example'); + assert.deepEqual(options, { all: true, verbatim: true }); + return [{ address: publicV4 }, { address: '10.0.0.1' }]; + } })); + assert.equal(calls, 1); + await assert.rejects(resolvePublicHost('public.example', { lookup: resolver('10.0.0.1', publicV4) })); +}); + +test('DNS errors, empty/malformed answers and timeout fail closed', async () => { + for (const result of [[], null, {}, [{}], [{ address: 'not-an-ip' }], [{ address: `${publicV6}%eth0` }]]) { + await assert.rejects(resolvePublicHost('public.example', { lookup: async () => result })); + } + await assert.rejects(resolvePublicHost('public.example', { lookup: async () => { throw new Error('ENOTFOUND'); } }), /Unable to resolve/); + await assert.rejects(resolvePublicHost('public.example', { lookup: () => new Promise(() => {}), timeoutMs: 1 }), /Unable to resolve/); +}); + +test('blocks internal names and pin-control syntax before DNS', async () => { + for (const host of ['localhost', 'LOCALHOST.', 'foo.localhost', 'metadata.google.internal', + '+public.example', '*.example', 'public.example:80', 'a,b', '[bad]', '']) { + await assert.rejects(resolvePublicHost(host, { lookup: noLookup })); + } +}); + +test('only HTTP(S), including canonicalized numeric hosts', async () => { + for (const url of ['file:///etc/hostname', 'gopher://public.example', 'ftp://public.example', + 'http://127.1', 'http://2130706433', 'http://0x7f000001', 'http://[::1]', + 'http://%31%32%37.0.0.1', 'not a url']) { + await assert.rejects(validateHttpUrl(url, { lookup: noLookup })); + } + const result = await validateHttpUrl('https://PUBLIC.example.:8443/path?q=1', { lookup: resolver(publicV4) }); + assert.equal(result.url, 'https://public.example:8443/path?q=1'); + assert.equal(result.port, '8443'); +}); + +function harness(lookup = resolver(publicV4), response = { output: 'hello\n---HTTP_STATUS:200' }) { + const commands = []; + const handlers = createNetworkHandlers({ + lookup, + execute: async (...args) => { commands.push(args); return response; }, + logger: () => ({ debug() {}, error() {} }), + }); + return { handlers, commands }; +} + +test('HTTP and HTTPS execute the validated address once and preserve hostname/TLS identity', async () => { + for (const [scheme, port] of [['http', '80'], ['https', '443']]) { + let lookups = 0; + const { handlers, commands } = harness(async () => { + lookups++; + return [{ address: lookups === 1 ? publicV4 : '127.0.0.1' }, { address: publicV6 }]; + }); + assert.deepEqual(await handlers.curl_url({ url: `${scheme}://public.example/path` }), { status_code: 200, body: 'hello' }); + assert.equal(lookups, 1); + assert.equal(commands.length, 1); + const [command] = commands[0]; + assert.ok(command.startsWith('curl -q --globoff ')); + assert.ok(command.includes("--proto '=http,https' --noproxy '*'")); + assert.ok(command.includes(`--resolve 'public.example:${port}:${publicV4}'`)); + assert.ok(command.endsWith(`--url '${scheme}://public.example/path'`)); + assert.doesNotMatch(command, /--location|--follow|--insecure|(?:^| )-[Lk](?: |$)/); + } +}); + +test('pins custom ports and IPv6 DNS answers with bracketed addresses', async () => { + const { handlers, commands } = harness(resolver(publicV6)); + await handlers.curl_url({ url: 'https://public.example:8443/' }); + assert.ok(commands[0][0].includes(`--resolve 'public.example:8443:[${publicV6}]'`)); +}); + +test('public numeric URLs are already pinned and need no DNS/resolve entry', async () => { + const { handlers, commands } = harness(noLookup); + for (const url of [`https://${publicV4}/`, `https://[${publicV6}]/`]) { + assert.equal((await handlers.curl_url({ url })).status_code, 200); + assert.ok(commands.at(-1)[0].endsWith(`--url '${url}'`)); + assert.ok(!commands.at(-1)[0].includes('--resolve')); + } +}); + +test('denied destinations never reach execution in either handler', async () => { + const { handlers, commands } = harness(resolver(publicV4, '10.0.0.1')); + assert.ok((await handlers.curl_url({ url: 'https://public.example' })).error); + assert.ok((await handlers.check_port({ host: 'public.example', port: 22 })).error); + assert.ok((await handlers.curl_url({ url: 'file:///etc/hostname' })).error); + assert.equal(commands.length, 0); +}); + +test('check_port pins public answers and preserves the explicit localhost exception', async () => { + const { handlers, commands } = harness(resolver(publicV6), { output: 'OPEN' }); + assert.deepEqual(await handlers.check_port({ host: 'public.example', port: 22 }), { host: 'public.example', port: 22, status: 'open' }); + assert.equal(commands[0][0], `nc -6 -n -z -w 3 '${publicV6}' 22 2>&1 && echo "OPEN" || echo "CLOSED"`); + const local = harness(noLookup, { output: 'OPEN' }); + for (const params of [{ port: 80 }, { host: 'localhost', port: 80 }]) { + assert.equal((await local.handlers.check_port(params)).status, 'open'); + assert.ok(local.commands.at(-1)[0].startsWith("nc -4 -n -z -w 3 '127.0.0.1' 80")); + } + assert.ok((await local.handlers.check_port({ host: '127.0.0.1', port: 80 })).error); + assert.ok((await local.handlers.check_port({ host: '10.0.0.1', port: 80 })).error); + assert.ok((await local.handlers.check_port({ port: 65536 })).error); + assert.equal(local.commands.length, 2); +}); + +test('preserves method, headers, body escaping and execution errors', async () => { + const { handlers, commands } = harness(); + await handlers.curl_url({ url: 'https://public.example/{a,b}', method: 'POST', headers: { 'X-Test': "it's fine" }, body: "it's data" }); + assert.ok(commands[0][0].includes("-X 'POST'")); + assert.ok(commands[0][0].includes(`-H ${shellEscape("X-Test: it's fine")}`)); + assert.ok(commands[0][0].includes(`-d ${shellEscape("it's data")}`)); + const failed = harness(resolver(publicV4), { error: 'connection failed' }); + assert.deepEqual(await failed.handlers.curl_url({ url: 'https://public.example' }), { error: 'connection failed' }); +});