@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
210 lines (203 loc) • 7.17 kB
JavaScript
// 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);
}
};
}