mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 08:54:45 +00:00
fix(security): block IPv6 transition addresses in webhook SSRF guard (#4887)
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,6 +31,16 @@ var errBlockedAddress = errors.New("webhook target resolves to a blocked address
|
|||||||
var zeroNetV4 = &net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(8, 32)}
|
var zeroNetV4 = &net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(8, 32)}
|
||||||
|
|
||||||
func isBlockedIP(ip net.IP) bool {
|
func isBlockedIP(ip net.IP) bool {
|
||||||
|
if isBlockedBaseIP(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// IPv6 transition mechanisms (6to4, NAT64, Teredo, IPv4-compatible) embed an
|
||||||
|
// arbitrary IPv4 address that none of the checks above look at. Unwrap and
|
||||||
|
// re-check the embedded address so 2002:7f00:1::1 is treated as 127.0.0.1.
|
||||||
|
return slices.ContainsFunc(embeddedIPv4(ip), isBlockedBaseIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBlockedBaseIP(ip net.IP) bool {
|
||||||
if ip.IsLoopback() ||
|
if ip.IsLoopback() ||
|
||||||
ip.IsLinkLocalUnicast() ||
|
ip.IsLinkLocalUnicast() ||
|
||||||
ip.IsLinkLocalMulticast() ||
|
ip.IsLinkLocalMulticast() ||
|
||||||
@@ -50,6 +61,59 @@ func isBlockedIP(ip net.IP) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embeddedIPv4 returns the IPv4 addresses carried inside an IPv6 transition
|
||||||
|
// address, or nil when the address carries none. Teredo yields two: the relay
|
||||||
|
// server and the (obfuscated) client.
|
||||||
|
func embeddedIPv4(ip net.IP) []net.IP {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ip16 := ip.To16()
|
||||||
|
if ip16 == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// 6to4 — RFC 3056, 2002::/16, IPv4 in bytes 2-6
|
||||||
|
case ip16[0] == 0x20 && ip16[1] == 0x02:
|
||||||
|
return []net.IP{net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5])}
|
||||||
|
|
||||||
|
// NAT64 well-known prefix — RFC 6052, 64:ff9b::/96, IPv4 in the low 32 bits
|
||||||
|
case ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b &&
|
||||||
|
isZeros(ip16[4:12]):
|
||||||
|
return []net.IP{net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])}
|
||||||
|
|
||||||
|
// NAT64 local-use prefix — RFC 8215, 64:ff9b:1::/48. The embedded IPv4 sits
|
||||||
|
// at a position that depends on the operator's prefix length, so block the
|
||||||
|
// whole range rather than guess.
|
||||||
|
case ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b && ip16[4] == 0x00 && ip16[5] == 0x01:
|
||||||
|
return []net.IP{net.IPv4zero}
|
||||||
|
|
||||||
|
// Teredo — RFC 4380, 2001::/32. Server IPv4 in bytes 4-8, client IPv4 in
|
||||||
|
// bytes 12-16 obfuscated by XOR with 0xff.
|
||||||
|
case ip16[0] == 0x20 && ip16[1] == 0x01 && ip16[2] == 0x00 && ip16[3] == 0x00:
|
||||||
|
return []net.IP{
|
||||||
|
net.IPv4(ip16[4], ip16[5], ip16[6], ip16[7]),
|
||||||
|
net.IPv4(ip16[12]^0xff, ip16[13]^0xff, ip16[14]^0xff, ip16[15]^0xff),
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4-compatible — deprecated ::a.b.c.d, not unwrapped by net.IP.To4
|
||||||
|
case isZeros(ip16[0:12]):
|
||||||
|
return []net.IP{net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isZeros(b []byte) bool {
|
||||||
|
for _, x := range b {
|
||||||
|
if x != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -226,9 +226,21 @@ func TestIsBlockedIP(t *testing.T) {
|
|||||||
"fe80::1",
|
"fe80::1",
|
||||||
"224.0.0.1",
|
"224.0.0.1",
|
||||||
"0.0.0.0",
|
"0.0.0.0",
|
||||||
"0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux
|
"0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux
|
||||||
"0.255.255.255", // top of 0.0.0.0/8
|
"0.255.255.255", // top of 0.0.0.0/8
|
||||||
"255.255.255.255", // limited broadcast
|
"255.255.255.255", // limited broadcast
|
||||||
|
|
||||||
|
// IPv6 transition addresses embedding a blocked IPv4
|
||||||
|
"2002:7f00:0001::1", // 6to4 -> 127.0.0.1
|
||||||
|
"2002:a9fe:a9fe::1", // 6to4 -> 169.254.169.254
|
||||||
|
"2002:0000:0001::1", // 6to4 -> 0.0.0.1
|
||||||
|
"64:ff9b::7f00:1", // NAT64 WKP -> 127.0.0.1
|
||||||
|
"64:ff9b::a9fe:a9fe", // NAT64 WKP -> 169.254.169.254
|
||||||
|
"64:ff9b:1::7f00:1", // NAT64 local-use prefix
|
||||||
|
"2001:0000:dead:beef:0000:0000:80ff:fffe", // Teredo -> client 127.0.0.1
|
||||||
|
"2001:0000:7f00:0001::1", // Teredo -> server 127.0.0.1
|
||||||
|
"::7f00:1", // IPv4-compatible -> 127.0.0.1
|
||||||
|
"::a9fe:a9fe", // IPv4-compatible -> 169.254.169.254
|
||||||
}
|
}
|
||||||
for _, s := range blocked {
|
for _, s := range blocked {
|
||||||
ip := net.ParseIP(s)
|
ip := net.ParseIP(s)
|
||||||
@@ -242,6 +254,9 @@ func TestIsBlockedIP(t *testing.T) {
|
|||||||
"172.16.5.10",
|
"172.16.5.10",
|
||||||
"8.8.8.8",
|
"8.8.8.8",
|
||||||
"2606:4700:4700::1111",
|
"2606:4700:4700::1111",
|
||||||
|
"2002:0808:0808::1", // 6to4 -> 8.8.8.8
|
||||||
|
"64:ff9b::808:808", // NAT64 WKP -> 8.8.8.8
|
||||||
|
"2001:0:808:808::1", // Teredo with public server/client
|
||||||
}
|
}
|
||||||
for _, s := range allowed {
|
for _, s := range allowed {
|
||||||
ip := net.ParseIP(s)
|
ip := net.ParseIP(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user