Skip to content

fix(security): validate and IP-pin proxy target on /api/upload-binary (SSRF)#184

Open
aaronjmars wants to merge 1 commit into
Anil-matcha:mainfrom
aaronjmars:security/ssrf-upload-binary-proxy
Open

fix(security): validate and IP-pin proxy target on /api/upload-binary (SSRF)#184
aaronjmars wants to merge 1 commit into
Anil-matcha:mainfrom
aaronjmars:security/ssrf-upload-binary-proxy

Conversation

@aaronjmars
Copy link
Copy Markdown

Summary

/api/upload-binary and /api/v1/upload-binary are server-side proxies that accept any URL in the x-proxy-target-url form field and POST FormData to it. The intent is to forward to S3 presigned URLs the upstream MuAPI service returns, but with no validation those endpoints are generic SSRF gadgets — an attacker can point them at cloud metadata (http://169.254.169.254/...), internal admin APIs, RFC1918 hosts, or any URL the Next.js server can reach. The server returns the upstream response body to the caller on non-2xx, which leaks internal error pages back to the attacker too.

This PR adds a shared validator (app/lib/proxyTargetSafety.mjs) that both routes call before fetch, and pins the connection to a validated IP via an undici Agent so a DNS-rebinding race can't redirect the request after validation.

Impact

Concretely, before this change, anyone who can reach the deployment can do:

curl -X POST https://<host>/api/upload-binary \
  -F 'x-proxy-target-url=http://169.254.169.254/latest/meta-data/iam/security-credentials/' \
  -F 'noise=1'

…and have the Next.js server execute that fetch from inside its network boundary. On a non-2xx the response body is echoed back; on a 2xx the request still happens server-side (useful for triggering destructive POSTs against internal-only services, port-scanning by status/timing, etc.). Cloud metadata, internal Kubernetes services, intranet admin panels — anything the server's network reaches is in scope.

Severity: HIGH (CWE-918 SSRF, with CWE-350 / CWE-346 DNS rebinding mitigation as defense-in-depth).

Location

  • app/api/upload-binary/route.js
  • app/api/v1/upload-binary/route.js

Both files read x-proxy-target-url from the multipart form and call fetch(targetUrl, …) without any validation of scheme, host, or resolved IP.

Fix

New helper app/lib/proxyTargetSafety.mjs exports validateAndPinProxyTarget(rawUrl). The route handlers call it before fetch:

let target;
try {
    target = await validateAndPinProxyTarget(String(rawTargetUrl));
} catch (err) {
    return NextResponse.json({ error: `Invalid proxy target: ${err.message}` }, { status: 400 });
}

const s3Response = await fetch(target.url, {
    method: 'POST',
    body: s3FormData,
    dispatcher: target.dispatcher,
});

The validator enforces, in order:

  1. https:// only (blocks http://, file://, ftp://, …).
  2. No embedded credentials (https://attacker:secret@example.com/...).
  3. Literal-IP hostnames are checked directly against private / loopback / link-local / cloud-metadata / CGNAT / multicast / reserved ranges — covering IPv4, IPv4-mapped IPv6 in both dotted and hex-normalized forms (Node's URL parser canonicalizes [::ffff:169.254.169.254][::ffff:a9fe:a9fe], the validator handles both), IPv4-compatible IPv6, IPv6 ULA (fc00::/7), IPv6 link-local (fe80::/10), IPv6 multicast (ff00::/8), and 2002::/16 6to4 wrapping a private IPv4.
  4. DNS hostnames are resolved with dns.lookup({ all: true }); if any returned record is in the same blocked ranges, the request is rejected.
  5. The connection is pinned to the validated IP via an undici Agent with a custom connect.lookup so a racing DNS flip between the validator and the actual fetch can't redirect the request to a private IP (canonical DNS-rebinding defense).
  6. Optional operator allowlist via PROXY_TARGET_ALLOWED_HOSTS (comma-separated host suffixes, e.g. s3.amazonaws.com,storage.googleapis.com) for deployments that want to scope the endpoint to a known storage-provider list.

undici is added as an explicit dependency (Node 18+ ships it internally but doesn't expose it as a built-in module).

Detected by

Aeon + manual review of app/api/*/route.js.

  • Severity: HIGH
  • CWE-918 (SSRF) with CWE-350 / CWE-346 DNS rebinding hardening as defense-in-depth.

Verification

  • 29 new unit tests in tests/proxyTargetSafety.test.mjs covering every IPv4/IPv6 range, IPv4-mapped IPv6 in both forms, 6to4, DNS rebinding, allowlist behavior, malformed input, and the happy path.
  • node --test tests/*.test.js tests/*.test.mjs → 41/41 pass (12 pre-existing localInference tests + 29 new SSRF tests). No regressions.
  • The two route handlers preserve their original behavior on the happy path: well-formed https://*.s3.amazonaws.com/... presigned URLs round-trip through unchanged; only the SSRF surface is closed.

Notes for maintainers

  • The validator returns { url, dispatcher }; both routes pass dispatcher: target.dispatcher to fetch. This works in Next.js Route Handlers (Node.js runtime) because Node's global fetch accepts undici dispatchers. If you ever move these routes to the Edge runtime, the dispatcher path will need a different shape — flagging it here as a forward note rather than a current issue.
  • PROXY_TARGET_ALLOWED_HOSTS is opt-in (undefined → all public hosts allowed). If you want stricter behavior in production, setting it to s3.amazonaws.com (and whatever storage hostname MuAPI actually hands back) tightens this further without code changes.

Filed by Aeon.

… (SSRF)

The two /api/upload-binary route handlers accept any URL in the
x-proxy-target-url form field and POST FormData to it server-side. The
intent is to forward to S3 presigned URLs returned by upstream MuAPI,
but with no validation the endpoint is a generic SSRF gadget — an
attacker can point it at cloud metadata (169.254.169.254), internal
admin APIs, RFC1918 hosts, or any URL the Next.js server can reach.

This change extracts a shared validator in app/lib/proxyTargetSafety.mjs
that both routes call before fetch:

  - Requires https:// (blocks http://, file://, ftp://, ...).
  - Rejects URLs with embedded credentials.
  - Rejects literal IPs in private / loopback / link-local /
    cloud-metadata / CGNAT / multicast / reserved / IPv6 ULA / IPv6
    link-local ranges. Covers IPv4-mapped IPv6 in both dotted and
    hex-normalized forms (URL parser canonicalizes
    [::ffff:169.254.169.254] to [::ffff:a9fe:a9fe]), IPv4-compatible
    IPv6, and 2002::/16 6to4 wrapping a private IPv4.
  - DNS-resolves the hostname and rejects the request if any returned
    address is in those ranges.
  - Pins the connection to the validated IP via an undici Agent so a
    racing DNS flip between validation and fetch cannot redirect the
    request to a private IP (canonical DNS-rebinding defense).
  - Optional operator allowlist via PROXY_TARGET_ALLOWED_HOSTS for
    deployments that want to scope this endpoint to a known storage
    provider list.

Adds undici as an explicit dependency for the IP-pinned Agent.

Detected by Aeon + manual review of app/api/*/route.js proxy handlers.
Severity: HIGH
CWE-918 (SSRF) + CWE-350/346 (DNS rebinding hardening)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@aaronjmars
Copy link
Copy Markdown
Author

Friendly bump — validates and IP-pins the proxy target on /api/upload-binary to close the SSRF. Mergeable, no conflicts. Happy to iterate on feedback whenever you have a moment.

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.

2 participants