Skip to content
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 84 additions & 0 deletions src/security/network-policy.js
Original file line number Diff line number Diff line change
@@ -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') };
}
68 changes: 30 additions & 38 deletions src/tools/network.js
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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 };
}

Expand All @@ -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)) {
Expand All @@ -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;

Expand Down Expand Up @@ -138,4 +128,6 @@ export const handlers = {
logger.debug('nginx_reload: successfully reloaded');
return { success: true, test_output: test.output };
},
};
});

export const handlers = createNetworkHandlers();
156 changes: 156 additions & 0 deletions test/network-policy.test.js
Original file line number Diff line number Diff line change
@@ -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' });
});