GHSA-chwm-m7g7-685g Dragonfly scheduler v1 and v2 gRPC unauthenticated SSRF via attacker-controlled PeerHost in DownloadTinyFile
SummaryThe 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).SeverityMedium. 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 componentRepository: `dragonflyoss/dragonfly`Go module: `d7y.io/dragonfly/v2`Component: scheduler, v1 gRPC protocolAffected 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 causeThe 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: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 verificationAll 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 build2) Confirming the deployed scheduler binary is unauthenticated by defaultThe 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`: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`:`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 sinkBuild and run:5) Captured output (positive control)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 targetsThis 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:Output:`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 legitimateThis 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:Output:After the fix, loopback and link-local metadata destinations are rejected, while a legitimate global-unicast peer download still succeeds.ImpactA remote, unauthenticated attacker who can reach the scheduler's gRPC port can: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`.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 / RemediationApply 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: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.ReferencesSink: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/resource/standard/peer.go#L435-L478Attacker-controlled host ingestion: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L816-L845Trigger (handlePeerSuccess): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/service/service_v1.go#L1176-L1202Default insecure credentials: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/scheduler/scheduler.go#L235-L246gRPC interceptor chain (no auth): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/rpc/scheduler/server/server.go#L71-L90Sibling guard (manager preheat SafeDialer): https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/internal/job/image.go#L211SafeDialer / safeSocketControl: https://github.com/dragonflyoss/dragonfly/blob/0822e3aecc3369017d6b25c9441ff6f318129b31/pkg/net/http/http.go#L50-L80
CVSS v4.0 score: 5.5