UNPKG

@sellable/install

Version:

One-command installer for Sellable MCP in Claude Code, Codex, and Hermes

210 lines (203 loc) • 7.17 kB
// SSRF control for user-supplied endpoint URLs, shared by the Next server and // the agent machine. Node built-ins only. import { promises as dns } from "node:dns"; import https from "node:https"; import net from "node:net"; import { Readable } from "node:stream"; // IANA special-purpose registries (2025-10-09) plus metadata and CGNAT. There // is deliberately no ::ffff:0:0/96 row: a BlockList checks IPv4 addresses // against v4-mapped IPv6 rules, so that row would block every IPv4 answer. // v4-mapped answers are unwrapped and checked against the IPv4 rows instead. const V4 = [ ["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], ]; const V6 = [ ["::", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20], ["5f00::", 16], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ]; const blocked = new net.BlockList(); for (const [address, prefix] of V4) blocked.addSubnet(address, prefix, "ipv4"); for (const [address, prefix] of V6) blocked.addSubnet(address, prefix, "ipv6"); // The IPv4 address an IPv4-mapped answer carries (::ffff:a.b.c.d in any // spelling), so its check never rests on BlockList's v4-in-v6 matching. function mappedIPv4(ip) { let hostname; try { hostname = new URL(`http://[${ip}]`).hostname; } catch { return null; } const mapped = /^\[::ffff:([\da-f]{1,4}):([\da-f]{1,4})\]$/.exec(hostname); if (!mapped) return null; const [high, low] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)]; return [high >> 8, high & 255, low >> 8, low & 255].join("."); } export function isPublicAddress(ip) { const family = net.isIP(ip); if (family === 0) return false; const v4 = family === 4 ? ip : mappedIPv4(ip); return v4 ? !blocked.check(v4, "ipv4") : !blocked.check(ip, "ipv6"); } // Errors carry only the fixed code: never the URL, its credentials or headers. const refuse = (code) => Object.assign(new Error(code), { code }); export async function resolvePublicTarget( rawUrl, { lookup = (host) => dns.lookup(host, { all: true, verbatim: true }) } = {} ) { let url; try { url = new URL(rawUrl); } catch { throw refuse("URL_REJECTED"); } // url.port is "" for the default (443) port, however it was written. if ( url.protocol !== "https:" || url.port || url.username || url.password || url.search || url.hash ) { throw refuse("URL_REJECTED"); } const host = url.hostname.replace(/\.$/, "").toLowerCase(); if (net.isIP(host.replace(/^\[|\]$/g, ""))) throw refuse("IP_LITERAL"); if (/(^|\.)(localhost|local|internal|flycast|home\.arpa)$/.test(host)) { throw refuse("BLOCKED_NAME"); } const answers = await lookup(host); if (!answers.length || answers.some((a) => !isPublicAddress(a.address))) { throw refuse("NON_PUBLIC_ADDRESS"); } return { url, host, addresses: answers.map(({ address, family }) => ({ address, family })), }; } // A fetch that resolves and checks the target on every request, dials only // the vetted addresses (TLS SNI and certificate checks stay on the hostname) // and refuses every redirect. `request` is a test seam only. export function createPinnedFetch({ maxResponseBytes = 8 << 20, deadlineMs = 120_000, resolve = resolvePublicTarget, request = https.request, } = {}) { return async (input, init = {}) => { // deadlineMs bounds DNS, connect and response headers only. Once headers // arrive, only init.signal and the byte cap bound the body, so a long SSE // stream or provider call is never cut here. A plain setTimeout (not // AbortSignal.timeout) so it can be cleared. const deadline = new AbortController(); const expired = new Promise((_, reject) => { deadline.signal.addEventListener("abort", () => reject(deadline.signal.reason) ); }); const timer = setTimeout( () => deadline.abort(refuse("DEADLINE_EXCEEDED")), deadlineMs ); try { const target = await Promise.race([resolve(String(input)), expired]); // Both lookup shapes (Pitfall 2): net asks for { all: true } under // autoSelectFamily; refuse any hostname other than the vetted one. const lookup = (hostname, options, callback) => { if (hostname !== target.host) return callback(refuse("ENOTFOUND")); if (options?.all) return callback(null, target.addresses); const [first] = target.addresses; callback(null, first.address, first.family); }; const method = (init.method ?? "GET").toUpperCase(); const headers = init.headers instanceof Headers ? Object.fromEntries(init.headers) : init.headers; const dial = new Promise((resolveResponse, reject) => { const req = request( { hostname: target.host, servername: target.host, port: target.url.port || 443, path: target.url.pathname + target.url.search, method, headers, lookup, agent: false, signal: init.signal ? AbortSignal.any([init.signal, deadline.signal]) : deadline.signal, }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400) { res.destroy(); return reject(refuse("REDIRECT_REFUSED")); } let seen = 0; res.on("data", (chunk) => { if ((seen += chunk.length) > maxResponseBytes) { res.destroy(refuse("RESPONSE_TOO_LARGE")); } }); // An untrusted server can send what Response refuses (status 999): // reject, never throw out of this callback (that kills the process). try { const responseHeaders = new Headers(); for (const [name, value] of Object.entries(res.headers)) { for (const item of [].concat(value)) { responseHeaders.append(name, item); } } const nullBody = [204, 205].includes(res.statusCode) || method === "HEAD"; resolveResponse( new Response(nullBody ? null : Readable.toWeb(res), { status: res.statusCode, statusText: res.statusMessage, headers: responseHeaders, }) ); } catch (error) { res.destroy(); reject(error); } } ); req.on("error", reject); if (init.body != null) req.write(init.body); req.end(); }); return await Promise.race([dial, expired]); } finally { // Settles at response headers (or on any error): the deadline ends here. clearTimeout(timer); } }; }