Skip to content

fix(security): block literal private/loopback IPs in SSRF-safe dispatcher#1671

Open
giladresisi wants to merge 1 commit into
mainfrom
fix/ssrf-literal-ip-bypass
Open

fix(security): block literal private/loopback IPs in SSRF-safe dispatcher#1671
giladresisi wants to merge 1 commit into
mainfrom
fix/ssrf-literal-ip-bypass

Conversation

@giladresisi

Copy link
Copy Markdown
Collaborator

The vulnerability

ssrfSafeDispatcher enforced its private-IP allow-list only inside the undici connect.lookup hook. But Node's net.connect calls lookup only for hostnames that need DNS resolution — for a literal-IP host it connects directly and never invokes lookup. So any URL written as a bare private / loopback / link-local IP bypassed the guard completely:

  • http://169.254.169.254/… — cloud metadata endpoint (can expose temporary cloud credentials → account compromise)
  • http://127.0.0.1:<port>/…, http://10.x.x.x/… — internal-only services that assume "only our own network can reach me"
  • http://192.168.1.1/…, http://[::1]/… — local network / loopback

This is an SSRF hole affecting every consumer of the dispatcher: the uploadFromUrl agent tool, the public /upload-from-url API (n8n & other external clients), and webhook delivery. Any authenticated API/agent user could make the server issue requests into its own private network and read back signal from the response.

The fix

Move the literal-IP check into a custom connect (built via undici's buildConnector), which runs for every connection regardless of DNS. The hostname DNS-pinning path is preserved as pinnedConnector.lookup, so hostnames that resolve to private IPs stay blocked too — the TOCTOU protection from GHSA-f7jj-p389-4w45 is unchanged.

Reproduction & validation

Pre-fix, live via the uploadFromUrl agent tool over MCP:

http://192.168.1.1/a.jpg  ->  {"error":"Failed to fetch URL"}

fetch succeeded — the server connected to the private host and got a response (the !response.ok branch), proving the round-trip happened.

Post-fix, same live call:

http://192.168.1.1/a.jpg  ->  {"error":"Failed to upload media from URL: fetch failed"}

the connection is now rejected at the dispatcher before any socket opens. (With the companion err.cause PR applied, this reads ... fetch failed (Blocked IP).)

No regression, same live tool:

https://picsum.photos/200/300.jpg  ->  {"id","path"}      # public image still uploads
https://httpbin.org/status/404     ->  "Failed to fetch URL"  # ordinary non-2xx path intact

Also verified with a standalone undici repro: literal 192.168.1.1, 127.0.0.1, 169.254.169.254, [::1] and hostname localhost are all rejected with Blocked IP, while example.com still connects.

🤖 Generated with Claude Code

…cher

The ssrfSafeDispatcher enforced its allow-list only inside the undici
`connect.lookup` hook. But Node's net.connect only calls `lookup` for
hostnames that need DNS resolution — for a literal-IP host it connects
directly and never invokes lookup. So any URL with a literal private,
loopback, or link-local IP (http://192.168.1.1/, http://127.0.0.1/,
http://169.254.169.254/, http://[::1]/, ...) bypassed the guard entirely.

This is an SSRF hole affecting every consumer of the dispatcher: the
uploadFromUrl agent tool, the public /upload-from-url API, and webhook
delivery. Any authenticated API user could make the server issue requests
to hosts on its private network (cloud metadata endpoints, internal
services, localhost-bound databases) and read back non-2xx / error signal.

Fix: move the literal-IP check into a custom `connect` (undici
buildConnector), which runs for every connection regardless of DNS. The
hostname DNS-pinning path is kept as `pinnedConnector.lookup` so hostnames
resolving to private IPs stay blocked too (the TOCTOU protection from
GHSA-f7jj-p389-4w45 is preserved).

Reproduced (pre-fix), live via the uploadFromUrl agent tool over MCP:
  http://192.168.1.1/a.jpg -> {"error":"Failed to fetch URL"}
i.e. fetch SUCCEEDED — the server connected to the private host and got a
response back (the "!response.ok" branch), proving the round-trip happened.

Validated (post-fix), same live call:
  http://192.168.1.1/a.jpg -> {"error":"Failed to upload media from URL: fetch failed"}
i.e. the connection is now rejected at the dispatcher before any socket
opens (the catch branch). With the companion err.cause change applied it
reads "... fetch failed (Blocked IP)".

No regression (same live tool):
  https://picsum.photos/200/300.jpg -> {"id","path"}      (public image still uploads)
  https://httpbin.org/status/404    -> "Failed to fetch URL"  (ordinary non-2xx path intact)

Also verified with a standalone undici repro: literal 192.168.1.1,
127.0.0.1, 169.254.169.254, [::1] and hostname localhost are all rejected
with "Blocked IP", while example.com still connects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@postiz-contribution postiz-contribution Bot added the contribution:approved Approved contributor label Jul 3, 2026
@postiz-contribution

Copy link
Copy Markdown

Contribution-checker quality warning
Heuristic score: 0/100 (low). This is a non-blocking warning surfaced by the project's quality settings.

Heuristics that flagged:

  • Excessive inline code references: 26 inline refs (>3)
  • PR doesn't use the repo's PR template: 5 required checkbox items missing (max 1, match ≥80%)
  • Body adds too many extra headers beyond the template: 3 extra headers (>1)
  • AI watermark phrase: Matched: "Generated with Claude Code"
  • Commit message too long: Longest: 2229 chars
  • Excessive added comments: 6 added comment lines (ratio 0.24)

If this is a genuine contribution, please add detail to your PR description and tighten the diff scope before reviewers look at it.

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

Labels

contribution:approved Approved contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant