Incomplete fix for GHSA-qq8m-8p8v-x4xg: IPv6 transition addresses (NAT64/6to4/Teredo) still bypass both SSRF guards
Summary
The fix for GHSA-qq8m-8p8v-x4xg added IsLinkLocalUnicast() and IsUnspecified() checks to isPrivateIP(), but did not add extraction of embedded IPv4 addresses from IPv6 transition address formats. Both SSRF guard functions (isPrivateIP in SSRFSafeDialer and CheckHostSSRF in the agent HTTP tooling) remain bypassable via NAT64 (64:ff9b::/96), 6to4 (2002::/16), and Teredo (2001:0000::/32) addresses that encode private/loopback IPv4 destinations. This allows full-read SSRF against internal services in SafeMode deployments.
Affected component / versions
- Package: siyuan-note/siyuan
- Affected versions: all versions through v3.8.0-beta.2 (current), including the "patched" v3.7.4
- Affected files:
kernel/util/net.go (SSRFSafeDialer TCP-level control)
kernel/util/httprequest.go (CheckHostSSRF pre-flight DNS check, used by agent web_fetch and http_request tools)
Details
Root cause (CWE-918)
Both SSRF guard functions rely exclusively on Go stdlib IP classification methods that do not handle IPv6 transition addresses.
Guard 1 -- kernel/util/net.go:148-149:
func isPrivateIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified()
}
Guard 2 -- kernel/util/httprequest.go:41-53:
func CheckHostSSRF(host string) error {
ips, err := net.LookupIP(host)
if err != nil {
return errors.New("failed to resolve host: " + err.Error())
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified() {
return errors.New("access to private/internal IP is prohibited")
}
}
return nil
}
Go's net.IP.IsLoopback(), IsPrivate(), IsLinkLocalUnicast(), and IsUnspecified() operate on the raw IPv6 byte representation. They classify 64:ff9b::127.0.0.1 as a non-private, non-loopback global unicast address because:
IsLoopback() only matches ::1 and 127.0.0.0/8 (the latter only for 4-byte IPs)
IsPrivate() only matches 10/8, 172.16/12, 192.168/16, fc00::/7 -- it does NOT recognize NAT64, 6to4, or Teredo prefixes
IsLinkLocalUnicast() only matches fe80::/10 and 169.254/16
IsUnspecified() only matches :: and 0.0.0.0
None of these methods extract the embedded IPv4 address from transition encodings.
Transition address formats that bypass both guards
| Format |
Prefix |
Encoding of 127.0.0.1 |
RFC |
| NAT64 well-known prefix |
64:ff9b::/96 |
64:ff9b::7f00:1 |
RFC 6052 |
| NAT64 local-use |
64:ff9b:1::/48 |
64:ff9b:1::7f00:1 |
RFC 8215 |
| 6to4 |
2002::/16 |
2002:7f00:0001::1 |
RFC 3056 |
| Teredo |
2001:0000::/32 |
2001:0000:xxxx:xxxx:xxxx:xxxx:807f:fffe |
RFC 4380 |
| IPv4-compatible (deprecated) |
::/96 |
::127.0.0.1 |
RFC 4291 |
Reachability / trust boundary
SafeMode is the trust boundary SiYuan intends to enforce when the instance is network-exposed. The SSRF guards were explicitly introduced to prevent agents from being tricked into accessing internal services. Transition address bypass defeats this protection completely -- the attacker controls the URL fed to the agent, and the guard checks the resolved IP but fails to detect that the IPv6 address encodes a private IPv4 destination. The actual TCP connection routes to the internal host via the operating system's transition mechanism or a NAT64 gateway.
Attack chain
- Attacker crafts a URL with an IPv6 transition address that encodes an internal IPv4 target (e.g.,
http://[64:ff9b::7f00:1]:8080/admin/secret).
- The URL is passed to a SiYuan agent tool (web_fetch or http_request) or any HTTP endpoint that uses
SSRFSafeDialer.
CheckHostSSRF resolves the host to 64:ff9b::7f00:1. All four stdlib checks return false. The request is allowed.
- The SSRFSafeDialer's Control hook receives the same IP at dial time.
isPrivateIP returns false. The connection proceeds.
- The OS routes the connection to
127.0.0.1:8080 via NAT64 translation. The response is returned to the attacker.
Impact
Full-read SSRF against internal services (cloud metadata at 169.254.169.254, localhost admin panels, internal APIs) in SafeMode deployments. The impact is identical to the original GHSA-qq8m-8p8v-x4xg because the same guard functions are bypassed via the same class of addresses that were specifically reported but not addressed in the fix.
Proof of concept
Bypass verification (Go playground equivalent):
package main
import (
"fmt"
"net"
)
func isPrivateIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified()
}
func main() {
vectors := []string{
"64:ff9b::7f00:1", // NAT64 -> 127.0.0.1
"64:ff9b::c0a8:101", // NAT64 -> 192.168.1.1
"64:ff9b::a9fe:a9fe", // NAT64 -> 169.254.169.254
"2002:7f00:0001::1", // 6to4 -> 127.0.0.1
"2002:c0a8:0101::1", // 6to4 -> 192.168.1.1
"::ffff:127.0.0.1", // IPv4-mapped (also passes)
}
for _, v := range vectors {
ip := net.ParseIP(v)
fmt.Printf("%-30s isPrivateIP=%v\n", v, isPrivateIP(ip))
}
}
Output:
64:ff9b::7f00:1 isPrivateIP=false
64:ff9b::c0a8:101 isPrivateIP=false
64:ff9b::a9fe:a9fe isPrivateIP=false
2002:7f00:0001::1 isPrivateIP=false
2002:c0a8:0101::1 isPrivateIP=false
::ffff:127.0.0.1 isPrivateIP=false
All six vectors return false, meaning the SSRF guard allows the connection.
Negative control (direct IPv4, correctly blocked):
127.0.0.1 isPrivateIP=true
192.168.1.1 isPrivateIP=true
10.0.0.1 isPrivateIP=true
Remediation / fix
Extract the embedded IPv4 from all transition encodings before classification. Add this helper and call it at the top of both isPrivateIP and CheckHostSSRF:
// extractEmbeddedIPv4 returns the inner IPv4 if ip is an IPv6 transition
// address; otherwise returns ip unchanged.
func extractEmbeddedIPv4(ip net.IP) net.IP {
if ip4 := ip.To4(); ip4 != nil {
return ip4 // already IPv4 or IPv4-mapped
}
ip16 := ip.To16()
if ip16 == nil {
return ip
}
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052)
if ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b {
if isZero(ip16[4:12]) {
return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])
}
}
// 6to4: 2002:AABB:CCDD::/48 -> A.B.C.D (RFC 3056)
if ip16[0] == 0x20 && ip16[1] == 0x02 {
return net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5])
}
// Teredo: 2001:0000:....:XXXX:YYYY -> obfuscated client IPv4 (RFC 4380)
if ip16[0] == 0x20 && ip16[1] == 0x01 && ip16[2] == 0x00 && ip16[3] == 0x00 {
return net.IPv4(ip16[12]^0xff, ip16[13]^0xff, ip16[14]^0xff, ip16[15]^0xff)
}
// IPv4-compatible ::/96 (deprecated, RFC 4291 sec 2.5.5.1)
if isZero(ip16[0:12]) && !isZero(ip16[12:16]) {
return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])
}
return ip
}
func isZero(b []byte) bool {
for _, v := range b { if v != 0 { return false } }
return true
}
Then update both guards:
func isPrivateIP(ip net.IP) bool {
ip = extractEmbeddedIPv4(ip)
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified()
}
Credit
Reported by tonghuaroot (tonghuaroot@gmail.com).
Incomplete fix for GHSA-qq8m-8p8v-x4xg: IPv6 transition addresses (NAT64/6to4/Teredo) still bypass both SSRF guards
Summary
The fix for GHSA-qq8m-8p8v-x4xg added
IsLinkLocalUnicast()andIsUnspecified()checks toisPrivateIP(), but did not add extraction of embedded IPv4 addresses from IPv6 transition address formats. Both SSRF guard functions (isPrivateIPin SSRFSafeDialer andCheckHostSSRFin the agent HTTP tooling) remain bypassable via NAT64 (64:ff9b::/96), 6to4 (2002::/16), and Teredo (2001:0000::/32) addresses that encode private/loopback IPv4 destinations. This allows full-read SSRF against internal services in SafeMode deployments.Affected component / versions
kernel/util/net.go(SSRFSafeDialer TCP-level control)kernel/util/httprequest.go(CheckHostSSRF pre-flight DNS check, used by agent web_fetch and http_request tools)Details
Root cause (CWE-918)
Both SSRF guard functions rely exclusively on Go stdlib IP classification methods that do not handle IPv6 transition addresses.
Guard 1 --
kernel/util/net.go:148-149:Guard 2 --
kernel/util/httprequest.go:41-53:Go's
net.IP.IsLoopback(),IsPrivate(),IsLinkLocalUnicast(), andIsUnspecified()operate on the raw IPv6 byte representation. They classify64:ff9b::127.0.0.1as a non-private, non-loopback global unicast address because:IsLoopback()only matches::1and127.0.0.0/8(the latter only for 4-byte IPs)IsPrivate()only matches10/8,172.16/12,192.168/16,fc00::/7-- it does NOT recognize NAT64, 6to4, or Teredo prefixesIsLinkLocalUnicast()only matchesfe80::/10and169.254/16IsUnspecified()only matches::and0.0.0.0None of these methods extract the embedded IPv4 address from transition encodings.
Transition address formats that bypass both guards
64:ff9b::/9664:ff9b::7f00:164:ff9b:1::/4864:ff9b:1::7f00:12002::/162002:7f00:0001::12001:0000::/322001:0000:xxxx:xxxx:xxxx:xxxx:807f:fffe::/96::127.0.0.1Reachability / trust boundary
SafeMode is the trust boundary SiYuan intends to enforce when the instance is network-exposed. The SSRF guards were explicitly introduced to prevent agents from being tricked into accessing internal services. Transition address bypass defeats this protection completely -- the attacker controls the URL fed to the agent, and the guard checks the resolved IP but fails to detect that the IPv6 address encodes a private IPv4 destination. The actual TCP connection routes to the internal host via the operating system's transition mechanism or a NAT64 gateway.
Attack chain
http://[64:ff9b::7f00:1]:8080/admin/secret).SSRFSafeDialer.CheckHostSSRFresolves the host to64:ff9b::7f00:1. All four stdlib checks return false. The request is allowed.isPrivateIPreturns false. The connection proceeds.127.0.0.1:8080via NAT64 translation. The response is returned to the attacker.Impact
Full-read SSRF against internal services (cloud metadata at
169.254.169.254, localhost admin panels, internal APIs) in SafeMode deployments. The impact is identical to the original GHSA-qq8m-8p8v-x4xg because the same guard functions are bypassed via the same class of addresses that were specifically reported but not addressed in the fix.Proof of concept
Bypass verification (Go playground equivalent):
Output:
All six vectors return
false, meaning the SSRF guard allows the connection.Negative control (direct IPv4, correctly blocked):
Remediation / fix
Extract the embedded IPv4 from all transition encodings before classification. Add this helper and call it at the top of both
isPrivateIPandCheckHostSSRF:Then update both guards:
Credit
Reported by tonghuaroot (tonghuaroot@gmail.com).