GHSA-JG62-J5H6-8MPQ
Vulnerability from github – Published: 2026-06-26 23:04 – Updated: 2026-06-26 23:041. Description
The Nezha dashboard exposes two endpoints that create long-lived WebSocket streams to monitored agents:
POST /api/v1/terminal→createTerminal()(terminal.go:27-67)POST /api/v1/file→createFM()(fm.go:28-67)
Both 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:
- A
ioStreamContextstruct with several channels and sync primitives - Two goroutines via
StartStream()(io_stream.go:358-369) — bidirectionalio.CopyBuffer - A gRPC IOStream between the dashboard and the agent
- An agent-side PTY/shell process
Vulnerable code:
terminal.go:27-67 — createTerminal:
func createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) {
// ... validation ...
rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID)
// ... sends TaskTypeTerminalGRPC to agent ...
return &model.CreateTerminalResponse{...}, nil
}
fm.go:28-67 — createFM:
func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
// ... validation ...
rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID)
// ... sends TaskTypeFM to agent ...
return &model.CreateFMResponse{...}, nil
}
io_stream.go:55-67 — CreateStreamWithPurpose (inserts into unbounded map):
func (s *NezhaHandler) CreateStreamWithPurpose(...) {
s.ioStreamMutex.Lock()
defer s.ioStreamMutex.Unlock()
s.ioStreams[streamId] = &ioStreamContext{
creatorUserID: creatorUserID,
targetServerID: targetServerID,
purpose: purpose,
userIoConnectCh: make(chan struct{}),
agentIoConnectCh: make(chan struct{}),
revokedCh: make(chan struct{}),
}
}
io_stream.go:319-372 — StartStream spawns two goroutines per stream:
func (s *NezhaHandler) StartStream(streamId string, timeout time.Duration) error {
// ...
go func() {
_, innerErr := io.CopyBuffer(userIo, agentIo, bp.buf)
errCh <- innerErr
}()
go func() {
_, innerErr := io.CopyBuffer(agentIo, userIo, bp.buf)
errCh <- innerErr
}()
return <-errCh
}
The 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.
The 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.
2. PoC
A conceptual attack (no Docker needed):
# As an authenticated user with a valid JWT or PAT:
for i in {1..1000}; do
curl -X POST "https://dashboard.example.com/api/v1/terminal" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"server_id": 1}' &
done
wait
Each request:
- Creates a new stream entry in ioStreams
- Sends a TaskTypeTerminalGRPC task to the agent
- When the WebSocket attachment occurs (GET /ws/terminal/{id}), spawns 2 goroutines for I/O relay and allocates a 1 MB buffer per goroutine
The attack targets three resource domains: 1. Dashboard memory/goroutines — each stream adds goroutines, channels, and buffers 2. Agent resources — each stream spawns a PTY/shell process on the monitored server 3. gRPC connection pool — concurrent IOStreams consume gRPC multiplexing capacity
The POST /file (createFM) endpoint provides an alternative path with the same unbounded behavior, using ScopeServerRead+Write+Delete instead of ScopeServerExec.
3. Impact
- Denial of Service against the dashboard: memory exhaustion, goroutine starvation, or gRPC stream table overflow from rapid stream creation
- 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
- Operational cascade: if the dashboard OOMs, all agent monitoring and alerting is lost
- PAT connection-registry bypass: rapid create-connect-disconnect cycles may evade cleanup tracking
The 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.
4. Remediation
Implement layered rate limiting and concurrency control:
-
Per-user stream cap in
CreateStream— reject if the user already has N active streams (e.g., 10 per user):go func (s *NezhaHandler) CreateStreamWithPurpose(...) { s.ioStreamMutex.Lock() defer s.ioStreamMutex.Unlock() count := 0 for _, ctx := range s.ioStreams { if ctx.creatorUserID == creatorUserID { count++ } } if count >= maxStreamsPerUser { return error } // ... existing code ... } -
Per-server semaphore — limit concurrent streams to any single server (e.g., 20 per server)
-
Rate limiter on
createTerminalandcreateFM— mirror the existing MCP rate limiter (mcp_ratelimit.go) for legacy WebSocket endpoints -
Add a configurable
MaxStreamsPerUser/MaxStreamsPerServersetting so operators can tune limits without code changes
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/nezhahq/nezha"
},
"ranges": [
{
"events": [
{
"introduced": "1.0.0"
},
{
"fixed": "2.2.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-53522"
],
"database_specific": {
"cwe_ids": [
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-26T23:04:18Z",
"nvd_published_at": "2026-06-12T22:16:52Z",
"severity": "MODERATE"
},
"details": "## 1. Description\n\nThe Nezha dashboard exposes two endpoints that create long-lived WebSocket streams to monitored agents:\n\n- `POST /api/v1/terminal` \u2192 `createTerminal()` (terminal.go:27-67)\n- `POST /api/v1/file` \u2192 `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) \u2014 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` \u2014 `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 \u0026model.CreateTerminalResponse{...}, nil\n}\n```\n\n`fm.go:28-67` \u2014 `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 \u0026model.CreateFMResponse{...}, nil\n}\n```\n\n`io_stream.go:55-67` \u2014 `CreateStreamWithPurpose` (inserts into unbounded map):\n```go\nfunc (s *NezhaHandler) CreateStreamWithPurpose(...) {\n s.ioStreamMutex.Lock()\n defer s.ioStreamMutex.Unlock()\n s.ioStreams[streamId] = \u0026ioStreamContext{\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` \u2014 `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 \u003c- innerErr\n }()\n go func() {\n _, innerErr := io.CopyBuffer(agentIo, userIo, bp.buf)\n errCh \u003c- innerErr\n }()\n return \u003c-errCh\n}\n```\n\nThe `NezhaHandler.ioStreams` map is initialized as a plain `make(map[string]*ioStreamContext)` in `nezha.go:36` \u2014 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 \u0027{\"server_id\": 1}\u0027 \u0026\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** \u2014 each stream adds goroutines, channels, and buffers\n2. **Agent resources** \u2014 each stream spawns a PTY/shell process on the monitored server\n3. **gRPC connection pool** \u2014 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 \u2014 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 \u2014 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` \u2014 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 \u003e= maxStreamsPerUser { return error }\n // ... existing code ...\n }\n ```\n\n2. **Per-server semaphore** \u2014 limit concurrent streams to any single server (e.g., 20 per server)\n\n3. **Rate limiter on `createTerminal` and `createFM`** \u2014 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",
"id": "GHSA-jg62-j5h6-8mpq",
"modified": "2026-06-26T23:04:18Z",
"published": "2026-06-26T23:04:18Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nezhahq/nezha/security/advisories/GHSA-jg62-j5h6-8mpq"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-53522"
},
{
"type": "PACKAGE",
"url": "https://github.com/nezhahq/nezha"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "Nezha Monitoring: Unbounded WebSocket Streams \u2014 Resource Exhaustion DoS"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.