CVE-2026-53522Medium· 6.5▾ SunlitNezha Monitoring: Unbounded WebSocket Streams — Resource Exhaustion DoS
▾ Sunlit zone — Low / medium · no exploitation signal
impact 35.8 · likelihood 0.1 · exploitation 0
Need a working PoC? Pro members can cast a request and our team develops one — it lands right here.
Exploit-prediction probability, daily snapshots since Jul 4.
Disclosure to exploitation, from the record and what we observed since indexing it.
Disclosed via GHSA
0.3%
0.3% → 0.3%
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
github.com/nezhahq/nezha >= 1.0.0, < 2.2.0Upgrade to a patched release:
github.com/nezhahq/nezha 2.2.0Connected by shared product, vendor, weakness, or advisory.
GHSA-rf68-8gjr-36q7LowNezha: OAuth2 redirect_uri Host header injection regression when dashboard_host is empty
CVE-2026-62283Critical· 9.9Nezha Monitoring is a self-hostable, lightweight, servers and websites monitoring and O&M tool
CVE-2026-48119High· 7.1Nezha's authenticated agents can forge service-monitor results for other users' services
CVE-2026-49396High· 7.1Nezha has cross-site GET request that can trigger stored cron commands on a victim's agents
CVE-2026-49397Medium· 5.3Nezha's private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data
GHSA-q6xx-5vr8-p898Critical· 9.9Nezha vulnerable to cross-tenant terminal/file-manager session hijack via WebSocket stream UUID without ownership check