Security [HIGH]: SSRF guard misses IPv6 private ranges + webhook dispatcher lacks DNS-rebind protection #49

Closed
opened 2026-04-16 22:05:11 +02:00 by Hartmut · 1 comment
Owner

Problem

(1) ssrf-guard.ts blocklist covers IPv4 RFC1918 + loopback but misses IPv6 link-local fe80::/10, ULA fc00::/7, IPv4-mapped ::ffff:..., 0.0.0.0/8, and partial 100.64.0.0/10. (2) testWebhook validates URL via guard, but fetch in webhook-dispatcher.ts:117 re-resolves DNS — DNS-rebinding attacks succeed (public IP at validate, private at fetch). (3) webhook-dispatcher.ts NEVER calls assertWebhookUrlAllowed before actual delivery, so legacy/manually-edited webhook URLs bypass guard entirely.

Evidence

  • packages/api/src/lib/ssrf-guard.ts:11-24 — IPv4-only blocklist
  • packages/api/src/router/webhook-procedure-support.ts:117-138 — guard runs, then fetch re-resolves
  • packages/api/src/router/webhook-procedure-support.ts:72-73 — updateWebhook re-validates only if URL changes

Impact

Admin-configured webhook reaches internal IPv6 services (etcd, metrics), metadata endpoints, or via DNS-rebind any private IP at dispatch time.

Proposed Fix

(1) Add IPv6 private prefix blocklist + IPv4-mapped check. (2) Use dns.lookup({ all: true }) and check every result. (3) Pin resolved IP and pass via undici Agent with connect.lookup override. (4) Call assertWebhookUrlAllowed(wh.url) inside deliverWebhook right before fetch.

Acceptance Criteria

  • Unit test: IPv6 loopback/ULA/link-local rejected
  • Unit test: DNS-rebind scenario (mocked) — dispatch uses validated IP
  • Every fetch in dispatcher preceded by guard call

Parent Epic: #1
Source: Full-Codebase Security Audit 2026-04-16 (B-14, B-23, B-25)

## Problem (1) `ssrf-guard.ts` blocklist covers IPv4 RFC1918 + loopback but misses IPv6 link-local `fe80::/10`, ULA `fc00::/7`, IPv4-mapped `::ffff:...`, `0.0.0.0/8`, and partial `100.64.0.0/10`. (2) `testWebhook` validates URL via guard, but `fetch` in `webhook-dispatcher.ts:117` re-resolves DNS — DNS-rebinding attacks succeed (public IP at validate, private at fetch). (3) `webhook-dispatcher.ts` NEVER calls `assertWebhookUrlAllowed` before actual delivery, so legacy/manually-edited webhook URLs bypass guard entirely. ## Evidence - `packages/api/src/lib/ssrf-guard.ts:11-24 — IPv4-only blocklist` - `packages/api/src/router/webhook-procedure-support.ts:117-138 — guard runs, then fetch re-resolves` - `packages/api/src/router/webhook-procedure-support.ts:72-73 — updateWebhook re-validates only if URL changes` ## Impact Admin-configured webhook reaches internal IPv6 services (etcd, metrics), metadata endpoints, or via DNS-rebind any private IP at dispatch time. ## Proposed Fix (1) Add IPv6 private prefix blocklist + IPv4-mapped check. (2) Use `dns.lookup({ all: true })` and check every result. (3) Pin resolved IP and pass via `undici` `Agent` with `connect.lookup` override. (4) Call `assertWebhookUrlAllowed(wh.url)` inside `deliverWebhook` right before `fetch`. ## Acceptance Criteria - [ ] Unit test: IPv6 loopback/ULA/link-local rejected - [ ] Unit test: DNS-rebind scenario (mocked) — dispatch uses validated IP - [ ] Every fetch in dispatcher preceded by guard call --- Parent Epic: #1 Source: Full-Codebase Security Audit 2026-04-16 (B-14, B-23, B-25)
Hartmut added the security label 2026-04-16 22:05:11 +02:00
Author
Owner

Resolved in commit 4ff7bc9 (security: SSRF guard covers IPv6 + DNS-rebind defence via pinned IP).

SSRF-guard (packages/api/src/lib/ssrf-guard.ts) — blocks full IPv4 private space (loopback, RFC 1918, link-local, CGNAT 100.64/10, 198.18/15 benchmark, TEST-NETs, 224/4 multicast, 240/4 reserved) and IPv6 (::1, fc00::/7 ULA, fe80::/10 link-local, ff00::/8 multicast, 2001:db8::/32, ::ffff:… mapped → v4). dns.lookup({all:true}) + reject-if-any-private covers multi-record rebind attacks.

Webhook dispatcher (packages/api/src/lib/webhook-dispatcher.ts) — uses Node native https.request with a custom Agent({lookup}) that pins the pre-validated IP at socket connect() time. The real hostname stays on the Host header + SNI for cert validation. A DNS rebind between guard-check and dial cannot redirect the connection.

Tests: 28 cases in packages/api/src/__tests__/ssrf-guard.test.ts (including multi-record rebind + IPv6-mapped v4), plus a DNS-rebind regression in packages/api/src/__tests__/webhook-dispatcher.test.ts that invokes the Agent.lookup callback and verifies the pinned IP.

Resolved in commit 4ff7bc9 (`security: SSRF guard covers IPv6 + DNS-rebind defence via pinned IP`). **SSRF-guard (packages/api/src/lib/ssrf-guard.ts)** — blocks full IPv4 private space (loopback, RFC 1918, link-local, CGNAT 100.64/10, 198.18/15 benchmark, TEST-NETs, 224/4 multicast, 240/4 reserved) and IPv6 (::1, fc00::/7 ULA, fe80::/10 link-local, ff00::/8 multicast, 2001:db8::/32, ::ffff:… mapped → v4). `dns.lookup({all:true})` + reject-if-any-private covers multi-record rebind attacks. **Webhook dispatcher (packages/api/src/lib/webhook-dispatcher.ts)** — uses Node native `https.request` with a custom `Agent({lookup})` that pins the pre-validated IP at socket `connect()` time. The real hostname stays on the Host header + SNI for cert validation. A DNS rebind between guard-check and dial cannot redirect the connection. Tests: 28 cases in `packages/api/src/__tests__/ssrf-guard.test.ts` (including multi-record rebind + IPv6-mapped v4), plus a DNS-rebind regression in `packages/api/src/__tests__/webhook-dispatcher.test.ts` that invokes the Agent.lookup callback and verifies the pinned IP.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Hartmut/CapaKraken#49