Skip to content

Commit 7c5b67b

Browse files
authored
fix: harden httpx dialer IP validation (#4102)
## Summary - Harden the IP validation used by the HTTP dialer (`GetSSRFDialContext`) to also cover IPv6 transition mechanism addresses, IPv6 scoped (zoned) addresses and the CGNAT range. - Decode the IPv4 address embedded in NAT64 (Well-Known Prefix), 6to4, Teredo and IPv4-compatible addresses and validate it recursively; block the local-use NAT64 prefix wholesale. ## Why - The guard relied on Go's `net.IP` predicates (`IsLoopback`, `IsPrivate`, `IsUnspecified`, `IsMulticast`, `IsLinkLocalUnicast`, `IsLinkLocalMulticast`), which operate on the literal address. These do not decode IPv6 transition prefixes, so an address that embeds a private/reserved IPv4 destination is not recognized as internal. - The guard also used `net.ParseIP`, which rejects IPv6 addresses carrying a zone identifier (e.g. `fe80::1%eth0`) and returns `nil`, so such addresses fell through as if they were a valid public destination. ## Changes In This PR - New `internal/pkg/httpx/ssrf.go`: - `isInternalIP(ip)` - runs the standard checks plus CGNAT (RFC 6598); blocks the RFC 8215 local-use NAT64 prefix `64:ff9b:1::/48` outright; otherwise decodes transition addresses and recurses on the embedded IPv4. - `embeddedIPv4(ip16)` - extracts the embedded IPv4 only from the NAT64 Well-Known Prefix `64:ff9b::/96` (low 32 bits), 6to4 `2002::/16` (bits 16-47), Teredo `2001::/32` (bit-inverted low 32 bits) and IPv4-compatible `::/96`. The local-use prefix is intentionally not decoded here, since its IPv4 layout is operator-defined and not globally reachable. - Module-level parsed CIDR prefixes. - `internal/pkg/httpx/http.go`: `GetSSRFDialContext` now parses the dial host with `netip.ParseAddr` (which accepts zone identifiers), strips the zone for validation, and fails closed on any host that cannot be parsed as an IP. The original zoned address is still used for the actual connection. `EnablePrivateNet` opt-out is preserved. - Tests: - `internal/pkg/httpx/ssrf_unit_test.go` - `isInternalIP`, `embeddedIPv4`, CGNAT boundaries, the real RFC 6052 `/48` layout case, public-address cases. - `internal/pkg/httpx/http_test.go` - dialer-level regression cases for the transition prefixes, the `/48` local-use bypass, CGNAT and zoned link-local addresses. ## Notes - No config or runtime behavior change for valid public addresses; transition addresses wrapping a public IPv4 still connect normally. - The fix is shared by all consumers of the dialer: the HTTP source/sink client (`internal/io/http/client.go`) and the service executor (`internal/service/executors.go`); no changes needed there. - Validation runs once per new TCP connection (transports use keep-alive), not per message. - All `internal/pkg/httpx` tests pass; `internal/io/http` passes (one pre-existing unrelated failure, `TestRestSinkRecoverErr`, exists on `master` as well). Signed-off-by: Jiyong Huang <huangjy@emqx.io>
1 parent 40471e4 commit 7c5b67b

4 files changed

Lines changed: 335 additions & 2 deletions

File tree

internal/pkg/httpx/http.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"mime/multipart"
2424
"net"
2525
"net/http"
26+
"net/netip"
2627
"net/url"
2728
"os"
2829
"path/filepath"
@@ -245,8 +246,17 @@ func GetSSRFDialContext(timeout time.Duration) func(ctx context.Context, network
245246
if err != nil {
246247
return err
247248
}
248-
ip := net.ParseIP(host)
249-
if ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) {
249+
// net.ParseIP rejects IPv6 addresses carrying a zone identifier
250+
// (e.g. "fe80::1%eth0"), which would then fall through as nil
251+
// and bypass the internal-network check. Use netip.ParseAddr,
252+
// which accepts zones, strip the zone for validation, and fail
253+
// closed on any host that cannot be parsed as an IP.
254+
na, err := netip.ParseAddr(host)
255+
if err != nil {
256+
return fmt.Errorf("invalid dial address %q: %w", host, err)
257+
}
258+
ip := net.IP(na.WithZone("").AsSlice())
259+
if isInternalIP(ip) {
250260
return fmt.Errorf("ip %s is in internal network", ip.String())
251261
}
252262
return nil

internal/pkg/httpx/http_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,44 @@ func TestGetSSRFDialContext(t *testing.T) {
119119
}
120120
})
121121

