9router / src /shared /utils /ssrfGuard.js
2api
feat: configurable stream stall timeout + per-provider UI
88c4c60
Raw
History Blame Contribute Delete
1.92 kB
// SSRF guard: block internal/private/metadata targets for server-side fetch.
const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"];
// Parse dotted IPv4 to 32-bit integer, or null if not a valid IPv4 literal.
function ipv4ToInt(host) {
const parts = host.split(".");
if (parts.length !== 4) return null;
let value = 0;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const octet = Number(part);
if (octet > 255) return null;
value = value * 256 + octet;
}
return value >>> 0;
}
// Private/reserved IPv4 ranges as [startInt, maskBits].
const BLOCKED_V4_RANGES = [
[ipv4ToInt("0.0.0.0"), 8],
[ipv4ToInt("10.0.0.0"), 8],
[ipv4ToInt("127.0.0.0"), 8],
[ipv4ToInt("169.254.0.0"), 16],
[ipv4ToInt("172.16.0.0"), 12],
[ipv4ToInt("192.168.0.0"), 16],
];
function isBlockedIpv4(host) {
const ip = ipv4ToInt(host);
if (ip === null) return false;
return BLOCKED_V4_RANGES.some(([base, bits]) => {
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (ip & mask) === (base & mask);
});
}
function isBlockedIpv6(host) {
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
if (h === "::1" || h === "::") return true;
return h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd");
}
// Throw if URL targets a non-public host. Caller should map to 400.
export function assertPublicUrl(rawUrl) {
const parsed = new URL(rawUrl);
const host = parsed.hostname.toLowerCase();
if (BLOCKED_HOSTNAMES.has(host)) throw new Error("Blocked URL: internal host");
if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) throw new Error("Blocked URL: internal host");
if (isBlockedIpv4(host)) throw new Error("Blocked URL: private IP");
if (host.includes(":") && isBlockedIpv6(host)) throw new Error("Blocked URL: private IP");
}