github.com/nezhahq/nezha: Transfer WebSocket endpoint allows unbounded subscriber allocation
What fixes it
No source in this snapshot names a fixed version.
Description
Maintainer resolution
Fixed in v2.3.15 by commit 36b742a7. Affected versions: >= 2.0.13, < 2.3.15. Patched versions: >= 2.3.15.
Summary
The Nezha dashboard's server-transfer WebSocket endpoint GET /api/v1/ws/transfer allows any authenticated user to open an unbounded number of concurrent connections. Each connection is registered via ServerTransferClass.Subscribe (service/singleton/server_transfer.go), which allocates a buffered channel and inserts it into an in-process subs map with no per-user, per-IP, or global capacity check. The connection handler spawns two goroutines and holds around 72 KB of WebSocket buffers per connection for its entire lifetime, with no server-side idle timeout. An attacker with any authenticated dashboard session can exhaust dashboard file descriptors, memory, and goroutine capacity, cutting off new connections including agent gRPC reconnections.
Root Cause
Every code path that allocates persistent per-connection resources on an authenticated endpoint must enforce a bounded connection count. The transferStream handler and its underlying Subscribe routine do not. Subscribe unconditionally allocates a new buffered channel and stores it under an atomically incremented id, with no read of the current map size and no branch that could refuse the allocation:
// service/singleton/server_transfer.go
func (c *ServerTransferClass) Subscribe() (uint64, <-chan *model.ServerTransfer) {
c.subMu.Lock()
defer c.subMu.Unlock()
id := atomic.AddUint64(&c.nextSubID, 1)
ch := make(chan *model.ServerTransfer, 16)
c.subs[id] = ch
return id, ch
}The handler that drives this from a WebSocket similarly performs no connection-count check between authentication and the upgrade:
// cmd/dashboard/controller/transfer.go (transferStream)
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return nil, newWsError("%v", err)
}
subID, ch := singleton.ServerTransferShared.Subscribe()
// no per-user/per-IP/global limit; every subscription succeedsThe stream-count quota introduced by GHSA-jg62-j5h6-8mpq (service/rpc/io_stream_quota.go) applies only to NezhaHandler.CreateStreamWithPurpose, which is called from the terminal and file manager endpoints. The transfer subscription path is a separate subsystem operating on ServerTransferShared.subs, so that quota provides no coverage.
Impact
An authenticated dashboard user reaching the endpoint (JWT cookie holders bypass the restScopeMiddleware scope check because it only enforces scopes for PAT-authenticated requests) can hold N concurrent WebSocket connections. Each connection costs roughly 32 KB read buffer plus 32 KB write buffer plus two goroutines plus a subscriber channel plus one file descriptor. At N in the low thousands, the dashboard process exhausts file descriptors and cannot accept new inbound connections, including agent gRPC reconnects, causing total loss of monitoring for the duration of the attack. At higher N, goroutine scheduler pressure and GC overhead degrade all in-flight operations, and OOM termination becomes likely. The broadcast routine holds subMu for O(N) iterations per transfer event, so contention on that lock further amplifies degradation once transfers occur. No user interaction is required and the attack sustains as long as the attacker keeps the TCP connections alive; a standards-compliant WebSocket client responds to server pings automatically, so no additional traffic is needed to keep sessions open.
Proof of Concept
Prerequisites: - Any dashboard user token (JWT cookie or PAT with server:read equivalent access). Because restScopeMiddleware short-circuits when the request is JWT-authenticated, no specific transfer:read scope is required for cookie-based sessions. - Network reachability to the dashboard's HTTP(S) endpoint. - Sufficient client-side file descriptors (a modest ulimit -n increase on the attacker).
# transfer_subscriber_exhaust.py
import asyncio
import ssl
import os
import websockets
URL = f"wss://dashboard.example.com/api/v1/ws/transfer"
HEADERS = {"Cookie": os.environ["DASHBOARD_COOKIE"]}
N = 5000
async def hold_one(i, ctx):
try:
async with websockets.connect(URL, ssl=ctx, extra_headers=HEADERS) as ws:
await asyncio.sleep(3600) # keep the subscription open
except Exception as exc:
print(f"conn {i}: {exc}")
async def main():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
await asyncio.gather(*[hold_one(i, ctx) for i in range(N)])
asyncio.run(main())Observed server behavior:
# during the run, the dashboard's open file descriptors climb by ~N, memory # grows by roughly N * 72 KiB, and 2*N handler/reader goroutines accumulate; # new connections (including agent gRPC reconnects) fail once the process FD # limit is reached; killing the attacker restores service after the sockets close.
The corresponding IO stream endpoints refuse a similar test at 20 concurrent streams per user (per the fix in GHSA-jg62-j5h6-8mpq), demonstrating the missing symmetry.
Attack Chain
- Exposure: The route
GET /api/v1/ws/transferis registered in the authenticated route group (cmd/dashboard/controller/controller.go) in every default deployment and ships enabled. - Control: The attacker fully controls the number and lifetime of concurrent WebSocket connections. JWT-authenticated dashboard sessions bypass
restScopeMiddleware(ScopeTransferRead)because that middleware callsc.Next()when no PAT is present (service/singleton/api_token_scope.go). - Path: For each connection,
transferStreamperforms the WebSocket upgrade, callssingleton.ServerTransferShared.Subscribe, spawns a reader goroutine that blocks inconn.NextReaderto observe client disconnect, and enters a select loop over the subscriber channel and a 30 second ping ticker. - Guard: There is no per-user, per-IP, or global connection cap; the
http.Serverin cmd/dashboard/main.go only setsReadHeaderTimeoutand noConnStatelimiter.registerPATConnectionrecords revocation hooks and is explicitly documented as unfit for policy decisions (countForTokencomment).Subscribehas nolen(c.subs)check. NoSetReadDeadlineis set, and the ping does not evict a well-behaved client. - Primitive: Each connection allocates around 32 KB read buffer, 32 KB write buffer, two goroutines with their stacks, a
chan *model.ServerTransferbuffered at 16, an internalclosedchannel, atime.Ticker, and one file descriptor with associated kernel buffers. - Result: Resource accumulation at attacker-controlled scale drives file descriptor exhaustion, memory pressure, goroutine scheduler saturation, and lock contention in
broadcast. New connection acceptance (including agent gRPC reconnects) fails, monitoring stops, and OOM termination becomes likely at higher connection counts.
Bypass Evidence
The relevant protection is a bounded subscriber or connection count on the transfer WebSocket path. It does not exist. grep -rn "Subscribe(" --include='*.go' in the tree returns transfer.go:183 as the only caller, and reading Subscribe shows a four-line body with no capacity check. GHSA-jg62-j5h6-8mpq (CVE-2026-53522) introduced io_stream_quota.go, whose per-user (20) and per-server (40) caps apply only inside CreateStreamWithPurpose; the transfer path never calls CreateStream or CreateStreamWithPurpose and uses a completely separate data structure (ServerTransferClass.subs vs NezhaHandler.ioStreams). The MCP rate limiter (mcp_ratelimit.go) is wired only to MCP endpoints, not to /ws/transfer. The WAF component only tracks previously offending IPs (failed logins, brute force). The default upgrader's CheckOrigin blocks cross-origin browser requests, not direct API clients using cookie or bearer authentication.
Affected Versions
- Ecosystem:
go - Package:
github.com/nezhahq/nezha - Confirmed affected range:
>= 2.0.13, <= 2.3.14 - Latest release checked:
v2.3.14(GitHub release) - Fix status: not fixed
The transfer subscription path (transferStream and ServerTransferClass.Subscribe) is present in the earliest tag reachable in this checkout, v2.0.13 (commit e816e94), and remains unlimited at the current release v2.3.14 (commit c726930). No commit on any inspected branch adds a subscriber or connection cap. Note that the Go module path published as github.com/nezhahq/nezha predates the project's v2 tag lineage; user-facing deployments consume tagged GitHub releases rather than the go-proxy module.
Suggested Fix
Introduce a bounded per-user (and ideally per-IP or global) subscriber cap for Subscribe, checked while holding subMu, returning an error to the caller when the limit is reached. The transferStream handler must translate that error into a WebSocket close with an informative reason rather than proceeding. A configurable MaxTransferSubscribersPerUser setting sized to legitimate operational needs (single-digit per user) is appropriate; the fix released in GHSA-jg62-j5h6-8mpq already establishes a similar pattern in io_stream_quota.go that can be reused. Setting a bounded SetReadDeadline on the connection and enforcing that inbound frames are unexpected (closing on any received data) would additionally reduce the resource footprint per unauthorized hold. Auditing other singleton subscription patterns for the same missing guard is recommended.
As an interim mitigation, operators can front the dashboard with a reverse proxy that enforces per-source-IP concurrent-connection limits on the transfer WebSocket path (for example, NGINX limit_conn). This narrows exposure to attackers who can distribute across many IPs but does not remove the underlying dashboard-side lack of application-level bounds.
---
Reported by zx (GitHub: @manus-pi).
Text from GitHub Advisory Database
Affected versions 1
- github.com/nezhahq/nezha, go>= 2.0.13, < 2.3.15GitHub