Pixelpipe: A Sound Allowlist With a Redirect Shaped Hole
Pixelpipe thumbnails remote images. Node 18 + axios 1.6:
const u = new URL(req.body.url);
if (!u.hostname.endsWith('.cdn.pixelpipe.io')) return res.status(400).json({e:'host'});
if (!['http:','https:'].includes(u.protocol)) return res.status(400).json({e:'proto'});
const r = await axios.get(u.href, {timeout: 5000, maxRedirects: 5, responseType: 'arraybuffer'});
res.set('content-type', r.headers['content-type'] || 'application/octet-stream').send(r.data);
Inside the VPC, 10.20.4.12:8080 runs an unauthenticated admin API; GET /config returns {"pg_dsn":"postgres://..."} and is reachable only from the app subnet. The egress firewall drops all traffic from app hosts to the public internet. www.cdn.pixelpipe.io is a Netlify site whose published _redirects contains the single line /go/* http://:splat 302. DNS inside the VPC resolves internal names and the CDN CNAME, nothing else.
1. Why do the obvious payloads fail? Evaluate direct-IP, userinfo, an attacker-controlled DNS name, and a trailing-dot hostname.
Read the worked answer
Direct IP - fails on the check. http://10.20.4.12:8080/config: WHATWG URL parses hostname as 10.20.4.12, which does not end with .cdn.pixelpipe.io. 400 before any socket opens. Same for 169.254.169.254, hex/octal forms like http://0x0a14040c:8080, and IPv6-mapped literals - hostname normalises them to canonical form, so endsWith still misses.
Userinfo - fails on the fetch, not the check. http://10.20.4.12:[email protected]/ passes the check (hostname is x.cdn.pixelpipe.io) but the request goes to x.cdn.pixelpipe.io. The @ trick only defeats naive parsers that treat the pre-@ text as host; WHATWG URL does not. There is no differential here because the check and the fetch use the same parsed object.
Attacker-controlled DNS - unavailable. To pass the check you need a name ending in .cdn.pixelpipe.io, which means control of a subdomain of a domain you do not own. No rebinding, no wildcard, no free lunch.
Trailing dot - fails closed. http://10.20.4.12:8080.cdn.pixelpipe.io. - the trailing dot makes endsWith('.cdn.pixelpipe.io') false, and even without it the name would have to resolve.
The check itself is genuinely sound. The vulnerability must therefore live in something the check does not cover: the redirect chain.
2. Build the working chain. Explain why the redirect bypasses the control, where the firewall stops mattering, and what the app returns to you.
Read the worked answer
Hop 1 - pass the allowlist with a real subdomain:
POST /api/thumb {"url":"http://www.cdn.pixelpipe.io/go/10.20.4.12:8080/config"}
hostname is www.cdn.pixelpipe.io, scheme is http: - both checks pass.
Hop 2 - Netlify answers with the open redirect: /go/* maps the splat onto http://:splat, so the 302 Location is http://10.20.4.12:8080/config.
Hop 3 - axios follows it. maxRedirects: 5 means the redirect is followed inside the same axios.get call, and the validator code has already returned. The hostname check is a one-shot gate on the initial URL; nothing re-validates Location after a hop. That is the entire bug: an allowlist applied to input rather than to every connection actually made.
Why the firewall is irrelevant: it filters egress to public ranges. 10.20.4.12 is RFC1918 and reached from the app's own subnet, which is exactly the traffic the VPC permits. SSRF here does not need internet egress; it needs the absence of one.
Why the http://:splat line matters: if that line were https://:splat, the hop would land on TLS against a plaintext admin API and fail with a handshake error - a dead end that looks identical to "no route". Noting the scheme in the redirect rule before firing is the difference between a clean hit and an hour of confusion.
Output: the endpoint echoes r.data and the upstream content-type, so the JSON DSN comes straight back in the response body. No blind extraction needed. Verify from the DSN by connecting to Postgres if the host you are on can route to it; otherwise the creds are already a full compromise of the data tier.
3. The proposed fix is `maxRedirects: 0` plus the existing hostname suffix check. Is that sufficient? What residual risk remains and how do you close it?
Read the worked answer
It closes this exploit, not the class.
With maxRedirects: 0, hop 2 never happens, so the open redirect on the marketing site is neutralised as an SSRF primitive - but the org still has an open redirect and should fix _redirects regardless.
Residual risk 1 - the allowlist inherits the CDN's DNS. Any .cdn.pixelpipe.io record that resolves into RFC1918, a CNAME to an internal zone, or a dangling subdomain someone can claim, reopens SSRF with no redirect at all. endsWith also silently excludes the apex cdn.pixelpipe.io (no leading dot), which is a correctness footnote. Close it by resolving the host yourself, rejecting private/loopback/link-local/CGNAT answers, and pinning the checked IP in the socket's lookup option so the address used is the address validated - a plain resolve-then-fetch reintroduces the TOCTOU you just removed. Re-check after any redirect hop if redirects are ever re-enabled.
Residual risk 2 - the response is reflected. Even a read-only fetch of internal services leaks data. Restrict response size, content type, and never return upstream content-type verbatim.
Residual risk 3 - protocol and method surface. http: to internal hosts still reaches services that speak cleartext admin protocols; consider gopher:-style smuggling if other fetchers in the codebase accept more schemes, and audit for a sibling endpoint that forwards a user-supplied method or headers - that would reach IMDSv2-style token endpoints the current one cannot.
Practical architecture fix: put the fetch behind a forward proxy that enforces a destination allowlist at connect time, and drop direct egress from app containers entirely.