mirror of
https://github.com/axllent/mailpit.git
synced 2026-06-27 22:46:09 +00:00
Security: Fix incomplete SSRF protection in IsInternalIP() detection for IPv6 transition mechanisms (GHSA-w4mc-hhc6-xp28)
This commit is contained in:
@@ -1,35 +1,111 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// cgnatRange is the CGNAT shared address space (RFC 6598), not covered by net.IP.IsPrivate().
|
||||
// CGNAT (Carrier-Grade NAT) is a technique used by ISPs to conserve IPv4 addresses. Instead of assigning a unique
|
||||
// public IP to every customer, the ISP places many customers behind a shared NAT, then gives them all addresses
|
||||
// from the reserved 100.64.0.0/10 range (RFC 6598) on their internal network.
|
||||
var cgnatRange = func() *net.IPNet {
|
||||
_, cidr, _ := net.ParseCIDR("100.64.0.0/10")
|
||||
var (
|
||||
// cgnatRange is the CGNAT shared address space (RFC 6598), not covered by net.IP.IsPrivate().
|
||||
// CGNAT (Carrier-Grade NAT) is a technique used by ISPs to conserve IPv4 addresses. Instead of assigning a unique
|
||||
// public IP to every customer, the ISP places many customers behind a shared NAT, then gives them all addresses
|
||||
// from the reserved 100.64.0.0/10 range (RFC 6598) on their internal network.
|
||||
cgnatRange = mustCIDR("100.64.0.0/10")
|
||||
|
||||
// IPv6 transition prefixes that embed an IPv4 destination. Go's net.IP.Is* family
|
||||
// does not decode these, so an IPv6 literal of one of these forms can carry a
|
||||
// private/link-local IPv4 destination past the stdlib checks. See golang/go#79925.
|
||||
nat64WellKnown = mustCIDR("64:ff9b::/96") // RFC 6052
|
||||
nat64LocalUse = mustCIDR("64:ff9b:1::/48") // RFC 8215
|
||||
sixToFour = mustCIDR("2002::/16") // RFC 3056
|
||||
teredo = mustCIDR("2001::/32") // RFC 4380
|
||||
ipv4Compatible = mustCIDR("::/96") // RFC 4291 §2.5.5.1
|
||||
// IPv4-mapped IPv6 (::ffff:0:0/96, RFC 4291 §2.5.5.2) is normalised by net.IP.To4,
|
||||
// so the stdlib Is* checks above already see the embedded IPv4 - no decode needed.
|
||||
|
||||
// Direct IPv6 prefixes outside the scope of Go's stdlib Is* family.
|
||||
deprecatedSiteLocal = mustCIDR("fec0::/10") // RFC 3879 / RFC 4291 §2.5.7 — deprecated, still routable on dual-stack hosts
|
||||
documentationPrefix = mustCIDR("2001:db8::/32") // RFC 3849 — documentation only, must not appear in real traffic
|
||||
)
|
||||
|
||||
// MustCIDR is a helper for use in global var initialisation.
|
||||
func mustCIDR(s string) *net.IPNet {
|
||||
_, cidr, _ := net.ParseCIDR(s)
|
||||
|
||||
return cidr
|
||||
}()
|
||||
}
|
||||
|
||||
// IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast).
|
||||
// IsLoopback — 127.0.0.0/8, ::1
|
||||
// IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
|
||||
// IsLinkLocalUnicast — 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
|
||||
// IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16
|
||||
// IsUnspecified — 0.0.0.0, ::
|
||||
// IsMulticast — 224.0.0.0/4, ff00::/8
|
||||
// CGNAT — 100.64.0.0/10 (RFC 6598) (Carrier-Grade NAT)
|
||||
// IsLoopback - 127.0.0.0/8, ::1
|
||||
// IsPrivate - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
|
||||
// IsLinkLocalUnicast - 169.254.0.0/16, fe80::/10 (covers cloud metadata 169.254.169.254)
|
||||
// IsLinkLocalMulticast - 224.0.0.0/24, ff02::/16
|
||||
// IsUnspecified - 0.0.0.0, ::
|
||||
// IsMulticast - 224.0.0.0/4, ff00::/8
|
||||
// CGNAT - 100.64.0.0/10 (RFC 6598) (Carrier-Grade NAT)
|
||||
// IPv6 transition forms - NAT64 (RFC 6052/8215), 6to4 (RFC 3056), Teredo (RFC 4380),
|
||||
// IPv4-compatible (RFC 4291) - re-checked against their embedded IPv4.
|
||||
func IsInternalIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
if ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsMulticast() ||
|
||||
cgnatRange.Contains(ip)
|
||||
cgnatRange.Contains(ip) ||
|
||||
deprecatedSiteLocal.Contains(ip) ||
|
||||
documentationPrefix.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
|
||||
if embeddedV4, ok := embeddedIPv4(ip); ok {
|
||||
return IsInternalIP(embeddedV4)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// embeddedIPv4 returns the IPv4 destination encoded in ip, if ip is an IPv6 form
|
||||
// documented to carry one. Without this, an IPv6 literal like 64:ff9b::a9fe:a9fe
|
||||
// (NAT64 wrapping 169.254.169.254) bypasses the stdlib Is* checks above.
|
||||
func embeddedIPv4(ip net.IP) (net.IP, bool) {
|
||||
// Skip addresses that are already IPv4 (4-byte or IPv4-mapped IPv6) - those are
|
||||
// covered by the stdlib Is* checks via To4 normalisation. Re-entering here would
|
||||
// recurse infinitely, because To16 turns an IPv4 back into ::ffff:<ipv4>.
|
||||
if ip.To4() != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
ip16 := ip.To16()
|
||||
if ip16 == nil || len(ip16) != net.IPv6len {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
switch {
|
||||
case nat64WellKnown.Contains(ip16), nat64LocalUse.Contains(ip16),
|
||||
ipv4Compatible.Contains(ip16):
|
||||
// Last 32 bits are the embedded IPv4.
|
||||
return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4(), true
|
||||
case sixToFour.Contains(ip16):
|
||||
// Bits 16..47 are the embedded IPv4.
|
||||
return net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5]).To4(), true
|
||||
case teredo.Contains(ip16):
|
||||
// Bits 96..127 are the embedded IPv4 XOR'd with 0xFFFFFFFF.
|
||||
x := binary.BigEndian.Uint32(ip16[12:16]) ^ 0xFFFFFFFF
|
||||
b := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(b, x)
|
||||
return net.IPv4(b[0], b[1], b[2], b[3]).To4(), true
|
||||
case ip16[10] == 0x5e && ip16[11] == 0xfe:
|
||||
// ISATAP (RFC 5214) - interface identifier ends with :5efe:<ipv4>. The /64
|
||||
// prefix is not fixed (any subnet can carry ISATAP), so match structurally
|
||||
// on bytes 10-11 and treat bytes 12-15 as the embedded IPv4. Must run after
|
||||
// the fixed-prefix cases above (Teredo can legitimately have 5efe in bytes
|
||||
// 10-11; its embedding takes precedence).
|
||||
return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4(), true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname.
|
||||
|
||||
@@ -19,11 +19,27 @@ func TestIsInternalIP(t *testing.T) {
|
||||
"224.0.0.1", // multicast
|
||||
"100.64.0.1", // CGNAT start
|
||||
"100.127.255.255", // CGNAT end
|
||||
// IPv6 transition forms embedding an internal IPv4 destination — golang/go#79925.
|
||||
"64:ff9b::a9fe:a9fe", // NAT64 well-known (RFC 6052) wrapping 169.254.169.254
|
||||
"64:ff9b:1::a9fe:a9fe", // NAT64 local-use (RFC 8215) wrapping 169.254.169.254
|
||||
"2002:a9fe:a9fe::", // 6to4 (RFC 3056) wrapping 169.254.169.254
|
||||
"::a9fe:a9fe", // IPv4-compatible IPv6 (RFC 4291) wrapping 169.254.169.254
|
||||
"64:ff9b::7f00:1", // NAT64 wrapping 127.0.0.1
|
||||
"2002:0a00:0001::", // 6to4 wrapping 10.0.0.1
|
||||
"::ffff:169.254.169.254", // IPv4-mapped (also caught by stdlib via To4)
|
||||
"::5efe:a9fe:a9fe", // ISATAP (RFC 5214) wrapping 169.254.169.254
|
||||
"2001:db8::5efe:7f00:1", // ISATAP under a documentation prefix wrapping 127.0.0.1
|
||||
"fec0::1", // deprecated site-local (RFC 3879 / RFC 4291 §2.5.7)
|
||||
"2001:db8::1", // documentation prefix (RFC 3849)
|
||||
"2001:db8::5efe:0808:0808", // documentation prefix (blocked regardless of embedded IPv4)
|
||||
}
|
||||
external := []string{
|
||||
"8.8.8.8",
|
||||
"1.1.1.1",
|
||||
"100.128.0.1", // just outside CGNAT range
|
||||
"100.128.0.1", // just outside CGNAT range
|
||||
"2001:4860:4860::8888", // Google public DNS over IPv6
|
||||
"2002:0808:0808::", // 6to4 wrapping 8.8.8.8 (public IPv4)
|
||||
"64:ff9b::0808:0808", // NAT64 wrapping 8.8.8.8 (public IPv4)
|
||||
}
|
||||
|
||||
for _, s := range internal {
|
||||
|
||||
Reference in New Issue
Block a user