diff --git a/apps/web/lib/url-guard.test.ts b/apps/web/lib/url-guard.test.ts index 8d3fda4..afe4e73 100644 --- a/apps/web/lib/url-guard.test.ts +++ b/apps/web/lib/url-guard.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect } from "vitest"; -import { validateExternalUrl, domainOf } from "./url-guard"; +import { ipv4FromMappedIPv6, validateExternalUrl, domainOf } from "./url-guard"; + +describe("ipv4FromMappedIPv6", () => { + it("converts dotted-quad IPv4-mapped IPv6 literals", () => { + expect(ipv4FromMappedIPv6("::ffff:192.168.1.1")).toBe("192.168.1.1"); + expect(ipv4FromMappedIPv6("::ffff:0:169.254.169.254")).toBe("169.254.169.254"); + }); + it("rejects dotted-quad octets outside the IPv4 range", () => { + expect(ipv4FromMappedIPv6("::ffff:256.0.0.1")).toBe(null); + }); +}); describe("validateExternalUrl (SSRF guard)", () => { it("allows public https URLs", () => { @@ -8,6 +18,11 @@ describe("validateExternalUrl (SSRF guard)", () => { it("blocks localhost", () => { expect(validateExternalUrl("http://localhost/x").ok).toBe(false); }); + it("blocks internal hostnames with DNS root dots", () => { + expect(validateExternalUrl("http://localhost./x").ok).toBe(false); + expect(validateExternalUrl("http://app.localhost./x").ok).toBe(false); + expect(validateExternalUrl("http://metadata.google.internal./x").ok).toBe(false); + }); it("blocks private 10.x", () => { expect(validateExternalUrl("http://10.0.0.5/x").ok).toBe(false); }); diff --git a/apps/web/lib/url-guard.ts b/apps/web/lib/url-guard.ts index 7490554..de1d318 100644 --- a/apps/web/lib/url-guard.ts +++ b/apps/web/lib/url-guard.ts @@ -2,7 +2,14 @@ * Basic SSRF guard for user-provided media URLs. Blocks non-http(s) schemes and * obvious private / internal hosts. DNS-rebinding is out of scope for the MVP. */ -function ipv4FromMappedIPv6(host: string): string | null { +export function ipv4FromMappedIPv6(host: string): string | null { + const dotted = host.match(/^::ffff:(?:0:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/i); + if (dotted) { + const octets = dotted.slice(1).map(Number); + if (octets.some((octet) => octet > 255)) return null; + return octets.join("."); + } + const match = host.match(/^::ffff:(?:0:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); if (!match) return null; const high = Number.parseInt(match[1]!, 16); @@ -15,6 +22,7 @@ function isBlockedHost(host: string): boolean { const ipv4 = ipv4FromMappedIPv6(host) ?? host; return ( ipv4 === "localhost" || + ipv4.endsWith(".localhost") || ipv4 === "0.0.0.0" || ipv4 === "::1" || ipv4 === "::" || @@ -42,7 +50,7 @@ export function validateExternalUrl(raw: string): { ok: true; url: URL } | { ok: if (url.protocol !== "http:" && url.protocol !== "https:") { return { ok: false, error: "Only http and https URLs are allowed." }; } - const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1"); + const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.+$/, ""); if (isBlockedHost(host)) { return { ok: false, error: "That host is not allowed." }; }