🐛 Block IPv6 transition addresses in SSRF guard (#11320)

* 🐛 Block IPv6 transition addresses in SSRF guard

The outbound HTTP SSRF blocklist did not classify NAT64
(64:ff9b::/96), 6to4 (2002::/16) or Teredo (2001:0000::/32)
addresses, whose embedded IPv4 target is invisible to the JVM
InetAddress predicates, so URLs resolving to them could reach cloud
metadata, loopback or RFC 1918 hosts from webhook delivery and media
import.

Transition ranges are now rejected outright and any embedded IPv4 is
re-checked against the full blocklist, including operator-supplied
extra blocked CIDRs.

Closes #11319

* ♻️ Remove dead embedded-IPv4 re-check from SSRF guard

The previous commit added a recursive re-check of the IPv4 embedded in
NAT64/6to4/Teredo addresses, but the `or` in `blocked-address?`
short-circuits on the truthy keyword returned by `transition-prefix`,
so the embedded-IPv4 branch was unreachable. The transition ranges are
already rejected outright (fail-closed), making the re-check both
unnecessary and untested.

Remove `transition-embedded-ipv4`, simplify the IPv6 branch to a plain
prefix check, and correct the docstrings and tests to match what the
code actually does.

AI-assisted-by: glm-5.3-flash
This commit is contained in:
Andrey Antukh 2026-09-01 08:47:46 +02:00 committed by GitHub
parent 15195b3bbb
commit 326d83e780
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 47 additions and 5 deletions

View File

@ -5,7 +5,12 @@
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.util.ssrf
"URL/host validation to prevent Server-Side Request Forgery."
"URL/host validation to prevent Server-Side Request Forgery.
The blocklist covers the standard JVM InetAddress classifications plus
explicit ranges: IPv6 ULA, IPv4-mapped loopback, cloud metadata,
operator-supplied CIDRs and the IPv6 transition mechanisms NAT64, 6to4
and Teredo."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
@ -122,6 +127,20 @@
;; Check the embedded IPv4 is loopback (127.x.x.x)
(= (bit-and (aget bs 12) 0xFF) 127))))
(defn- transition-prefix
"Classify a 16-byte IPv6 address into its transition mechanism:
:nat64 (64:ff9b::/96), :6to4 (2002::/16), :teredo (2001:0000::/32) or nil."
[^bytes bs]
(let [b0 (bit-and (aget bs 0) 0xFF)
b1 (bit-and (aget bs 1) 0xFF)
b2 (bit-and (aget bs 2) 0xFF)
b3 (bit-and (aget bs 3) 0xFF)]
(cond
(and (= b0 0x00) (= b1 0x64) (= b2 0xFF) (= b3 0x9B)) :nat64
(and (= b0 0x20) (= b1 0x02)) :6to4
(and (= b0 0x20) (= b1 0x01) (= b2 0x00) (= b3 0x00)) :teredo
:else nil)))
(defn- blocked-address?
"Check if an InetAddress should be blocked. Returns true if blocked."
[^InetAddress addr]
@ -141,12 +160,15 @@
;; Cloud metadata IPs (exact match)
(contains? cloud-metadata-ips (.getHostAddress addr))
;; Extra blocked CIDRs (IPv4 only)
;; Extra blocked CIDRs (IPv4 only) and IPv6 transition mechanisms
(let [bs (.getAddress addr)]
(if (= (alength bs) 4)
(or (some #(in-cidr4? bs %) extra-blocked-ranges)
(some #(in-cidr4? bs %) extra-blocked-cidrs))
false))))
;; IPv6 transition mechanisms (NAT64/6to4/Teredo): the range is
;; rejected outright.
(boolean (when (= (alength bs) 16)
(transition-prefix bs)))))))
(defn resolve-host
"Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName
@ -163,8 +185,10 @@
- host must resolve to at least one address, and
- **every** resolved address must NOT be in the blocklist
(loopback, link-local, site-local, multicast, any-local,
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped
IPv6 of any blocked IPv4, plus operator-supplied CIDRs).
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv6 transition
mechanisms NAT64 64:ff9b::/96, 6to4 2002::/16 and Teredo
2001:0000::/32, IPv4-mapped IPv6 of any blocked IPv4,
plus operator-supplied CIDRs).
When the host is an IP literal (decimal/octal/hex/IPv6) it is
normalized via `com.google.common.net.InetAddresses` before the
check.

View File

@ -80,6 +80,24 @@
(t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo")))
(t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo"))))
(t/deftest validate-url-blocks-nat64-encoded-metadata
;; 64:ff9b::a9fe:a9fe embeds 169.254.169.254 (cloud metadata)
(t/is (false? (ssrf/safe-url? "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/"))))
(t/deftest validate-url-blocks-nat64-encoded-loopback
;; 64:ff9b::7f00:0001 embeds 127.0.0.1
(t/is (false? (ssrf/safe-url? "http://[64:ff9b::7f00:1]/foo"))))
(t/deftest validate-url-blocks-6to4-encoded-private
;; 2002:a00:1:: embeds 10.0.0.1; 2002:c0a8:101:: embeds 192.168.1.1
(t/is (false? (ssrf/safe-url? "http://[2002:a00:1::1]/foo")))
(t/is (false? (ssrf/safe-url? "http://[2002:c0a8:101::1]/foo"))))
(t/deftest validate-url-blocks-teredo-encoded-addresses
;; Teredo server prefix 2001:0000::/32
(t/is (false? (ssrf/safe-url?
"http://[2001:0000:4136:e378:8000:63bf:3fff:fdd2]/foo"))))
(t/deftest validate-url-blocks-encoded-loopback
;; Decimal encoding of 127.0.0.1 = 2130706433
;; InetAddress normalizes this to 127.0.0.1