diff --git a/open-sse/handlers/search/index.js b/open-sse/handlers/search/index.js index 662b293e..70c3c749 100644 --- a/open-sse/handlers/search/index.js +++ b/open-sse/handlers/search/index.js @@ -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(() => ""); diff --git a/src/shared/utils/ssrfGuard.js b/src/shared/utils/ssrfGuard.js index 40336acf..b02a02a9 100644 --- a/src/shared/utils/ssrfGuard.js +++ b/src/shared/utils/ssrfGuard.js @@ -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; + } } diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js index 0005095c..509656fd 100644 --- a/src/sse/handlers/fetch.js +++ b/src/sse/handlers/fetch.js @@ -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); diff --git a/tests/unit/fetch-success-clears-account.test.js b/tests/unit/fetch-success-clears-account.test.js index eb8aa267..2dc0ad19 100644 --- a/tests/unit/fetch-success-clears-account.test.js +++ b/tests/unit/fetch-success-clears-account.test.js @@ -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"; diff --git a/tests/unit/ssrf-guard-hardening.test.js b/tests/unit/ssrf-guard-hardening.test.js new file mode 100644 index 00000000..840f3934 --- /dev/null +++ b/tests/unit/ssrf-guard-hardening.test.js @@ -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(); + }); +});