fix(parse-url): block SSRF via private/internal URLs (#845)

/api/parse-url accepted any URL the user submitted, fetched it via
@extractus/article-extractor, and returned the body as Markdown. With
ALLOW_PRIVATE_URLS unset (the default after #600) the SSRF guard
short-circuited entirely, so an unauthenticated POST could probe
container ports, read AWS IMDS / GCP metadata, and reach same-VPC
internal services.

- parse-url now always rejects private URLs regardless of
  ALLOW_PRIVATE_URLS. The flag's only legitimate use case is local
  LLM provider baseUrl overrides (validate-model, chat); article
  extraction has no business fetching internal hosts. Local LLM
  setups (Ollama, LM Studio, etc.) are unaffected.
- Strip a trailing dot from the hostname before equality checks so
  the FQDN form "localhost." (which still resolves to 127.0.0.1) is
  caught by the existing string match.

Known follow-ups (not addressed here):
- DNS rebinding: hostnames are matched as strings; a public domain
  resolving to 127.0.0.1 (e.g. localtest.me) is not caught.
- HTTP redirects: @extractus/article-extractor uses cross-fetch with
  default redirect: "follow" and exposes no hook, so a public URL
  302-ing to an internal host still leaks.
This commit is contained in:
Dayuan Jiang
2026-05-21 23:54:23 +09:00
committed by GitHub
parent 1115b2d2cd
commit 7b6eb39fa5
2 changed files with 8 additions and 4 deletions

View File

@@ -1,7 +1,7 @@
import { extract } from "@extractus/article-extractor" import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import TurndownService from "turndown" import TurndownService from "turndown"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" import { isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000 const EXTRACT_TIMEOUT_MS = 15000
@@ -28,8 +28,10 @@ export async function POST(req: Request) {
) )
} }
// SSRF protection // SSRF protection: parse-url has no use case for fetching internal
if (!allowPrivateUrls && isPrivateUrl(url)) { // hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat).
if (isPrivateUrl(url)) {
return NextResponse.json( return NextResponse.json(
{ error: "Cannot access private/internal URLs" }, { error: "Cannot access private/internal URLs" },
{ status: 400 }, { status: 400 },

View File

@@ -9,7 +9,9 @@
export function isPrivateUrl(urlString: string): boolean { export function isPrivateUrl(urlString: string): boolean {
try { try {
const url = new URL(urlString) const url = new URL(urlString)
const hostname = url.hostname.toLowerCase() // 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(/\.$/, "")
// Block localhost // Block localhost
if ( if (