fix: resolve DNS before SSRF check and block redirects in parse-url

isPrivateUrl() did string-only hostname matching and never resolved DNS,
so a public-looking name that maps to an internal IP (e.g.
127-0-0-1.sslip.io -> 127.0.0.1) passed the check while fetch/extract
later resolved it and reached internal services (GHSA-wqcv-5qvx-vx75).

- isPrivateUrl is now async: it keeps the fast string/literal-IP path,
  then resolves the hostname via DNS and rejects if any address is private.
- parse-url now fetches the page itself with redirect: "error" and parses
  via extractFromHtml(), since article-extractor follows redirects
  internally and drops a redirect option, which allowed a public URL to
  302 to an internal host.
- Update validate-model call site to await; add regression tests.
This commit is contained in:
dayuan.jiang
2026-06-27 18:03:28 +09:00
parent 5c884766a8
commit 73862f6108
4 changed files with 182 additions and 109 deletions

View File

@@ -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" },

View File

@@ -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 },