diff --git a/apps/api/src/lib/ssrf.ts b/apps/api/src/lib/ssrf.ts new file mode 100644 index 00000000..1772e149 --- /dev/null +++ b/apps/api/src/lib/ssrf.ts @@ -0,0 +1,90 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +function isPrivateIPv4(ip: string): boolean { + const parts = ip.split(".").map(Number); + if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return false; + const [a, b] = parts; + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 0) return true; + return false; +} + +function isPrivateIPv6(ip: string): boolean { + const normalized = ip.replace(/^\[|]$/g, ""); + if (normalized === "::1") return true; + if (normalized.startsWith("fe80:")) return true; + if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; + if (normalized.includes("::ffff:")) { + const v4 = normalized.split("::ffff:")[1]; + if (v4 && isPrivateIPv4(v4)) return true; + } + return false; +} + +async function resolveAndCheck(hostname: string): Promise { + const bare = hostname.replace(/^\[|]$/g, ""); + if (isIP(bare)) { + if (isPrivateIPv4(bare) || isPrivateIPv6(bare)) { + throw new Error("URL resolves to a private or reserved IP address"); + } + return; + } + + const result = await lookup(hostname, { all: true }); + const addresses = Array.isArray(result) ? result : [result]; + for (const entry of addresses) { + const addr = entry.address; + if (isPrivateIPv4(addr) || isPrivateIPv6(addr)) { + throw new Error("URL resolves to a private or reserved IP address"); + } + } +} + +export async function validateFetchUrl(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error("Invalid URL"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Only HTTP and HTTPS URLs are supported"); + } + + await resolveAndCheck(parsed.hostname); +} + +export const MAX_REDIRECTS = 5; +export const FETCH_TIMEOUT_MS = 30_000; +export const MAX_URL_FETCH_SIZE = 50 * 1024 * 1024; +export const MAX_URLS_PER_REQUEST = 50; +export const URL_FETCH_CONCURRENCY = 4; + +export async function safeFetch(url: string, signal?: AbortSignal): Promise { + let currentUrl = url; + for (let i = 0; i <= MAX_REDIRECTS; i++) { + await validateFetchUrl(currentUrl); + const res = await fetch(currentUrl, { + signal, + redirect: "manual", + headers: { "User-Agent": "SnapOtter/1.0 (image-fetch)" }, + }); + + if (res.status >= 300 && res.status < 400) { + const location = res.headers.get("location"); + if (!location) throw new Error("Redirect without Location header"); + currentUrl = new URL(location, currentUrl).href; + if (i === MAX_REDIRECTS) throw new Error("Too many redirects"); + continue; + } + + return res; + } + throw new Error("Too many redirects"); +} diff --git a/tests/unit/api/ssrf.test.ts b/tests/unit/api/ssrf.test.ts new file mode 100644 index 00000000..9ae971ca --- /dev/null +++ b/tests/unit/api/ssrf.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { validateFetchUrl } from "../../../apps/api/src/lib/ssrf.js"; + +describe("validateFetchUrl", () => { + it("allows valid public HTTP URL", async () => { + await expect( + validateFetchUrl("https://images.unsplash.com/photo.jpg"), + ).resolves.toBeUndefined(); + }); + + it("allows valid public HTTP URL without TLS", async () => { + await expect(validateFetchUrl("http://example.com/image.png")).resolves.toBeUndefined(); + }); + + it("rejects non-HTTP schemes", async () => { + await expect(validateFetchUrl("ftp://example.com/image.jpg")).rejects.toThrow( + "Only HTTP and HTTPS", + ); + await expect(validateFetchUrl("file:///etc/passwd")).rejects.toThrow("Only HTTP and HTTPS"); + await expect(validateFetchUrl("data:image/png;base64,abc")).rejects.toThrow( + "Only HTTP and HTTPS", + ); + }); + + it("rejects localhost and loopback", async () => { + await expect(validateFetchUrl("http://127.0.0.1/image.jpg")).rejects.toThrow("private"); + await expect(validateFetchUrl("http://localhost/image.jpg")).rejects.toThrow("private"); + await expect(validateFetchUrl("http://[::1]/image.jpg")).rejects.toThrow("private"); + }); + + it("rejects private network ranges", async () => { + await expect(validateFetchUrl("http://10.0.0.1/image.jpg")).rejects.toThrow("private"); + await expect(validateFetchUrl("http://172.16.0.1/image.jpg")).rejects.toThrow("private"); + await expect(validateFetchUrl("http://192.168.1.1/image.jpg")).rejects.toThrow("private"); + }); + + it("rejects link-local addresses", async () => { + await expect(validateFetchUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow( + "private", + ); + }); + + it("rejects invalid URLs", async () => { + await expect(validateFetchUrl("not-a-url")).rejects.toThrow(); + await expect(validateFetchUrl("")).rejects.toThrow(); + }); +});