mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
d4ce19219c
The net.IP predicates every SSRF guard is built from read only the literal address bytes, so an IPv6 transition address smuggles an internal IPv4 destination past all of them. 64:ff9b::a9fe:a9fe reaches 169.254.169.254 while IsLoopback, IsPrivate, IsLinkLocalUnicast and To4 all report an ordinary public address, defeating both the webhook URL validator and the restricted outbound transport with the same input. Add securityutil.EmbeddedIPv4Candidates, which unwraps NAT64 (RFC 6052 well-known and RFC 8215 local-use prefixes), 6to4, Teredo, ISATAP, IPv4-compatible and IPv4-translated encodings, and run every candidate through the caller's own policy in both layers. The embedded destination inherits the outer policy rather than a stricter one, so a NAT64 address wrapping a permitted public target stays permitted and AllowPrivateIPs / AllowLoopback relax the embedded check the same way they relax the outer. Reported by tonghuaroot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
3.2 KiB
Go
91 lines
3.2 KiB
Go
package securityutil
|
|
|
|
import "net"
|
|
|
|
// EmbeddedIPv4Candidates returns every IPv4 destination that an IPv6 transition
|
|
// address can encode.
|
|
//
|
|
// SSRF guards are built out of the net.IP predicates - IsLoopback, IsPrivate,
|
|
// IsLinkLocalUnicast and friends - and every one of them inspects the literal
|
|
// 16 bytes it is handed. An IPv6 transition address carries an IPv4 destination
|
|
// somewhere inside those bytes, so a NAT64 address such as 64:ff9b::a9fe:a9fe
|
|
// reaches 169.254.169.254 while reporting itself as an ordinary global unicast
|
|
// address. Callers must run each returned candidate through the same policy
|
|
// they applied to the outer address.
|
|
//
|
|
// Plain IPv4 and IPv4-mapped addresses (::ffff:a.b.c.d) return no candidates:
|
|
// net.IP.To4 already normalises those, so the stdlib predicates see the real
|
|
// destination without help.
|
|
//
|
|
// Candidates in 0.0.0.0/8 are omitted. They are not routable destinations, and
|
|
// including them would make every ::/128 and ::1 look like a transition
|
|
// address when the unspecified and loopback checks already cover those.
|
|
func EmbeddedIPv4Candidates(ip net.IP) []net.IP {
|
|
v6 := ip.To16()
|
|
if v6 == nil || ip.To4() != nil {
|
|
return nil
|
|
}
|
|
|
|
var candidates []net.IP
|
|
add := func(a, b, c, d byte) {
|
|
if a == 0 {
|
|
return
|
|
}
|
|
embedded := net.IPv4(a, b, c, d)
|
|
for _, existing := range candidates {
|
|
if existing.Equal(embedded) {
|
|
return
|
|
}
|
|
}
|
|
candidates = append(candidates, embedded)
|
|
}
|
|
|
|
switch {
|
|
case isZeroIPBytes(v6[0:12]):
|
|
// RFC 4291 IPv4-compatible address (::a.b.c.d). Deprecated, still parsed.
|
|
add(v6[12], v6[13], v6[14], v6[15])
|
|
case isZeroIPBytes(v6[0:8]) && v6[8] == 0xff && v6[9] == 0xff && isZeroIPBytes(v6[10:12]):
|
|
// RFC 2765 IPv4-translated address (::ffff:0:a.b.c.d).
|
|
add(v6[12], v6[13], v6[14], v6[15])
|
|
case v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b:
|
|
if isZeroIPBytes(v6[4:12]) {
|
|
// RFC 6052 Well-Known Prefix 64:ff9b::/96.
|
|
add(v6[12], v6[13], v6[14], v6[15])
|
|
}
|
|
if v6[4] == 0x00 && v6[5] == 0x01 {
|
|
// RFC 8215 Local-Use Prefix 64:ff9b:1::/48. Operators pick the
|
|
// embedding length, so every RFC 6052 layout that fits under a /48
|
|
// is a candidate. Byte 8 is the reserved u octet and is skipped.
|
|
add(v6[6], v6[7], v6[9], v6[10]) // /48
|
|
add(v6[7], v6[9], v6[10], v6[11]) // /56
|
|
add(v6[9], v6[10], v6[11], v6[12]) // /64
|
|
add(v6[12], v6[13], v6[14], v6[15])
|
|
}
|
|
case v6[0] == 0x20 && v6[1] == 0x02:
|
|
// RFC 3056 6to4 (2002::/16) embeds the gateway IPv4 address.
|
|
add(v6[2], v6[3], v6[4], v6[5])
|
|
case v6[0] == 0x20 && v6[1] == 0x01 && v6[2] == 0x00 && v6[3] == 0x00:
|
|
// RFC 4380 Teredo (2001::/32) carries the server IPv4 in bytes 4-7 and
|
|
// the client IPv4 in bytes 12-15, obfuscated as its ones' complement.
|
|
add(v6[4], v6[5], v6[6], v6[7])
|
|
add(^v6[12], ^v6[13], ^v6[14], ^v6[15])
|
|
}
|
|
|
|
// RFC 5214 ISATAP interface identifiers sit under an arbitrary /64, so this
|
|
// is checked independently of the prefix cases above.
|
|
if (v6[8] == 0x00 || v6[8] == 0x02) && v6[9] == 0x00 && v6[10] == 0x5e && v6[11] == 0xfe {
|
|
add(v6[12], v6[13], v6[14], v6[15])
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
func isZeroIPBytes(b []byte) bool {
|
|
for _, octet := range b {
|
|
if octet != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|