fix(security): close SSRF guard bypasses in ssrfGuard.js (#3714)
Closes four SSRF guard bypasses reported in #3714: - Block alternate IPv6 encodings (hex format, NAT64, IPv4-compatible, IPv4-mapped) by parsing to 16-bit groups - Normalize trailing dots on hostnames to prevent FQDN bypasses - Add assertPublicUrlResolved() with DNS resolution to block wildcard DNS domains resolving to private/metadata IPs - Add fetchPublic() to safely handle and validate HTTP redirects
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
import { buildSearchRequest } from "./callers.js";
|
import { buildSearchRequest } from "./callers.js";
|
||||||
import { normalizeSearchResponse } from "./normalizers.js";
|
import { normalizeSearchResponse } from "./normalizers.js";
|
||||||
import { handleChatSearch } from "./chatSearch.js";
|
import { handleChatSearch } from "./chatSearch.js";
|
||||||
|
import { fetchPublic } from "../../../src/shared/utils/ssrfGuard.js";
|
||||||
|
|
||||||
const GLOBAL_TIMEOUT_MS = 15000;
|
const GLOBAL_TIMEOUT_MS = 15000;
|
||||||
const NON_RETRIABLE = new Set([400, 401, 403, 404]);
|
const NON_RETRIABLE = new Set([400, 401, 403, 404]);
|
||||||
@@ -100,7 +101,7 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
|||||||
log?.info?.("SEARCH", `${provider.id} | "${params.query.slice(0, 80)}" | type=${params.searchType}`);
|
log?.info?.("SEARCH", `${provider.id} | "${params.query.slice(0, 80)}" | type=${params.searchType}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, { ...init, headers: sanitizeHeaders(init.headers), signal: controller.signal });
|
const resp = await fetchPublic(url, { ...init, headers: sanitizeHeaders(init.headers), signal: controller.signal });
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const errText = await resp.text().catch(() => "");
|
const errText = await resp.text().catch(() => "");
|
||||||
|
|||||||
@@ -1,4 +1,24 @@
|
|||||||
// SSRF guard: block internal/private/metadata targets for server-side fetch.
|
// SSRF guard: block internal/private/metadata targets for server-side fetch.
|
||||||
|
//
|
||||||
|
// Three layers, each closing a distinct bypass class documented in #3714:
|
||||||
|
// 1. assertPublicUrl - synchronous literal-IP/hostname checks (cheap, for
|
||||||
|
// immediate rejection of obviously-bad input at request-build time).
|
||||||
|
// 2. assertPublicUrlResolved - adds DNS resolution so a hostname that merely
|
||||||
|
// *resolves* to a private/loopback address (e.g. a
|
||||||
|
// nip.io/sslip.io wildcard-DNS domain, or an attacker's
|
||||||
|
// own domain pointed at 127.0.0.1) is also rejected.
|
||||||
|
// 3. fetchPublic - wraps fetch() with manual redirect handling so a
|
||||||
|
// validated public URL can't 30x its way to an
|
||||||
|
// internal target without the redirect target being
|
||||||
|
// re-validated through layer 2 first.
|
||||||
|
//
|
||||||
|
// Layer 1 alone previously had matching bugs, not just missing coverage: hostname
|
||||||
|
// checks ran on the raw string without normalizing a trailing dot ("localhost."),
|
||||||
|
// and the IPv6 check only recognized one textual representation of an IPv4-mapped
|
||||||
|
// address (dotted "::ffff:a.b.c.d") while Node/WHATWG URL parsing can normalize the
|
||||||
|
// same address to hex form ("::ffff:7f00:1") — a mismatch, not an oversight.
|
||||||
|
|
||||||
|
import dns from "node:dns";
|
||||||
|
|
||||||
const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
|
const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
|
||||||
const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"];
|
const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"];
|
||||||
@@ -21,36 +41,175 @@ function ipv4ToInt(host) {
|
|||||||
const BLOCKED_V4_RANGES = [
|
const BLOCKED_V4_RANGES = [
|
||||||
[ipv4ToInt("0.0.0.0"), 8],
|
[ipv4ToInt("0.0.0.0"), 8],
|
||||||
[ipv4ToInt("10.0.0.0"), 8],
|
[ipv4ToInt("10.0.0.0"), 8],
|
||||||
|
[ipv4ToInt("100.64.0.0"), 10], // CGNAT — also used by some cloud metadata proxies
|
||||||
[ipv4ToInt("127.0.0.0"), 8],
|
[ipv4ToInt("127.0.0.0"), 8],
|
||||||
[ipv4ToInt("169.254.0.0"), 16],
|
[ipv4ToInt("169.254.0.0"), 16], // includes 169.254.169.254 cloud metadata
|
||||||
[ipv4ToInt("172.16.0.0"), 12],
|
[ipv4ToInt("172.16.0.0"), 12],
|
||||||
[ipv4ToInt("192.168.0.0"), 16],
|
[ipv4ToInt("192.168.0.0"), 16],
|
||||||
];
|
];
|
||||||
|
|
||||||
function isBlockedIpv4(host) {
|
function isBlockedIpv4Int(ip) {
|
||||||
const ip = ipv4ToInt(host);
|
|
||||||
if (ip === null) return false;
|
|
||||||
return BLOCKED_V4_RANGES.some(([base, bits]) => {
|
return BLOCKED_V4_RANGES.some(([base, bits]) => {
|
||||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
||||||
return (ip & mask) === (base & mask);
|
return (ip & mask) === (base & mask);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBlockedIpv6(host) {
|
function isBlockedIpv4(host) {
|
||||||
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
const ip = ipv4ToInt(host);
|
||||||
const v4Mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
if (ip === null) return false;
|
||||||
if (v4Mapped) return isBlockedIpv4(v4Mapped[1]);
|
return isBlockedIpv4Int(ip);
|
||||||
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.
|
function parseHextets(s) {
|
||||||
|
if (s === "") return [];
|
||||||
|
const segs = s.split(":");
|
||||||
|
const out = [];
|
||||||
|
for (const seg of segs) {
|
||||||
|
if (!/^[0-9a-f]{1,4}$/.test(seg)) return null;
|
||||||
|
out.push(parseInt(seg, 16));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse any textual IPv6 representation (including an embedded dotted-IPv4 tail,
|
||||||
|
// "::" compression in any position, and full/partial forms) into 8 16-bit groups.
|
||||||
|
// Returns null if the string isn't a valid IPv6 literal. Parsing into groups once
|
||||||
|
// and reasoning about the numeric value — rather than pattern-matching the source
|
||||||
|
// string — is what makes this immune to "which textual form did the URL parser
|
||||||
|
// pick" bugs: "::ffff:127.0.0.1" and "::ffff:7f00:1" produce identical groups.
|
||||||
|
function parseIPv6ToGroups(rawHost) {
|
||||||
|
let host = rawHost.toLowerCase();
|
||||||
|
|
||||||
|
const v4TailMatch = host.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||||
|
let v4Groups = null;
|
||||||
|
if (v4TailMatch) {
|
||||||
|
const v4Int = ipv4ToInt(v4TailMatch[1]);
|
||||||
|
if (v4Int === null) return null;
|
||||||
|
v4Groups = [(v4Int >>> 16) & 0xffff, v4Int & 0xffff];
|
||||||
|
host = host.slice(0, host.length - v4TailMatch[1].length);
|
||||||
|
if (host.endsWith("::")) {
|
||||||
|
// "::" compression marker itself — leave both colons, the removed IPv4
|
||||||
|
// fills the gap it represents.
|
||||||
|
} else if (host.endsWith(":")) {
|
||||||
|
host = host.slice(0, -1); // was just the "prevgroup:ipv4" separator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doubleColonParts = host.split("::");
|
||||||
|
if (doubleColonParts.length > 2) return null;
|
||||||
|
|
||||||
|
let groups;
|
||||||
|
if (doubleColonParts.length === 2) {
|
||||||
|
const head = parseHextets(doubleColonParts[0]);
|
||||||
|
const tail = parseHextets(doubleColonParts[1]);
|
||||||
|
if (head === null || tail === null) return null;
|
||||||
|
const v4Len = v4Groups ? v4Groups.length : 0;
|
||||||
|
const missing = 8 - head.length - tail.length - v4Len;
|
||||||
|
if (missing < 0) return null;
|
||||||
|
groups = [...head, ...new Array(missing).fill(0), ...tail, ...(v4Groups || [])];
|
||||||
|
} else {
|
||||||
|
const all = parseHextets(host);
|
||||||
|
if (all === null) return null;
|
||||||
|
groups = [...all, ...(v4Groups || [])];
|
||||||
|
}
|
||||||
|
return groups.length === 8 ? groups : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv6Groups(g) {
|
||||||
|
const isZero = (n) => g[n] === 0;
|
||||||
|
// loopback ::1
|
||||||
|
if ([0, 1, 2, 3, 4, 5, 6].every(isZero) && g[7] === 1) return true;
|
||||||
|
// unspecified ::
|
||||||
|
if (g.every((x) => x === 0)) return true;
|
||||||
|
// link-local fe80::/10
|
||||||
|
if ((g[0] & 0xffc0) === 0xfe80) return true;
|
||||||
|
// unique local fc00::/7
|
||||||
|
if ((g[0] & 0xfe00) === 0xfc00) return true;
|
||||||
|
// IPv4-mapped ::ffff:0:0/96 (0:0:0:0:0:ffff:a.b.c.d — the 0xffff marker is
|
||||||
|
// group index 5) and NAT64 well-known prefix 64:ff9b::/96 — both embed a
|
||||||
|
// real IPv4 address in the low 32 bits; check it against the same IPv4
|
||||||
|
// blocklist regardless of which prefix wraps it.
|
||||||
|
const low32 = ((g[6] << 16) | g[7]) >>> 0;
|
||||||
|
if ([0, 1, 2, 3, 4].every(isZero) && g[5] === 0xffff) return isBlockedIpv4Int(low32);
|
||||||
|
if (g[0] === 0x0064 && g[1] === 0xff9b && [2, 3, 4, 5].every(isZero)) return isBlockedIpv4Int(low32);
|
||||||
|
// IPv4-compatible ::a.b.c.d/96 (deprecated, still parseable) — excludes :: and ::1
|
||||||
|
// which already matched above.
|
||||||
|
if ([0, 1, 2, 3, 4, 5].every(isZero) && low32 !== 0 && low32 !== 1) return isBlockedIpv4Int(low32);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHost(hostname) {
|
||||||
|
// A trailing dot marks an FQDN and is semantically insignificant
|
||||||
|
// ("localhost." and "localhost" are the same host) but was being compared
|
||||||
|
// as a literal character, letting it slip past every string-based check.
|
||||||
|
return hostname.toLowerCase().replace(/\.+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedHost(host) {
|
||||||
|
if (BLOCKED_HOSTNAMES.has(host)) return true;
|
||||||
|
if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) return true;
|
||||||
|
if (isBlockedIpv4(host)) return true;
|
||||||
|
if (host.includes(":")) {
|
||||||
|
const groups = parseIPv6ToGroups(host.replace(/^\[|\]$/g, ""));
|
||||||
|
if (groups && isBlockedIpv6Groups(groups)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw if URL targets a non-public host by literal hostname/IP alone (no DNS
|
||||||
|
// resolution — see assertPublicUrlResolved for that). Caller should map to 400.
|
||||||
export function assertPublicUrl(rawUrl) {
|
export function assertPublicUrl(rawUrl) {
|
||||||
const parsed = new URL(rawUrl);
|
const parsed = new URL(rawUrl);
|
||||||
const host = parsed.hostname.toLowerCase();
|
const host = normalizeHost(parsed.hostname);
|
||||||
|
if (isBlockedHost(host)) throw new Error("Blocked URL: internal host");
|
||||||
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");
|
// Async: assertPublicUrl plus DNS resolution of non-literal hostnames, so a
|
||||||
if (host.includes(":") && isBlockedIpv6(host)) throw new Error("Blocked URL: private IP");
|
// domain that merely *resolves* to a private/loopback/metadata address (wildcard-DNS
|
||||||
|
// services like nip.io/sslip.io, or an attacker-controlled domain with an A record
|
||||||
|
// pointed at 127.0.0.1) is rejected too, not just IPs typed directly into the URL.
|
||||||
|
export async function assertPublicUrlResolved(rawUrl) {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
const host = normalizeHost(parsed.hostname);
|
||||||
|
if (isBlockedHost(host)) throw new Error("Blocked URL: internal host");
|
||||||
|
|
||||||
|
// Already a literal IPv4/IPv6 address — isBlockedHost above already covered it,
|
||||||
|
// no DNS lookup applies (and dns.lookup would just echo it back anyway).
|
||||||
|
const bracketless = host.replace(/^\[|\]$/g, "");
|
||||||
|
if (ipv4ToInt(bracketless) !== null || bracketless.includes(":")) return;
|
||||||
|
|
||||||
|
let addresses;
|
||||||
|
try {
|
||||||
|
addresses = await dns.promises.lookup(host, { all: true, verbatim: true });
|
||||||
|
} catch {
|
||||||
|
// Resolution failure isn't an SSRF signal by itself — let the subsequent
|
||||||
|
// fetch() fail with its own (clearer) network error.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { address, family } of addresses) {
|
||||||
|
if (family === 4 ? isBlockedIpv4(address) : isBlockedIpv6Groups(parseIPv6ToGroups(address) || [])) {
|
||||||
|
throw new Error("Blocked URL: hostname resolves to an internal host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch() with SSRF-safe manual redirect handling: each hop's target is
|
||||||
|
// re-validated through assertPublicUrlResolved before being followed, so a
|
||||||
|
// validated public URL can't 30x its way to an internal target. Bounded to
|
||||||
|
// maxRedirects hops (fetch's own default following behavior has no bound
|
||||||
|
// relevant here since we never let it auto-follow).
|
||||||
|
export async function fetchPublic(url, init = {}, { maxRedirects = 5 } = {}) {
|
||||||
|
await assertPublicUrlResolved(url);
|
||||||
|
let currentUrl = url;
|
||||||
|
for (let hop = 0; ; hop++) {
|
||||||
|
const res = await fetch(currentUrl, { ...init, redirect: "manual" });
|
||||||
|
const isRedirect = res.status >= 300 && res.status < 400;
|
||||||
|
const location = isRedirect ? res.headers.get("location") : null;
|
||||||
|
if (!location) return res;
|
||||||
|
if (hop >= maxRedirects) throw new Error("Blocked URL: too many redirects");
|
||||||
|
const nextUrl = new URL(location, currentUrl).toString();
|
||||||
|
await assertPublicUrlResolved(nextUrl);
|
||||||
|
currentUrl = nextUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
|||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||||
import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js";
|
import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js";
|
||||||
import { assertPublicUrl } from "@/shared/utils/ssrfGuard.js";
|
import { assertPublicUrlResolved } from "@/shared/utils/ssrfGuard.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle web fetch (URL extraction) request for the SSE/Next.js server.
|
* Handle web fetch (URL extraction) request for the SSE/Next.js server.
|
||||||
@@ -79,9 +79,10 @@ export async function handleFetch(request) {
|
|||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid URL format");
|
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid URL format");
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF guard: reject internal/private/metadata targets
|
// SSRF guard: reject internal/private/metadata targets, including
|
||||||
|
// hostnames that merely resolve to one (DNS lookup, not just literal checks).
|
||||||
try {
|
try {
|
||||||
assertPublicUrl(targetUrl);
|
await assertPublicUrlResolved(targetUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn("FETCH", "Blocked URL", { url: targetUrl });
|
log.warn("FETCH", "Blocked URL", { url: targetUrl });
|
||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, err.message);
|
return errorResponse(HTTP_STATUS.BAD_REQUEST, err.message);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ vi.mock("@/sse/utils/logger.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/shared/utils/ssrfGuard.js", () => ({
|
vi.mock("@/shared/utils/ssrfGuard.js", () => ({
|
||||||
assertPublicUrl: vi.fn(),
|
assertPublicUrlResolved: vi.fn(async () => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { handleFetch } from "@/sse/handlers/fetch.js";
|
import { handleFetch } from "@/sse/handlers/fetch.js";
|
||||||
|
|||||||
147
tests/unit/ssrf-guard-hardening.test.js
Normal file
147
tests/unit/ssrf-guard-hardening.test.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Regression coverage for #3714: the SSRF guard's literal-hostname/IP checks
|
||||||
|
// matched specific textual representations rather than the underlying address,
|
||||||
|
// so a different (but equivalent) representation slipped through. Each case
|
||||||
|
// below is a bypass the issue reported, or one found while fixing it.
|
||||||
|
|
||||||
|
const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() }));
|
||||||
|
vi.mock("node:dns", () => ({
|
||||||
|
default: { promises: { lookup: lookupMock } },
|
||||||
|
promises: { lookup: lookupMock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { assertPublicUrl, assertPublicUrlResolved, fetchPublic } = await import("../../src/shared/utils/ssrfGuard.js");
|
||||||
|
|
||||||
|
describe("assertPublicUrl: literal hostname/IP bypasses from #3714", () => {
|
||||||
|
it("blocks a trailing-dot FQDN the same as the bare hostname", () => {
|
||||||
|
expect(() => assertPublicUrl("http://localhost/")).toThrow();
|
||||||
|
expect(() => assertPublicUrl("http://localhost./")).toThrow();
|
||||||
|
expect(() => assertPublicUrl("http://LOCALHOST./")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks IPv4-mapped IPv6 loopback regardless of which textual form the URL parser picks", () => {
|
||||||
|
// WHATWG URL parsing normalizes dotted-decimal IPv4-in-IPv6 to hex form —
|
||||||
|
// the original regex only matched the dotted form.
|
||||||
|
expect(() => assertPublicUrl("http://[::ffff:127.0.0.1]/")).toThrow();
|
||||||
|
expect(() => assertPublicUrl("http://[::ffff:7f00:1]/")).toThrow(); // hex form directly
|
||||||
|
expect(() => assertPublicUrl("http://[0000::ffff:127.0.0.1]/")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks IPv4-mapped IPv6 cloud metadata address (169.254.169.254)", () => {
|
||||||
|
expect(() => assertPublicUrl("http://[::ffff:169.254.169.254]/")).toThrow();
|
||||||
|
expect(() => assertPublicUrl("http://[::ffff:a9fe:a9fe]/")).toThrow(); // hex form
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks other loopback/private/link-local/ULA IPv6 forms", () => {
|
||||||
|
for (const url of [
|
||||||
|
"http://[::1]/",
|
||||||
|
"http://[::127.0.0.1]/",
|
||||||
|
"http://[fe80::1]/",
|
||||||
|
"http://[fc00::1]/",
|
||||||
|
"http://[fd12:3456::1]/",
|
||||||
|
"http://[64:ff9b::127.0.0.1]/", // NAT64 well-known prefix embedding a private IPv4
|
||||||
|
]) {
|
||||||
|
expect(() => assertPublicUrl(url), url).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks alternate IPv4 literal encodings (already normalized by the URL parser)", () => {
|
||||||
|
for (const url of ["http://127.1/", "http://0177.0.0.1/", "http://2130706433/", "http://0x7f.0.0.1/"]) {
|
||||||
|
expect(() => assertPublicUrl(url), url).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still allows public hosts, including public IPv6", () => {
|
||||||
|
expect(() => assertPublicUrl("https://api.openai.com/v1/models")).not.toThrow();
|
||||||
|
expect(() => assertPublicUrl("http://8.8.8.8/")).not.toThrow();
|
||||||
|
expect(() => assertPublicUrl("https://[2001:4860:4860::8888]/")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertPublicUrlResolved: DNS-resolving hostname bypass from #3714", () => {
|
||||||
|
beforeEach(() => lookupMock.mockReset());
|
||||||
|
|
||||||
|
it("blocks a hostname that resolves to a loopback address (nip.io-style wildcard DNS)", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]);
|
||||||
|
await expect(assertPublicUrlResolved("http://127.0.0.1.nip.io/")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a hostname that resolves to a private range even if one of several addresses is public", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "203.0.113.5", family: 4 }, { address: "10.0.0.5", family: 4 }]);
|
||||||
|
await expect(assertPublicUrlResolved("http://multi-a-record.example.test/")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a hostname that resolves to a blocked IPv6 address", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "::1", family: 6 }]);
|
||||||
|
await expect(assertPublicUrlResolved("http://evil.example.test/")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a hostname that resolves only to public addresses", async () => {
|
||||||
|
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||||
|
await expect(assertPublicUrlResolved("https://example.com/")).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: "fails open when the DNS lookup itself rejects" is deliberately not
|
||||||
|
// covered here as a vitest case — a mocked node:dns rejection in this file
|
||||||
|
// trips what looks like a vitest 4 / rolldown-transform source-map bug
|
||||||
|
// (the same rejection pattern passes in an isolated single-function probe
|
||||||
|
// module; only reproduces once mocked against this larger file). Verified
|
||||||
|
// instead with a standalone Node script exercising the real try/catch
|
||||||
|
// directly: dns.promises.lookup rejecting resolves assertPublicUrlResolved
|
||||||
|
// with undefined rather than propagating, exactly as the source shows.
|
||||||
|
|
||||||
|
it("skips DNS lookup entirely for literal IP hosts (already covered by the sync check)", async () => {
|
||||||
|
await expect(assertPublicUrlResolved("http://127.0.0.1/")).rejects.toThrow();
|
||||||
|
expect(lookupMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchPublic: redirect-target re-validation from #3714", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
afterEach(() => { global.fetch = originalFetch; });
|
||||||
|
beforeEach(() => {
|
||||||
|
lookupMock.mockReset();
|
||||||
|
// These tests exercise redirect-chasing, not DNS behavior — give every
|
||||||
|
// synthetic *.example.test hostname a default public resolution so it
|
||||||
|
// doesn't get blocked (or throw on an unmocked undefined return) before
|
||||||
|
// reaching the redirect logic under test.
|
||||||
|
lookupMock.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a redirect from a validated public URL to an internal target", async () => {
|
||||||
|
global.fetch = vi.fn(async () => new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: "http://127.0.0.1:9999/admin" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(fetchPublic("https://public.example.test/redirect")).rejects.toThrow();
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1); // never followed the redirect
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a redirect chain of public URLs, re-validating each hop", async () => {
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { Location: "https://hop2.example.test/" } }))
|
||||||
|
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||||
|
|
||||||
|
const res = await fetchPublic("https://hop1.example.test/");
|
||||||
|
expect(await res.text()).toBe("ok");
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(global.fetch.mock.calls[1][0]).toBe("https://hop2.example.test/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds the redirect chain instead of looping forever", async () => {
|
||||||
|
global.fetch = vi.fn(async (url) => new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: url === "https://loop.example.test/a" ? "https://loop.example.test/b" : "https://loop.example.test/a" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(fetchPublic("https://loop.example.test/a", {}, { maxRedirects: 3 })).rejects.toThrow(/too many redirects/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the initial URL before ever calling fetch", async () => {
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
await expect(fetchPublic("http://127.0.0.1/steal")).rejects.toThrow();
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user