122+
t.Run("Block IPv6 transition addresses that wrap an internal IPv4", func(t *testing.T) {
123+
// Regression test for GHSA-4q5r-r938-8xr2: the SSRF guard must decode
124+
// IPv6 transition mechanisms and block the embedded private IPv4.
125+
conf.Config.Basic.EnablePrivateNet = false
126+
dialer := GetSSRFDialContext(time.Second)
127+
128+
cases := []string{
129+
// NAT64 (RFC 6052) /96 wrapping 169.254.169.254
130+
"[64:ff9b::a9fe:a9fe]:80",
131+
// RFC 8215 local-use NAT64 /48: real embedded IPv4 is 169.254.169.254
132+
// (bytes 6-7/9-10) while low 32 bits read as 8.8.8.8.
133+
"[64:ff9b:1:a9fe:a9:fe00:808:808]:80",
134+
// 6to4 (RFC 3056) wrapping 127.0.0.1
135+
"[2002:7f00:1::]:80",
136+
// Teredo (RFC 4380) wrapping 169.254.169.254
137+
"[2001:0:4136:e378:8000:63bf:5601:5601]:80",
138+
// IPv4-compatible (RFC 4291) wrapping 10.0.0.1
139+
"[::10.0.0.1]:80",
140+
// CGNAT (RFC 6598)
141+
"100.64.0.1:80",
142+
// IPv6 scoped addresses with a zone identifier. net.ParseIP rejects
143+
// the zone and returns nil, which used to bypass the check.
144+
"[fe80::1%eth0]:80",
145+
"[::1%lo]:80",
146+
"[64:ff9b::a9fe:a9fe%eth0]:80",
147+
}
148+
for _, addr := range cases {
149+
conn, err := dialer(context.Background(), "tcp", addr)
150+
assert.Error(t, err, addr)
151+
if err != nil {
152+
assert.Contains(t, err.Error(), "in internal network", addr)
153+
}
154+
if conn != nil {
155+
conn.Close()
156+
}
157+
}
158+
})
159+
122160
t.Run("Allow private IP when enabled", func(t *testing.T) {
123161
conf.Config.Basic.EnablePrivateNet = true
124162
dialer := GetSSRFDialContext(time.Second)

internal/pkg/httpx/ssrf.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright 2025 EMQ Technologies Co., Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httpx
16+
17+
import (
18+
"bytes"
19+
"fmt"
20+
"net"
21+
)
22+
23+
var (
24+
// IPv6 transition mechanism prefixes that embed an IPv4 address.
25+
nat64Prefix = mustParseCIDR("64:ff9b::/96") // RFC 6052 (NAT64 Well-Known Prefix)
26+
sixToFourPrefix = mustParseCIDR("2002::/16") // RFC 3056 (6to4)
27+
teredoPrefix = mustParseCIDR("2001::/32") // RFC 4380 (Teredo)
28+
// Carrier-grade NAT (RFC 6598) is not detected by net.IP.IsPrivate.
29+
cgnatPrefix = mustParseCIDR("100.64.0.0/10")
30+
// RFC 8215 local-use NAT64 prefix. It is not globally reachable and does
31+
// not carry an IPv4 address at a fixed position, so it is blocked wholesale
32+
// rather than decoded. (Only the /96 Well-Known Prefix carries IPv4 in the
33+
// low 32 bits per RFC 6052.)
34+
nat64LocalUsePrefix = mustParseCIDR("64:ff9b:1::/48")
35+
)
36+
37+
func mustParseCIDR(s string) *net.IPNet {
38+
_, n, err := net.ParseCIDR(s)
39+
if err != nil {
40+
panic(fmt.Sprintf("httpx: invalid CIDR %q: %v", s, err))
41+
}
42+
return n
43+
}
44+
45+
// isInternalIP reports whether ip points to a loopback, private, link-local or
46+
// otherwise reserved address that the SSRF guard must block.
47+
//
48+
// It decodes IPv6 transition mechanism addresses (NAT64 Well-Known Prefix,
49+
// 6to4, Teredo and IPv4-compatible) to extract and recursively validate the
50+
// embedded IPv4 address, so a private/reserved IPv4 destination cannot be
51+
// reached by wrapping it inside an IPv6 transition address (GHSA-4q5r-r938-8xr2).
52+
func isInternalIP(ip net.IP) bool {
53+
if ip == nil {
54+
return false
55+
}
56+
// Standard private/reserved ranges; works for both IPv4 and IPv6.
57+
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
58+
ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
59+
return true
60+
}
61+
if ip4 := ip.To4(); ip4 != nil {
62+
// Carrier-grade NAT (RFC 6598) is not covered by net.IP.IsPrivate.
63+
return cgnatPrefix.Contains(ip4)
64+
}
65+
ip16 := ip.To16()
66+
if ip16 == nil {
67+
return false
68+
}
69+
// RFC 8215 local-use NAT64 prefix: block outright, it is not globally
70+
// reachable and its IPv4 layout is operator-defined.
71+
if nat64LocalUsePrefix.Contains(ip16) {
72+
return true
73+
}
74+
if inner, ok := embeddedIPv4(ip16); ok {
75+
return isInternalIP(inner)
76+
}
77+
return false
78+
}
79+
80+
// embeddedIPv4 extracts the IPv4 address embedded in an IPv6 transition
81+
// mechanism address. It returns the embedded IPv4 address and true when the
82+
// address belongs to a known transition prefix, otherwise nil and false.
83+
func embeddedIPv4(ip16 net.IP) (net.IP, bool) {
84+
// IPv4-compatible (::/96 with the first 96 bits set to zero, RFC 4291,
85+
// deprecated). Loopback (::1) and unspecified (::) have an all-zero prefix
86+
// too, but they are already rejected by the standard checks in isInternalIP
87+
// before this function is called.
88+
var zero [12]byte
89+
if bytes.Equal(ip16[:12], zero[:]) {
90+
return ip16[12:16].To4(), true
91+
}
92+
switch {
93+
case nat64Prefix.Contains(ip16):
94+
// NAT64 Well-Known Prefix (RFC 6052): only /96 is well-known, and the
95+
// embedded IPv4 address occupies the low 32 bits. Other prefix lengths
96+
// are operator-chosen Network-Specific Prefixes and are not decoded.
97+
return ip16[12:16].To4(), true
98+
case sixToFourPrefix.Contains(ip16):
99+
// 6to4 (RFC 3056): the embedded IPv4 address occupies bits 16-47.
100+
return ip16[2:6].To4(), true
101+
case teredoPrefix.Contains(ip16):
102+
// Teredo (RFC 4380): the client IPv4 address occupies the low 32 bits
103+
// but each octet is bit-inverted.
104+
return net.IPv4(^ip16[12], ^ip16[13], ^ip16[14], ^ip16[15]).To4(), true
105+
}
106+
return nil, false
107+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright 2025 EMQ Technologies Co., Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httpx
16+
17+
import (
18+
"net"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
)
23+
24+
// narcted for IPv4 -> NAT64 (RFC 6052) address
25+
func nat64For(ipv4 string) string {
26+
b := net.ParseIP(ipv4).To4()
27+
addr := make(net.IP, 16)
28+
addr[0], addr[1] = 0x00, 0x64
29+
addr[2], addr[3] = 0xff, 0x9b
30+
addr[12], addr[13], addr[14], addr[15] = b[0], b[1], b[2], b[3]
31+
return addr.String()
32+
}
33+
34+
// 6to4For maps an IPv4 -> 6to4 (RFC 3056) address
35+
func sixToFourFor(ipv4 string) string {
36+
b := net.ParseIP(ipv4).To4()
37+
addr := make(net.IP, 16)
38+
addr[0], addr[1] = 0x20, 0x02
39+
addr[2], addr[3], addr[4], addr[5] = b[0], b[1], b[2], b[3]
40+
return addr.String()
41+
}
42+
43+
// teredoFor maps an IPv4 -> Teredo (RFC 4380) address. The client IPv4 octets
44+
// are bit-inverted.
45+
func teredoFor(ipv4 string) string {
46+
b := net.ParseIP(ipv4).To4()
47+
addr := make(net.IP, 16)
48+
addr[0], addr[1] = 0x20, 0x01 // 2001::/32
49+
addr[2], addr[3] = 0, 0
50+
addr[4], addr[5], addr[6], addr[7] = 0x41, 0x36, 0xe3, 0x78 // arbitrary server
51+
addr[8], addr[9] = 0x80, 0x00 // flags
52+
addr[10], addr[11] = 0x63, 0xbf // client port
53+
addr[12], addr[13], addr[14], addr[15] = ^b[0], ^b[1], ^b[2], ^b[3]
54+
return addr.String()
55+
}
56+
57+
// TestIsInternalIP_SSRFTransitionAddresses guards against the IPv6 transition
58+
// address bypass described in GHSA-4q5r-r938-8xr2. The previously-bypassed
59+
// addresses must now be reported as internal.
60+
func TestIsInternalIP_SSRFTransitionAddresses(t *testing.T) {
61+
internal := []struct {
62+
label string
63+
ip string
64+
}{
65+
// IPv4 ranges that were already blocked.
66+
{"ipv4 loopback", "127.0.0.1"},
67+
{"ipv4 private 10", "10.0.0.1"},
68+
{"ipv4 private 172", "172.16.0.1"},
69+
{"ipv4 private 192", "192.168.1.1"},
70+
{"ipv4 link-local metadata", "169.254.169.254"},
71+
{"ipv4 unspecified", "0.0.0.0"},
72+
{"ipv4 multicast", "224.0.0.1"},
73+
{"ipv4 cgnat", "100.64.0.1"},
74+
{"ipv4 cgnat edge low", "100.64.0.0"},
75+
{"ipv4 cgnat edge high", "100.127.255.255"},
76+
// IPv6 native ranges that were already blocked.
77+
{"ipv6 loopback", "::1"},
78+
{"ipv6 private ula", "fc00::1"},
79+
{"ipv6 link-local", "fe80::1"},
80+
{"ipv6 unspecified", "::"},
81+
// NAT64 (RFC 6052) wrapping an internal IPv4.
82+
{"nat64 loopback", nat64For("127.0.0.1")},
83+
{"nat64 metadata", nat64For("169.254.169.254")},
84+
{"nat64 private", nat64For("10.0.0.1")},
85+
// NAT64 local-use (RFC 8215) prefix is blocked wholesale; the next
86+
// case uses the *real* RFC 6052 /48 layout (v4 in bytes 6-7/9-10, u in
87+
// byte 8, suffix in bytes 11-15) so the embedded IPv4 is 169.254.169.254
88+
// while the low 32 bits read as 8.8.8.8. The previous implementation
89+
// decoded the low 32 bits and let this through.
90+
{"nat64 local-use /48 bypass", "64:ff9b:1:a9fe:a9:fe00:808:808"},
91+
// Any address under 64:ff9b:1::/48 is blocked (local-use, not globally
92+
// reachable), including one whose low 32 bits are a public IPv4.
93+
{"nat64 local-use low-32 public", "64:ff9b:1::808:808"},
94+
// 6to4 (RFC 3056) wrapping an internal IPv4.
95+
{"6to4 loopback", sixToFourFor("127.0.0.1")},
96+
{"6to4 metadata", sixToFourFor("169.254.169.254")},
97+
{"6to4 private", sixToFourFor("10.0.0.1")},
98+
// Teredo (RFC 4380) wrapping an internal IPv4.
99+
{"teredo loopback", teredoFor("127.0.0.1")},
100+
{"teredo metadata", teredoFor("169.254.169.254")},
101+
// IPv4-compatible (RFC 4291, deprecated).
102+
{"ipv4-compatible private", "::10.0.0.1"},
103+
{"ipv4-compatible loopback", "::127.0.0.1"},
104+
{"ipv4-compatible metadata", "::169.254.169.254"},
105+
// IPv4-mapped wrapping an internal IPv4.
106+
{"ipv4-mapped loopback", "::ffff:127.0.0.1"},
107+
{"ipv4-mapped metadata", "::ffff:169.254.169.254"},
108+
{"ipv4-mapped cgnat", "::ffff:100.64.0.1"},
109+
}
110+
for _, tt := range internal {
111+
t.Run(tt.label, func(t *testing.T) {
112+
ip := net.ParseIP(tt.ip)
113+
assert.NotNil(t, ip, "failed to parse %s", tt.ip)
114+
assert.True(t, isInternalIP(ip), "%s (%s) must be blocked", tt.label, tt.ip)
115+
})
116+
}
117+
}
118+
119+
func TestIsInternalIP_PublicAddressesAllowed(t *testing.T) {
120+
public := []struct {
121+
label string
122+
ip string
123+
}{
124+
{"ipv4 public dns", "8.8.8.8"},
125+
{"ipv4 public cloudflare", "1.1.1.1"},
126+
{"ipv4 just below cgnat", "100.63.255.255"},
127+
{"ipv4 just above cgnat", "100.128.0.0"},
128+
// NAT64 wrapping a *public* IPv4 must be allowed: reaching a public
129+
// address through NAT64 is not an SSRF issue.
130+
{"nat64 public dns", nat64For("8.8.8.8")},
131+
// 6to4 wrapping a public IPv4.
132+
{"6to4 public dns", sixToFourFor("8.8.8.8")},
133+
// Teredo wrapping a public IPv4.
134+
{"teredo public dns", teredoFor("8.8.8.8")},
135+
// Regular public IPv6.
136+
{"ipv6 public", "2606:4700:4700::1111"},
137+
}
138+
for _, tt := range public {
139+
t.Run(tt.label, func(t *testing.T) {
140+
ip := net.ParseIP(tt.ip)
141+
assert.NotNil(t, ip, "failed to parse %s", tt.ip)
142+
assert.False(t, isInternalIP(ip), "%s (%s) must not be blocked", tt.label, tt.ip)
143+
})
144+
}
145+
}
146+
147+
func TestIsInternalIP_Nil(t *testing.T) {
148+
assert.False(t, isInternalIP(nil))
149+
assert.False(t, isInternalIP(net.IP{}))
150+
}
151+
152+
func TestEmbeddedIPv4(t *testing.T) {
153+
tests := []struct {
154+
name string
155+
ip string
156+
want string
157+
wantOK bool
158+
}{
159+
{"nat64 /96", nat64For("169.254.169.254"), "169.254.169.254", true},
160+
{"6to4", sixToFourFor("8.8.8.8"), "8.8.8.8", true},
161+
{"teredo", teredoFor("127.0.0.1"), "127.0.0.1", true},
162+
{"ipv4-compatible", "::10.0.0.1", "10.0.0.1", true},
163+
{"not transition", "2606:4700:4700::1111", "", false},
164+
{"ipv4-mapped is handled by to4, not here", "::ffff:8.8.8.8", "", false},
165+
// RFC 8215 local-use is blocked wholesale by isInternalIP, never decoded.
166+
{"nat64 local-use /48", "64:ff9b:1:a9fe:a9:fe00:808:808", "", false},
167+
}
168+
for _, tt := range tests {
169+
t.Run(tt.name, func(t *testing.T) {
170+
ip16 := net.ParseIP(tt.ip).To16()
171+
got, ok := embeddedIPv4(ip16)
172+
assert.Equal(t, tt.wantOK, ok)
173+
if ok {
174+
assert.Equal(t, tt.want, got.String())
175+
}
176+
})
177+
}
178+
}

0 commit comments

Comments
 (0)