SSRF via fetch() that follows redirects with caller-controlled URL
`profileImageUrlUpload` calls `fetch(req.body.imageUrl)` with no allowlist and the WHATWG fetch default `redirect: "follow"`, so an attacker can point the server at any host (and chain through redirects to internal targets such as cloud metadata services).
In routes/profileImageUrlUpload.ts the imageUrl is taken directly from the request body and fetched server-side with no validation of host, scheme, or final destination:
const url = req.body.imageUrl
// ...
const response = await fetch(url)
Three problems compound here:
- There is no allowlist / scheme check on
urlbefore issuing the request — the call acceptshttp://169.254.169.254/...,file://, internalhttp://localhost:*/adminendpoints, etc. fetch()defaults toredirect: "follow". Even if a defender later added an initial-hostname allowlist, an attacker-controlled origin could redirect to an internal target and fetch would silently follow — the brief's exact bypass pattern. Noredirect: "manual"orredirect: "error"option is supplied here.- The response body is streamed to disk and the eventual
profileImageURL is what the user supplied, so the attacker can both probe internal services (timing/size differences leak data) and trigger arbitrary outbound HTTP from the server.
The fallback catch block also stores the raw URL in the database (user.update({ profileImage: url })), which converts the SSRF into a stored credential leak / phishing/redirect surface for future profile renders.
- Authenticate to obtain a valid
tokencookie. POST /profile/image/urlwith body{ "imageUrl": "http://attacker.example/redir" }whereattacker.example/redirresponds with302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/.- The server follows the redirect (default
redirect: "follow") and writes the cloud-metadata response intofrontend/dist/frontend/assets/public/images/uploads/<userId>.jpg, which is then served back to the attacker via/assets/public/images/uploads/<userId>.jpg.
Authenticated attacker can use the server as an SSRF proxy: probe internal services, hit cloud metadata endpoints, exfiltrate response bodies into a publicly-served static file, or escape host-allowlist defenses by redirecting through an attacker-controlled origin.
The code at line 24 (const response = await fetch(url)) passes req.body.imageUrl directly to fetch with no scheme/host allowlist and no redirect option, so WHATWG fetch defaults to redirect: "follow". Only an authenticated session is required (the security.authenticatedUsers.get check), and the response body is streamed to a publicly-served path under frontend/dist/.../uploads/${userId}.${ext}, enabling exfiltration of internal/metadata responses. The catch fallback additionally persists the raw URL into profileImage, broadening impact. Per the scope's framing, the intentionally-vulnerable nature of Juice Shop is not a reason to dismiss this real SSRF.
The endpoint is reachable over HTTP (AV:N) and the only gate is security.authenticatedUsers.get(req.cookies.token) returning a logged-in user, so any low-privilege authenticated user can trigger the sink (PR:L); no victim action is needed (UI:N) and exploitation is a single direct request (AC:L). The fetch(url) call with default redirect: "follow" and no allowlist lets the attacker reach a different security authority — internal services like http://169.254.169.254/... cloud-metadata or localhost admin endpoints — and the body is streamed to a publicly-served file under /assets/public/images/uploads/, which is a clear scope change (S:C) with full disclosure of internal/credential responses (C:H). Integrity is L because the attacker can trigger arbitrary outbound GETs and store an attacker-controlled URL as profileImage in the fallback catch (stored redirect/phishing surface), but cannot freely modify arbitrary backend data; availability impact is negligible (A:N).
- CWE-918
- CWE-601
- OWASP A10:2021 Server-Side Request Forgery