SSRF via pathname confusion
Affected pattern
Any Node.js application that constructs internal request URLs using naive string concatenation of the form:
const targetUrl = `http://${host}:${port}${userControlledPathname}`;
and then passes this URL to http.request, http.get, axios, fetch, http-proxy, or similar libraries without strict normalization and validation.
This PoC demonstrates a Server-Side Request Forgery (SSRF) vulnerability caused by differences in how Node.js parses URLs when the path contains special characters like *@.
Root cause
HTTP/2.0 connection preface starts with a special pseudo-request to check if HTTP/2 is available to use:
PRI * HTTP/1.0
This * in the request line is treated specially by some parsers. When combined with the authority form user@host:port, Node.js http-parser can interpret malformed request lines in unexpected ways (and considers that request line valid).
So when pathname is attacker-controlled (via query parameter, route parameter, header, etc), specially crafted values like: http://localhost:3000*@localhost:8000/admin can trick parsers into interpreting the string as:
URL {
href: 'http://localhost:3000*@localhost:8000/admin',
origin: 'http://localhost:8000',
protocol: 'http:',
username: 'localhost',
password: '3000*',
host: 'localhost:8000',
hostname: 'localhost',
port: '8000',
pathname: '/admin',
search: '',
searchParams: URLSearchParams {},
hash: ''
}
This allows the application to send requests to arbitrary internal ports.
Next.js SSRF
In Next.js versions >= 13.3.0 | <= 13.4.12, internal proxying used exactly this vulnerable construction
Source location packages/next/src/server/lib/start-server.ts:
const getProxyServer = (pathname: string) => {
const targetUrl = `http://${
targetHost === 'localhost' ? '127.0.0.1' : targetHost
}:${routerPort}${pathname}`
const proxyServer = httpProxy.createProxy({
target: targetUrl,
changeOrigin: false,
ignorePath: true,
xfwd: true,
ws: true,
followRedirects: false,
})
proxyServer.on('error', (_err) => {
// TODO?: enable verbose error logs with --debug flag?
})
return proxyServer
}
Since pathname is fully user-controlled, the *@localhost:8000 payloads allowed full SSRF — including reading responses from internal services.
From 13.4.13 onward, internal requests switched to fetch() with proper URL normalization. fetch rejects malformed parts, blocking the attack.
This internal proxying feature can be enabled by default (in App Router apps) or by using this config:
// next.config.js
experimental: {
appDir: true,
}
Exploitation
Request with payload:
GET *@127.0.0.1:3002 HTTP/1.1
Host: vulnerable.app