Files
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

112 lines
4.6 KiB
JavaScript

// @ts-check
// plugins/_net.mjs — SSRF egress validation for the plugin guarded fetch.
//
// Pure, side-effect-free import (no top-level work) so the engine's
// byte-identical-when-opted-out guarantee is preserved. Uses ONLY node builtins
// (node:dns, node:net) — NOT undici (Node's global fetch is powered by an
// INTERNAL undici that is not importable as the `undici` module in a zero-dep
// install, so we never depend on it).
//
// Honest scope: this resolves a hostname and rejects private/loopback/link-
// local/metadata destinations on EVERY redirect hop. It binds an HONEST plugin
// that routes through ctx.fetch. It is tamper-evidence + a footgun guard, NOT
// containment against malicious code (which can call node:net directly). A
// residual DNS-rebinding window exists because global fetch re-resolves at
// connect time; documented plainly in plugins/README.md (no overclaim).
import { isIP } from 'node:net';
import { lookup as dnsLookup } from 'node:dns/promises';
/**
* Is an IP address in a range a plugin must never reach (SSRF targets)?
* Rejects loopback, RFC1918 private, link-local + cloud metadata (169.254.x,
* incl. 169.254.169.254), CGNAT (100.64/10), unspecified, IPv6 ULA/link-local,
* and IPv4-mapped-IPv6 of all the above.
* @param {string} ip
* @returns {boolean}
*/
export function isBlockedIp(ip) {
const fam = isIP(ip);
if (fam === 0) return true; // not a valid IP → reject (fail-closed)
if (fam === 4) return isBlockedV4(ip);
// IPv6
const lower = ip.toLowerCase();
if (lower === '::1' || lower === '::') return true; // loopback / unspecified
if (lower.startsWith('fe80:')) return true; // link-local
if (/^f[cd][0-9a-f][0-9a-f]:/.test(lower)) return true; // fc00::/7 ULA
// IPv4-mapped (::ffff:a.b.c.d) → validate the embedded v4
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return isBlockedV4(mapped[1]);
// ::ffff:hex:hex form
const mappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (mappedHex) {
const a = parseInt(mappedHex[1], 16), b = parseInt(mappedHex[2], 16);
return isBlockedV4([(a >> 8) & 255, a & 255, (b >> 8) & 255, b & 255].join('.'));
}
return false;
}
function isBlockedV4(ip) {
const o = ip.split('.').map(Number);
if (o.length !== 4 || o.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return true;
const [a, b] = o;
if (a === 0) return true; // 0.0.0.0/8
if (a === 10) return true; // 10/8 private
if (a === 127) return true; // loopback
if (a === 169 && b === 254) return true; // link-local + 169.254.169.254 metadata
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12 private
if (a === 192 && b === 168) return true; // 192.168/16 private
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 CGNAT
if (a === 192 && b === 0 && o[2] === 0) return true; // 192.0.0/24
if (a >= 224) return true; // multicast / reserved
return false;
}
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
/**
* Resolve a hostname and reject if ANY resolved address is blocked. Returns the
* validated addresses. Throws on a blocked or unresolvable host.
* @param {string} hostname
* @param {{ allowsLocalhost?: boolean }} [opts]
* @returns {Promise<string[]>}
*/
export async function resolveAndValidate(hostname, { allowsLocalhost = false } = {}) {
// An IP literal host: validate directly (no DNS).
if (isIP(hostname)) {
if (isBlockedIp(hostname)) {
if (allowsLocalhost && isLoopbackLiteral(hostname)) return [hostname];
throw new Error(`plugin egress to ${hostname} is blocked (private/loopback/metadata range)`);
}
return [hostname];
}
if (allowsLocalhost && LOOPBACK_HOSTS.has(hostname.toLowerCase())) {
// Local-AI providers (Ollama/LM Studio). Resolve but allow loopback through.
return ['127.0.0.1'];
}
let addrs;
try {
addrs = await dnsLookup(hostname, { all: true });
} catch (err) {
throw new Error(`plugin egress: cannot resolve ${hostname}${err.message}`);
}
if (!addrs.length) throw new Error(`plugin egress: ${hostname} resolved to no addresses`);
for (const { address } of addrs) {
if (isBlockedIp(address)) {
if (allowsLocalhost && isLoopbackLiteral(address)) continue;
throw new Error(`plugin egress: ${hostname} resolves to a blocked address (${address}) — possible SSRF/rebinding`);
}
}
return addrs.map(a => a.address);
}
function isLoopbackLiteral(ip) {
if (ip === '::1') return true;
if (isIP(ip) === 4) return ip.split('.')[0] === '127';
return false;
}