{"id":"CVE-2026-53522","title":"Nezha Monitoring: Unbounded WebSocket Streams — Resource Exhaustion DoS","summary":"Nezha Monitoring: Unbounded WebSocket Streams — Resource Exhaustion DoS","severity":"medium","cvss":6.5,"cwe":["CWE-770"],"vendor":"nezhahq","product":"github.com/nezhahq/nezha","ecosystem":"go","affected":["github.com/nezhahq/nezha >= 1.0.0, < 2.2.0"],"patched":["github.com/nezhahq/nezha 2.2.0"],"published":"2026-06-26","updated":"2026-06-26","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-jg62-j5h6-8mpq","references":[{"url":"https://github.com/nezhahq/nezha/security/advisories/GHSA-jg62-j5h6-8mpq"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-53522"},{"url":"https://github.com/advisories/GHSA-jg62-j5h6-8mpq"}],"tags":["ghsa","go"],"epss":0.0029,"epssPercentile":0.21786,"ingestedAt":"2026-06-29T13:24:35.078Z","slug":"CVE-2026-53522","body":"## Overview\n\n## 1. Description\n\nThe Nezha dashboard exposes two endpoints that create long-lived WebSocket streams to monitored agents:\n\n- `POST /api/v1/terminal` → `createTerminal()` (terminal.go:27-67)\n- `POST /api/v1/file` → `createFM()` (fm.go:28-67)\n\nBoth call `rpc.NezhaHandlerSingleton.CreateStream(streamId, ...)` which inserts a new `ioStreamContext` into an **unbounded** `map[string]*ioStreamContext` (`s.ioStreams` in `io_stream.go:59-67`). There is **no per-user rate limit, no global semaphore, and no per-server connection cap**. Each stream allocates:\n\n1. A `ioStreamContext` struct with several channels and sync primitives\n2. Two goroutines via `StartStream()` (io_stream.go:358-369) — bidirectional `io.CopyBuffer`\n3. A gRPC IOStream between the dashboard and the agent\n4. An agent-side PTY/shell process\n\n**Vulnerable code:**\n\n`terminal.go:27-67` — `createTerminal`:\n```go\nfunc createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) {\n    // ... validation ...\n    rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID)\n    // ... sends TaskTypeTerminalGRPC to agent ...\n    return &model.CreateTerminalResponse{...}, nil\n}\n```\n\n`fm.go:28-67` — `createFM`:\n```go\nfunc createFM(c *gin.Context) (*model.CreateFMResponse, error) {\n    // ... validation ...\n    rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID)\n    // ... sends TaskTypeFM to agent ...\n    return &model.CreateFMResponse{...}, nil\n}\n```\n\n`io_stream.go:55-67` — `CreateStreamWithPurpose` (inserts into unbounded map):\n```go\nfunc (s *NezhaHandler) CreateStreamWithPurpose(...) {\n    s.ioStreamMutex.Lock()\n    defer s.ioStreamMutex.Unlock()\n    s.ioStreams[streamId] = &ioStreamContext{\n        creatorUserID:  creatorUserID,\n        targetServerID: targetServerID,\n        purpose:        purpose,\n        userIoConnectCh:  make(chan struct{}),\n        agentIoConnectCh: make(chan struct{}),\n        revokedCh:        make(chan struct{}),\n    }\n}\n```\n\n`io_stream.go:319-372` — `StartStream` spawns two goroutines per stream:\n```go\nfunc (s *NezhaHandler) StartStream(streamId string, timeout time.Duration) error {\n    // ...\n    go func() {\n        _, innerErr := io.CopyBuffer(userIo, agentIo, bp.buf)\n        errCh <- innerErr\n    }()\n    go func() {\n        _, innerErr := io.CopyBuffer(agentIo, userIo, bp.buf)\n        errCh <- innerErr\n    }()\n    return <-errCh\n}\n```\n\nThe `NezhaHandler.ioStreams` map is initialized as a plain `make(map[string]*ioStreamContext)` in `nezha.go:36` — no capacity limit, no eviction policy beyond explicit `CloseStream` / `RevokeStreamsForServer`.\n\nThe `HasPermission` check at terminal.go:41-43 and fm.go:43-45 controls **access scope** but does **not** limit creation volume. A user with `ScopeServerExec` (terminal) or `ScopeServerRead+Write+Delete` (file manager) can open unlimited streams.\n\n## 2. PoC\n\nA conceptual attack (no Docker needed):\n\n```\n# As an authenticated user with a valid JWT or PAT:\nfor i in {1..1000}; do\n  curl -X POST \"https://dashboard.example.com/api/v1/terminal\" \\\n    -H \"Authorization: Bearer $JWT\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"server_id\": 1}' &\ndone\nwait\n```\n\nEach request:\n- Creates a new stream entry in `ioStreams`\n- Sends a `TaskTypeTerminalGRPC` task to the agent\n- When the WebSocket attachment occurs (`GET /ws/terminal/{id}`), spawns 2 goroutines for I/O relay and allocates a 1 MB buffer per goroutine\n\nThe attack targets three resource domains:\n1. **Dashboard memory/goroutines** — each stream adds goroutines, channels, and buffers\n2. **Agent resources** — each stream spawns a PTY/shell process on the monitored server\n3. **gRPC connection pool** — concurrent IOStreams consume gRPC multiplexing capacity\n\nThe `POST /file (createFM)` endpoint provides an alternative path with the same unbounded behavior, using `ScopeServerRead+Write+Delete` instead of `ScopeServerExec`.\n\n## 3. Impact\n\n- **Denial of Service against the dashboard**: memory exhaustion, goroutine starvation, or gRPC stream table overflow from rapid stream creation\n- **Denial of Service against monitored agents**: each terminal session spawns a PTY process on the agent — an attacker can crash or degrade all agents behind the dashboard\n- **Operational cascade**: if the dashboard OOMs, all agent monitoring and alerting is lost\n- **PAT connection-registry bypass**: rapid create-connect-disconnect cycles may evade cleanup tracking\n\nThe attack requires only authenticated access with standard scopes — no special privileges. Any team member with terminal access to a server can DoS the entire infrastructure.\n\n## 4. Remediation\n\nImplement layered rate limiting and concurrency control:\n\n1. **Per-user stream cap** in `CreateStream` — reject if the user already has N active streams (e.g., 10 per user):\n   ```go\n   func (s *NezhaHandler) CreateStreamWithPurpose(...) {\n       s.ioStreamMutex.Lock()\n       defer s.ioStreamMutex.Unlock()\n       count := 0\n       for _, ctx := range s.ioStreams {\n           if ctx.creatorUserID == creatorUserID { count++ }\n       }\n       if count >= maxStreamsPerUser { return error }\n       // ... existing code ...\n   }\n   ```\n\n2. **Per-server semaphore** — limit concurrent streams to any single server (e.g., 20 per server)\n\n3. **Rate limiter on `createTerminal` and `createFM`** — mirror the existing MCP rate limiter (`mcp_ratelimit.go`) for legacy WebSocket endpoints\n\n4. **Add a configurable `MaxStreamsPerUser` / `MaxStreamsPerServer` setting** so operators can tune limits without code changes\n\n## Affected packages\n\n- `github.com/nezhahq/nezha >= 1.0.0, < 2.2.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/nezhahq/nezha 2.2.0`","depth":"sunlit","depthScore":36,"depthScoreParts":{"impact":35.8,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}