From 485a2d72d370a80624b36daf2cca496a7e84ca05 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:29:58 +0800 Subject: [PATCH] fix: harden SSRF validation with missing IP ranges and safeFetch tests --- apps/api/src/lib/ssrf.ts | 10 +++- tests/unit/api/ssrf.test.ts | 94 ++++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/apps/api/src/lib/ssrf.ts b/apps/api/src/lib/ssrf.ts index 1772e149..e49a5c93 100644 --- a/apps/api/src/lib/ssrf.ts +++ b/apps/api/src/lib/ssrf.ts @@ -11,14 +11,20 @@ function isPrivateIPv4(ip: string): boolean { if (a === 127) return true; if (a === 169 && b === 254) return true; if (a === 0) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 192 && b === 0 && parts[2] === 0) return true; + if (a === 198 && (b === 18 || b === 19)) return true; + if (a >= 240) return true; return false; } function isPrivateIPv6(ip: string): boolean { - const normalized = ip.replace(/^\[|]$/g, ""); + const normalized = ip.replace(/^\[|]$/g, "").toLowerCase(); if (normalized === "::1") return true; + if (normalized === "::") return true; if (normalized.startsWith("fe80:")) return true; if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; + if (normalized.startsWith("2001:db8:")) return true; if (normalized.includes("::ffff:")) { const v4 = normalized.split("::ffff:")[1]; if (v4 && isPrivateIPv4(v4)) return true; @@ -79,8 +85,8 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise= 300 && res.status < 400) { const location = res.headers.get("location"); if (!location) throw new Error("Redirect without Location header"); + await res.body?.cancel(); currentUrl = new URL(location, currentUrl).href; - if (i === MAX_REDIRECTS) throw new Error("Too many redirects"); continue; } diff --git a/tests/unit/api/ssrf.test.ts b/tests/unit/api/ssrf.test.ts index 9ae971ca..378f7f0d 100644 --- a/tests/unit/api/ssrf.test.ts +++ b/tests/unit/api/ssrf.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { validateFetchUrl } from "../../../apps/api/src/lib/ssrf.js"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; +import { MAX_REDIRECTS, safeFetch, validateFetchUrl } from "../../../apps/api/src/lib/ssrf.js"; describe("validateFetchUrl", () => { it("allows valid public HTTP URL", async () => { @@ -40,8 +40,98 @@ describe("validateFetchUrl", () => { ); }); + it("rejects CG-NAT range (100.64.0.0/10)", async () => { + await expect(validateFetchUrl("http://100.64.0.1/image.jpg")).rejects.toThrow("private"); + await expect(validateFetchUrl("http://100.127.255.255/image.jpg")).rejects.toThrow("private"); + }); + + it("rejects IETF protocol assignments (192.0.0.0/24)", async () => { + await expect(validateFetchUrl("http://192.0.0.1/image.jpg")).rejects.toThrow("private"); + }); + + it("rejects benchmarking range (198.18.0.0/15)", async () => { + await expect(validateFetchUrl("http://198.18.0.1/image.jpg")).rejects.toThrow("private"); + await expect(validateFetchUrl("http://198.19.255.255/image.jpg")).rejects.toThrow("private"); + }); + + it("rejects reserved/class E range (240.0.0.0/4)", async () => { + await expect(validateFetchUrl("http://240.0.0.1/image.jpg")).rejects.toThrow("private"); + await expect(validateFetchUrl("http://255.255.255.255/image.jpg")).rejects.toThrow("private"); + }); + + it("rejects IPv6 unspecified address", async () => { + await expect(validateFetchUrl("http://[::]/image.jpg")).rejects.toThrow("private"); + }); + + it("rejects IPv6 documentation range (2001:db8::/32)", async () => { + await expect(validateFetchUrl("http://[2001:db8::1]/image.jpg")).rejects.toThrow("private"); + await expect(validateFetchUrl("http://[2001:DB8::1]/image.jpg")).rejects.toThrow("private"); + }); + it("rejects invalid URLs", async () => { await expect(validateFetchUrl("not-a-url")).rejects.toThrow(); await expect(validateFetchUrl("")).rejects.toThrow(); }); }); + +describe("safeFetch", () => { + let mockFetch: Mock; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + function mockResponse(status: number, headers?: Record): Response { + return { + status, + headers: new Headers(headers), + body: { cancel: vi.fn() }, + } as unknown as Response; + } + + it("returns response for a direct (non-redirect) fetch", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(200)); + const res = await safeFetch("https://example.com/image.jpg"); + expect(res.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("follows a redirect chain within MAX_REDIRECTS", async () => { + // 3 redirects then a 200 + mockFetch + .mockResolvedValueOnce(mockResponse(302, { location: "https://example.com/hop1" })) + .mockResolvedValueOnce(mockResponse(301, { location: "https://example.com/hop2" })) + .mockResolvedValueOnce(mockResponse(307, { location: "https://example.com/final" })) + .mockResolvedValueOnce(mockResponse(200)); + + const res = await safeFetch("https://example.com/start"); + expect(res.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it("throws when redirect chain exceeds MAX_REDIRECTS", async () => { + // Return redirects for every call (MAX_REDIRECTS + 1 iterations, all redirects) + for (let i = 0; i <= MAX_REDIRECTS; i++) { + mockFetch.mockResolvedValueOnce( + mockResponse(302, { location: `https://example.com/hop${i + 1}` }), + ); + } + + await expect(safeFetch("https://example.com/start")).rejects.toThrow("Too many redirects"); + }); + + it("rejects a redirect to a private IP", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(302, { location: "http://127.0.0.1/evil" })); + + await expect(safeFetch("https://example.com/image.jpg")).rejects.toThrow("private"); + }); + + it("throws when redirect has no Location header", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(302)); + + await expect(safeFetch("https://example.com/image.jpg")).rejects.toThrow( + "Redirect without Location header", + ); + }); +});