Skip to content

fix(upload-binary): prevent SSRF via x-proxy-target-url allowlist - #302

Open
sebastionoss wants to merge 1 commit into
Anil-matcha:mainfrom
sebastionoss:fix/cwe918-route-ssrf-e9e5
Open

fix(upload-binary): prevent SSRF via x-proxy-target-url allowlist#302
sebastionoss wants to merge 1 commit into
Anil-matcha:mainfrom
sebastionoss:fix/cwe918-route-ssrf-e9e5

Conversation

@sebastionoss

Copy link
Copy Markdown

Summary

The /api/upload-binary and /api/v1/upload-binary routes act as a server-side proxy for S3 uploads. The destination is taken directly from the x-proxy-target-url field on the incoming multipart form and passed to fetch() without any validation. That makes both endpoints a Server-Side Request Forgery (SSRF) sink (CWE-918): an attacker who can reach the Next.js server can coerce it into issuing arbitrary outbound requests — including to internal services or cloud metadata endpoints such as http://169.254.169.254/latest/meta-data/.

The routes have no auth gate — there is no middleware.ts at the project root and no per-route auth check — so any client that can reach the app can trigger the proxy.

  • Affected files: app/api/upload-binary/route.js, app/api/v1/upload-binary/route.js
  • Sink: fetch(targetUrl, { method: 'POST', body: s3FormData }) where targetUrl = formData.get('x-proxy-target-url')
  • CWE: 918 (SSRF)

Fix

Both routes now validate x-proxy-target-url against a small allowlist before calling fetch():

  • URL is parsed with the WHATWG URL constructor (so userinfo tricks like https://s3.amazonaws.com@169.254.169.254/ don't smuggle a different host past the check).
  • Protocol must be https: (blocks file:, gopher:, http: to plaintext internal services).
  • Hostname must exactly match, or be a subdomain of, one of: .amazonaws.com, .s3.amazonaws.com, .cloudfront.net, .muapi.ai. The check is suffix-anchored on .<suffix> so lookalikes like amazonaws.com.evil.com don't match.
  • IP literals never match a DNS suffix, so 169.254.169.254, 127.0.0.1, decimal/hex/octal encodings, and [::1] are all rejected.

Rejected requests return 400 Invalid proxy target URL. Legitimate presigned URLs returned by MuAPI (which point at AWS S3 / CloudFront) continue to work unchanged.

Proof of concept

Before the fix, against a local dev server:

# Force the server to hit the AWS instance metadata service
curl -X POST http://localhost:3000/api/upload-binary \
  -F "file=@/tmp/x" \
  -F "x-proxy-target-url=http://169.254.169.254/latest/meta-data/"
# → server issues outbound request to 169.254.169.254

# Reach an internal admin service on the host
curl -X POST http://localhost:3000/api/upload-binary \
  -F "file=@/tmp/x" \
  -F "x-proxy-target-url=http://127.0.0.1:6379/"

After the fix, both requests return {"error":"Invalid proxy target URL"} with HTTP 400. Presigned URLs of the form https://<bucket>.s3.amazonaws.com/... continue to be proxied normally.

What was tested

The allowlist logic was exercised against the following cases; all bypass attempts are rejected and all legitimate targets are accepted:

  • Rejected: http://169.254.169.254/latest/meta-data/, http://127.0.0.1:6379/, http://[::1]/, http://2130706433/, http://0x7f000001/, file:///etc/passwd, gopher://127.0.0.1:6379/_INFO, https://amazonaws.com.evil.com/, https://evil.com/amazonaws.com, https://s3.amazonaws.com@169.254.169.254/, not-a-url, empty string.
  • Accepted: https://my-bucket.s3.amazonaws.com/key, https://d123.cloudfront.net/asset, https://api.muapi.ai/upload.

Adversarial review

Before submitting we tried to disprove the finding. The routes are part of a self-hostable app, so we asked whether an attacker realistically reaches them and whether existing controls block exploitation. They don't: there is no auth middleware, no host/CORS restriction on the route, and the fetch() call trusts the raw form value. Anyone who can reach the Next.js server on the network — including LAN peers on a developer's machine and anyone hitting a publicly-exposed deployment — can drive the proxy. AWS IMDSv2 mitigates the metadata angle on modern EC2, but IMDSv1 is still common on self-hosted setups and internal-service pivots (Redis, Docker socket over TCP, admin panels on loopback) don't depend on cloud metadata at all. The fix is a positive allowlist rather than a denylist, which avoids the usual round of bypasses (encoded IPs, IPv6, DNS rebinding on the hostname string, userinfo @ confusion).


Discovered by the Sebastion AI GitHub App.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant