diff --git a/lib/ssrf-protection.ts b/lib/ssrf-protection.ts index 8dbf2ca..25e5bb2 100644 --- a/lib/ssrf-protection.ts +++ b/lib/ssrf-protection.ts @@ -11,17 +11,38 @@ export function isPrivateUrl(urlString: string): boolean { const url = new URL(urlString) // Strip a trailing dot so FQDN forms like "localhost." (which still // resolve to 127.0.0.1) cannot bypass the equality checks below. - const hostname = url.hostname.toLowerCase().replace(/\.$/, "") + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, "") // Block localhost if ( hostname === "localhost" || hostname === "127.0.0.1" || - hostname === "::1" + hostname === "::1" || + hostname === "::" ) { return true } + // Block IPv6 unique-local (fc00::/7), link-local (fe80::/10), + // and IPv4-mapped (::ffff:0:0/96) hosts. + if (hostname.includes(":")) { + if ( + hostname.startsWith("fc") || + hostname.startsWith("fd") || + hostname.startsWith("::ffff:") + ) { + return true + } + const linkLocal = hostname.match(/^fe([0-9a-f]{2}):/) + if (linkLocal) { + const high = parseInt(linkLocal[1], 16) + if (high >= 0x80 && high <= 0xbf) return true + } + } + // Block AWS/cloud metadata endpoints if ( hostname === "169.254.169.254" || diff --git a/tests/unit/ssrf-protection.test.ts b/tests/unit/ssrf-protection.test.ts new file mode 100644 index 0000000..4a405eb --- /dev/null +++ b/tests/unit/ssrf-protection.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" +import { isPrivateUrl } from "@/lib/ssrf-protection" + +describe("isPrivateUrl", () => { + it("blocks private IPv6 URLs", () => { + expect(isPrivateUrl("http://[::1]/")).toBe(true) + expect(isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true) + expect(isPrivateUrl("http://[::]/")).toBe(true) + expect(isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true) + expect(isPrivateUrl("http://[fc00::1]/")).toBe(true) + expect(isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true) + expect(isPrivateUrl("http://[fe80::1]/")).toBe(true) + expect(isPrivateUrl("http://[fe9f::1]/")).toBe(true) + expect(isPrivateUrl("http://[febf::1]/")).toBe(true) + }) + + it("allows public URLs", () => { + expect(isPrivateUrl("https://example.com/article")).toBe(false) + expect(isPrivateUrl("https://fc00.example.com/article")).toBe(false) + }) +})