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 { normalizeSearchResponse } from "./normalizers.js";
|
||||
import { handleChatSearch } from "./chatSearch.js";
|
||||
import { fetchPublic } from "../../../src/shared/utils/ssrfGuard.js";
|
||||
|
||||
const GLOBAL_TIMEOUT_MS = 15000;
|
||||
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}`);
|
||||
|
||||
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);
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => "");
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
// 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_SUFFIXES = [".internal", ".local", ".localhost"];
|
||||
@@ -21,36 +41,175 @@ function ipv4ToInt(host) {
|
||||
const BLOCKED_V4_RANGES = [
|
||||
[ipv4ToInt("0.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("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("192.168.0.0"), 16],
|
||||
];
|
||||
|
||||
function isBlockedIpv4(host) {
|
||||
const ip = ipv4ToInt(host);
|
||||
if (ip === null) return false;
|
||||
function isBlockedIpv4Int(ip) {
|
||||
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();
|
||||
const v4Mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4Mapped) return isBlockedIpv4(v4Mapped[1]);
|
||||
if (h === "::1" || h === "::") return true;
|
||||
return h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd");
|
||||
function isBlockedIpv4(host) {
|
||||
const ip = ipv4ToInt(host);
|
||||
if (ip === null) return false;
|
||||
return isBlockedIpv4Int(ip);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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");
|
||||
const host = normalizeHost(parsed.hostname);
|
||||
if (isBlockedHost(host)) throw new Error("Blocked URL: internal host");
|
||||
}
|
||||
|
||||
// Async: assertPublicUrl plus DNS resolution of non-literal hostnames, so a
|
||||
// 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 { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.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.
|
||||
@@ -79,9 +79,10 @@ export async function handleFetch(request) {
|
||||
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 {
|
||||
assertPublicUrl(targetUrl);
|
||||
await assertPublicUrlResolved(targetUrl);
|
||||
} catch (err) {
|
||||
log.warn("FETCH", "Blocked URL", { url: targetUrl });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, err.message);
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock("@/sse/utils/logger.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/ssrfGuard.js", () => ({
|
||||
assertPublicUrl: vi.fn(),
|
||||
assertPublicUrlResolved: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
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