Skip to content

fix(security): pin outbound webhook and media fetches to the SSRF-validated IP#338

Closed
rmyndharis wants to merge 1 commit into
mainfrom
fix/ssrf-pin-validated-ip
Closed

fix(security): pin outbound webhook and media fetches to the SSRF-validated IP#338
rmyndharis wants to merge 1 commit into
mainfrom
fix/ssrf-pin-validated-ip

Conversation

@rmyndharis

Copy link
Copy Markdown
Owner

Problem

The SSRF guard and the actual outbound connection resolved DNS independently: assertSafeFetchUrl(url) did its own dns.lookup to validate the host, then fetch(url) performed a second, unrelated resolution at connect time. An attacker controlling a domain's authoritative DNS with a ~0 TTL could answer a public address to the guard and an internal one (loopback, RFC1918, 169.254.169.254 cloud metadata) to the fetch — a classic DNS-rebinding TOCTOU. This affected webhook delivery (the OPERATOR-set URL) and server-side media fetches.

Fix

A single guarded helper, withSafeFetch, that resolves and validates the host once, then pins the connection to the vetted IP via an undici dispatcher whose connect.lookup returns only those addresses — so the connection cannot be re-resolved between check and connect. Key invariants preserved:

  • TLS SNI + Host header stay the original hostname (not the IP), so certificate validation and virtual hosting are unaffected.
  • All vetted addresses are offered, so A-record round-robin / failover still works.
  • Redirects are refused (redirect: 'manual' + assertNoRedirect) — the guard only validated the original host.
  • The WEBHOOK_SSRF_PROTECT opt-out and SSRF_ALLOWED_HOSTS escape-hatch behave exactly as before; literal-IP and allowlisted-host targets need no pin.

Routed through it: webhook delivery (direct, queued, and the test endpoint), the engine-neutral media download, and the whatsapp-web.js send-media-by-URL path — which previously used MessageMedia.fromUrl (bundled node-fetch, its own unpinned re-resolution) and is the default engine, so it was still exploitable. It now fetches via the pinned helper and builds MessageMedia from the bytes, mirroring the Baileys path.

Dependency

Adds undici (the official Node HTTP client — the engine behind global fetch), pinned to v6 to match Node 22's bundled major. Global fetch exposes no per-request DNS hook; undici's dispatcher is the documented way to pin a connection's IP while keeping SNI. No audit advisories.

Compatibility

No wire-format or response-shape change; non-breaking. SemVer: patch.

Tests

  • ssrf-guard.spec.ts: pinnedLookup returns the captured addresses and never re-resolves (rebind-immune); withSafeFetch passes a real pinned dispatcher for a hostname target (a regression that removes the pin fails this), refuses redirects on the pinned path, and is fail-closed before any socket.
  • load-remote-media.spec.ts / whatsapp-web-js.adapter.spec.ts: media now flows through the pinned fetch and never via MessageMedia.fromUrl.
  • Full backend suite 739/739; dashboard build + lint clean.

…idated IP

The host safety check and the actual connection resolved DNS independently, so a hostile low-TTL DNS answer could pass validation as a public address and then have the connection re-resolve to an internal one (DNS rebinding). Connections now reuse the address vetted by the guard via an undici dispatcher pinned to it, preserving the original hostname for TLS SNI and the Host header and offering all vetted addresses so A-record failover still works.

Routes webhook delivery (direct, queued, and test), server-side media downloads, and the whatsapp-web.js send-media-by-URL path (previously fetched via MessageMedia.fromUrl, which re-resolved DNS independently) through a single guarded helper. Adds undici as a dependency for the IP pinning.
rmyndharis added a commit that referenced this pull request Jun 19, 2026
* fix(webhook): deliver session lifecycle events and key webhook hardening (#335)

* fix(security): pin outbound webhook and media fetches to validated IP (#338)

* fix(plugins): persist plugin enable/config and restore (#339)

* fix(message): persist bulk-sent messages, sanitize SSRF (#340)

* fix(engine): return the real id for forwarded messages (#341)

* fix(security): harden outbound requests, IPv6 SSRF (#344)

* fix(security): secret-file perms, key pepper, allowedIps (#345)

* fix(storage): bound tar imports, contain storage keys (#346)

* fix(session): reconcile late acks, serialize reactions (#348)

* fix(contract): webhook timeout, bounded shutdown, 501 (#350)

* feat(session): force-kill a stuck session (#352)

* merge #343

* merge #351

* chore(release): v0.4.3 — CHANGELOG, version bump (package.json/dashboard/swagger), README + docs
@rmyndharis

Copy link
Copy Markdown
Owner Author

Shipped in v0.4.3 (integrated via the release PR #354 and tagged v0.4.3). Thanks! 🎉

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