(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.
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 undiciAgent 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
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
(1)
ssrf-guard.tsblocklist covers IPv4 RFC1918 + loopback but misses IPv6 link-localfe80::/10, ULAfc00::/7, IPv4-mapped::ffff:...,0.0.0.0/8, and partial100.64.0.0/10. (2)testWebhookvalidates URL via guard, butfetchinwebhook-dispatcher.ts:117re-resolves DNS — DNS-rebinding attacks succeed (public IP at validate, private at fetch). (3)webhook-dispatcher.tsNEVER callsassertWebhookUrlAllowedbefore actual delivery, so legacy/manually-edited webhook URLs bypass guard entirely.Evidence
packages/api/src/lib/ssrf-guard.ts:11-24 — IPv4-only blocklistpackages/api/src/router/webhook-procedure-support.ts:117-138 — guard runs, then fetch re-resolvespackages/api/src/router/webhook-procedure-support.ts:72-73 — updateWebhook re-validates only if URL changesImpact
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 viaundiciAgentwithconnect.lookupoverride. (4) CallassertWebhookUrlAllowed(wh.url)insidedeliverWebhookright beforefetch.Acceptance Criteria
Parent Epic: #1
Source: Full-Codebase Security Audit 2026-04-16 (B-14, B-23, B-25)
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.requestwith a customAgent({lookup})that pins the pre-validated IP at socketconnect()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 inpackages/api/src/__tests__/webhook-dispatcher.test.tsthat invokes the Agent.lookup callback and verifies the pinned IP.