diff --git a/app/api/parse-url/route.ts b/app/api/parse-url/route.ts index 4dd6612..4001454 100644 --- a/app/api/parse-url/route.ts +++ b/app/api/parse-url/route.ts @@ -1,4 +1,4 @@ -import { extract } from "@extractus/article-extractor" +import { extractFromHtml } from "@extractus/article-extractor" import { NextResponse } from "next/server" import TurndownService from "turndown" import { isPrivateUrl } from "@/lib/ssrf-protection" @@ -31,21 +31,31 @@ export async function POST(req: Request) { // SSRF protection: parse-url has no use case for fetching internal // hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only // governs LLM provider baseUrl overrides (validate-model, chat). - if (isPrivateUrl(url)) { + if (await isPrivateUrl(url)) { return NextResponse.json( { error: "Cannot access private/internal URLs" }, { status: 400 }, ) } - const headController = new AbortController() - const headTimeout = setTimeout(() => headController.abort(), 3000) + // Fetch the page ourselves so we control redirect handling. The + // article-extractor library follows redirects internally and ignores a + // `redirect` option, which would let a public URL 302 to an internal + // host and bypass the SSRF check above. `redirect: "error"` rejects any + // redirect outright. + const controller = new AbortController() + const timeoutId = setTimeout(() => { + controller.abort() + }, EXTRACT_TIMEOUT_MS) + + let html: string try { - const headResponse = await fetch(url, { - method: "HEAD", + const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, - signal: headController.signal, + redirect: "error", + signal: controller.signal, }) - const contentType = headResponse.headers.get("content-type") + + const contentType = response.headers.get("content-type") if (contentType?.includes("application/pdf")) { return NextResponse.json( { @@ -54,27 +64,15 @@ export async function POST(req: Request) { { status: 422 }, ) } - } catch (err) { - console.warn( - "HEAD pre-check failed, proceeding with extraction:", - err, - ) - } finally { - clearTimeout(headTimeout) - } - // Extract article content with timeout to avoid tying up server resources - const controller = new AbortController() - const timeoutId = setTimeout(() => { - controller.abort() - }, EXTRACT_TIMEOUT_MS) + if (!response.ok) { + return NextResponse.json( + { error: "Could not fetch URL content" }, + { status: 400 }, + ) + } - let article - try { - article = await extract(url, undefined, { - headers: { "User-Agent": USER_AGENT }, - signal: controller.signal, - }) + html = await response.text() } catch (err: any) { if (err?.name === "AbortError") { return NextResponse.json( @@ -82,11 +80,18 @@ export async function POST(req: Request) { { status: 504 }, ) } - throw err + // Redirects are rejected with a TypeError ("failed to fetch" / + // "unexpected redirect") when redirect: "error" is set. + return NextResponse.json( + { error: "Could not fetch URL content" }, + { status: 400 }, + ) } finally { clearTimeout(timeoutId) } + const article = await extractFromHtml(html, url) + if (!article || !article.content) { return NextResponse.json( { error: "Could not extract content from URL" }, diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts index 1b40c48..6f3b5b9 100644 --- a/app/api/validate-model/route.ts +++ b/app/api/validate-model/route.ts @@ -56,7 +56,7 @@ export async function POST(req: Request) { } // SECURITY: Block SSRF attacks via custom baseUrl - if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) { + if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) { return NextResponse.json( { valid: false, error: "Invalid base URL" }, { status: 400 }, diff --git a/lib/ssrf-protection.ts b/lib/ssrf-protection.ts index 30593a6..f9fad85 100644 --- a/lib/ssrf-protection.ts +++ b/lib/ssrf-protection.ts @@ -2,80 +2,107 @@ * SSRF (Server-Side Request Forgery) protection utilities */ +import { lookup } from "node:dns/promises" + /** - * Check if URL points to private/internal network - * Blocks: localhost, private IPs, link-local, AWS metadata service + * Check if an IP address (IPv4 or IPv6) belongs to a private/internal range. + * Works for both user-supplied literal IPs and DNS-resolved addresses. */ -export function isPrivateUrl(urlString: string): boolean { +function isPrivateIp(ip: string): boolean { + const addr = ip.toLowerCase().replace(/^\[|\]$/g, "") + + // IPv6 + if (addr.includes(":")) { + if (addr === "::1" || addr === "::") return true + // unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96) + if ( + addr.startsWith("fc") || + addr.startsWith("fd") || + addr.startsWith("::ffff:") + ) { + return true + } + // link-local (fe80::/10) + const linkLocal = addr.match(/^fe([0-9a-f]{2}):/) + if (linkLocal) { + const high = parseInt(linkLocal[1], 16) + if (high >= 0x80 && high <= 0xbf) return true + } + return false + } + + // IPv4 + const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (ipv4Match) { + const [, a, b] = ipv4Match.map(Number) + if (a === 10) return true // 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 + if (a === 192 && b === 168) return true // 192.168.0.0/16 + if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local) + if (a === 127) return true // 127.0.0.0/8 (loopback) + if (a === 0) return true // 0.0.0.0/8 + } + + return false +} + +/** + * String-only check against well-known private hostnames and literal IPs. + * Fast path that avoids a DNS lookup for obvious cases. + */ +function isPrivateHostname(hostname: string): boolean { + const host = hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, "") + + if ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "::" + ) { + return true + } + + if (host === "169.254.169.254" || host === "metadata.google.internal") { + return true + } + + if ( + host.endsWith(".local") || + host.endsWith(".internal") || + host.endsWith(".localhost") + ) { + return true + } + + // Literal IP supplied directly in the URL + return isPrivateIp(host) +} + +/** + * Check if URL points to private/internal network. + * Blocks: localhost, private IPs, link-local, AWS metadata service. + * + * Resolves the hostname via DNS and validates every returned address, so + * public-looking names that map to internal IPs (e.g. "127-0-0-1.sslip.io") + * are caught even though they pass the string-only check. + */ +export async function isPrivateUrl(urlString: string): Promise { try { 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(/^\[|\]$/g, "") - .replace(/\.$/, "") - // Block localhost - if ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "::" - ) { - return true - } + // Fast path: obvious string matches and literal IPs. + if (isPrivateHostname(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" || - hostname === "metadata.google.internal" - ) { - return true - } - - // Check for private IPv4 ranges - const ipv4Match = hostname.match( - /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/, - ) - if (ipv4Match) { - const [, a, b] = ipv4Match.map(Number) - if (a === 10) return true // 10.0.0.0/8 - if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 - if (a === 192 && b === 168) return true // 192.168.0.0/16 - if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local) - if (a === 127) return true // 127.0.0.0/8 (loopback) - } - - // Block common internal hostnames - if ( - hostname.endsWith(".local") || - hostname.endsWith(".internal") || - hostname.endsWith(".localhost") - ) { - return true - } - - return false + // Resolve DNS and reject if any address is private. + const stripped = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "") + const addresses = await lookup(stripped, { all: true }) + return addresses.some(({ address }) => isPrivateIp(address)) } catch { - return true // Invalid URL - block it + return true // Invalid URL or DNS failure - block it } } diff --git a/tests/unit/ssrf-protection.test.ts b/tests/unit/ssrf-protection.test.ts index 4a405eb..53b1830 100644 --- a/tests/unit/ssrf-protection.test.ts +++ b/tests/unit/ssrf-protection.test.ts @@ -1,21 +1,62 @@ -import { describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { isPrivateUrl } from "@/lib/ssrf-protection" +// Mock DNS so tests are deterministic and never hit the network. +const lookupMock = vi.hoisted(() => vi.fn()) +vi.mock("node:dns/promises", () => ({ + default: { lookup: lookupMock }, + lookup: lookupMock, +})) + 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) + beforeEach(() => { + lookupMock.mockReset() }) - it("allows public URLs", () => { - expect(isPrivateUrl("https://example.com/article")).toBe(false) - expect(isPrivateUrl("https://fc00.example.com/article")).toBe(false) + it("blocks private IPv6 URLs (string-only fast path, no DNS)", async () => { + expect(await isPrivateUrl("http://[::1]/")).toBe(true) + expect(await isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true) + expect(await isPrivateUrl("http://[::]/")).toBe(true) + expect(await isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true) + expect(await isPrivateUrl("http://[fc00::1]/")).toBe(true) + expect(await isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true) + expect(await isPrivateUrl("http://[fe80::1]/")).toBe(true) + expect(await isPrivateUrl("http://[fe9f::1]/")).toBe(true) + expect(await isPrivateUrl("http://[febf::1]/")).toBe(true) + expect(lookupMock).not.toHaveBeenCalled() + }) + + it("blocks literal private IPv4 without DNS", async () => { + expect(await isPrivateUrl("http://127.0.0.1/")).toBe(true) + expect(await isPrivateUrl("http://10.0.0.5/")).toBe(true) + expect(await isPrivateUrl("http://192.168.1.1/")).toBe(true) + expect(await isPrivateUrl("http://169.254.169.254/")).toBe(true) + expect(lookupMock).not.toHaveBeenCalled() + }) + + it("allows public URLs that resolve to public IPs", async () => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]) + expect(await isPrivateUrl("https://example.com/article")).toBe(false) + }) + + it("blocks public-looking hostnames that resolve to a private IP (DNS-rebinding-style bypass)", async () => { + // e.g. 127-0-0-1.sslip.io resolves to 127.0.0.1 + lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]) + expect(await isPrivateUrl("http://127-0-0-1.sslip.io/")).toBe(true) + }) + + it("blocks when any resolved address is private", async () => { + lookupMock.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + { address: "10.1.2.3", family: 4 }, + ]) + expect(await isPrivateUrl("http://mixed.example.com/")).toBe(true) + }) + + it("blocks when DNS resolution fails", async () => { + lookupMock.mockRejectedValue(new Error("ENOTFOUND")) + expect(await isPrivateUrl("http://does-not-resolve.example/")).toBe( + true, + ) }) })