mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
/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.
131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
import { extract } from "@extractus/article-extractor"
|
|
import { NextResponse } from "next/server"
|
|
import TurndownService from "turndown"
|
|
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
|
|
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
|
const EXTRACT_TIMEOUT_MS = 15000
|
|
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
|
|
|
export async function POST(req: Request) {
|
|
try {
|
|
const { url } = await req.json()
|
|
|
|
if (!url || typeof url !== "string") {
|
|
return NextResponse.json(
|
|
{ error: "URL is required" },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// Validate URL format
|
|
try {
|
|
new URL(url)
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: "Invalid URL format" },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// 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)) {
|
|
return NextResponse.json(
|
|
{ error: "Cannot access private/internal URLs" },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
const headController = new AbortController()
|
|
const headTimeout = setTimeout(() => headController.abort(), 3000)
|
|
try {
|
|
const headResponse = await fetch(url, {
|
|
method: "HEAD",
|
|
headers: { "User-Agent": USER_AGENT },
|
|
signal: headController.signal,
|
|
})
|
|
const contentType = headResponse.headers.get("content-type")
|
|
if (contentType?.includes("application/pdf")) {
|
|
return NextResponse.json(
|
|
{
|
|
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
|
|
},
|
|
{ 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)
|
|
|
|
let article
|
|
try {
|
|
article = await extract(url, undefined, {
|
|
headers: { "User-Agent": USER_AGENT },
|
|
signal: controller.signal,
|
|
})
|
|
} catch (err: any) {
|
|
if (err?.name === "AbortError") {
|
|
return NextResponse.json(
|
|
{ error: "Timed out while fetching URL content" },
|
|
{ status: 504 },
|
|
)
|
|
}
|
|
throw err
|
|
} finally {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
|
|
if (!article || !article.content) {
|
|
return NextResponse.json(
|
|
{ error: "Could not extract content from URL" },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// Convert HTML to Markdown
|
|
const turndownService = new TurndownService({
|
|
headingStyle: "atx",
|
|
codeBlockStyle: "fenced",
|
|
})
|
|
|
|
// Remove unwanted elements before conversion
|
|
turndownService.remove(["script", "style", "iframe", "noscript"])
|
|
|
|
const markdown = turndownService.turndown(article.content)
|
|
|
|
// Check content length
|
|
if (markdown.length > MAX_CONTENT_LENGTH) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
title: article.title || "Untitled",
|
|
content: markdown,
|
|
charCount: markdown.length,
|
|
})
|
|
} catch (error) {
|
|
console.error("URL extraction error:", error)
|
|
return NextResponse.json(
|
|
{ error: "Failed to fetch or parse URL content" },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
}
|