Nezha Monitoring: Unbounded WebSocket Streams — Resource Exhaustion DoS
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:
ioStreamContext struct with several channels and sync primitivesStartStream() (io_stream.go:358-369) — bidirectional io.CopyBufferVulnerable 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.
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:
ioStreamsTaskTypeTerminalGRPC task to the agentGET /ws/terminal/{id}), spawns 2 goroutines for I/O relay and allocates a 1 MB buffer per goroutineThe attack targets three resource domains:
The POST /file (createFM) endpoint provides an alternative path with the same unbounded behavior, using ScopeServerRead+Write+Delete instead of ScopeServerExec.
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.
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):
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 createTerminal and createFM — mirror the existing MCP rate limiter (mcp_ratelimit.go) for legacy WebSocket endpoints
Add a configurable MaxStreamsPerUser / MaxStreamsPerServer setting so operators can tune limits without code changes
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.