VYPR
Medium severity6.5NVD Advisory· Published Jun 12, 2026

CVE-2026-53522

CVE-2026-53522

Description

Nezha Monitoring before 2.2.0 allows unbounded WebSocket streams leading to resource exhaustion via terminal and file-manager endpoints.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Nezha Monitoring before 2.2.0 allows unbounded WebSocket streams leading to resource exhaustion via terminal and file-manager endpoints.

Vulnerability

The Nezha dashboard endpoints POST /api/v1/terminal and POST /api/v1/file create long-lived WebSocket streams to monitored agents. Both call rpc.NezhaHandlerSingleton.CreateStream(), inserting an ioStreamContext into an unbounded map[string]*ioStreamContext (s.ioStreams). There is no per-user rate limit, global semaphore, or per-server connection cap. Affected versions are from 1.0.0 to before 2.2.0 [1].

Exploitation

An authenticated attacker with network access to the dashboard can repeatedly call these endpoints without any rate limiting. Each request creates a stream that allocates an ioStreamContext struct, spawns two goroutines via StartStream(), establishes a gRPC IOStream between the dashboard and the agent, and spawns an agent-side PTY or shell process. By issuing a high volume of requests, the attacker exhausts system resources [1].

Impact

Successful exploitation leads to resource exhaustion on both the dashboard and monitored agents. The unbounded accumulation of streams consumes memory, goroutines, gRPC connections, and agent-side processes, potentially causing system instability, denial of service, or crash [1].

Mitigation

The vulnerability has been patched in version 2.2.0. Users should upgrade to this version or later. No workaround is available for earlier versions [1].

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
77ad812e79dc

fix(rpc): cap concurrent IO streams per user and per server

https://github.com/nezhahq/nezhanaibaJun 5, 2026Fixed in 2.2.0via llm-release-walk
7 files changed · +241 9
  • cmd/dashboard/controller/fm.go+3 1 modified
    @@ -49,7 +49,9 @@ func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
     		return nil, err
     	}
     
    -	rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID)
    +	if err := rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID); err != nil {
    +		return nil, err
    +	}
     
     	fmData, _ := json.Marshal(&model.TaskFM{
     		StreamID: streamId,
    
  • cmd/dashboard/controller/mcp_transfer.go+3 1 modified
    @@ -962,7 +962,9 @@ func openFsTransferStream(ctx context.Context, serverID uint64, req *model.FsTra
     	}
     	req.StreamID = streamId
     
    -	rpc.NezhaHandlerSingleton.CreateStreamWithPurpose(streamId, 0, serverID, rpc.PurposeMCPTransfer)
    +	if err := rpc.NezhaHandlerSingleton.CreateStreamWithPurpose(streamId, 0, serverID, rpc.PurposeMCPTransfer); err != nil {
    +		return nil, func() {}, err
    +	}
     	cleanup := func() { _ = rpc.NezhaHandlerSingleton.CloseStream(streamId) }
     
     	body, err := json.Marshal(req)
    
  • cmd/dashboard/controller/terminal.go+3 1 modified
    @@ -47,7 +47,9 @@ func createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) {
     		return nil, err
     	}
     
    -	rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID)
    +	if err := rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID); err != nil {
    +		return nil, err
    +	}
     
     	terminalData, _ := json.Marshal(&model.TerminalTask{
     		StreamID: streamId,
    
  • cmd/dashboard/rpc/rpc.go+5 1 modified
    @@ -217,7 +217,11 @@ func ServeNAT(w http.ResponseWriter, r *http.Request, natConfig *model.NAT) {
     	// IS required though — the receiving agent must prove it is the server the
     	// NAT config addressed, otherwise any agent that snoops the streamId can
     	// answer NAT traffic on behalf of an unrelated host.
    -	rpcService.NezhaHandlerSingleton.CreateStream(streamId, 0, server.ID)
    +	if err := rpcService.NezhaHandlerSingleton.CreateStream(streamId, 0, server.ID); err != nil {
    +		w.WriteHeader(http.StatusTooManyRequests)
    +		w.Write(fmt.Appendf(nil, "stream limit: %v", err))
    +		return
    +	}
     	defer rpcService.NezhaHandlerSingleton.CloseStream(streamId)
     
     	taskData, err := json.Marshal(model.TaskNAT{
    
  • service/rpc/io_stream.go+34 5 modified
    @@ -48,14 +48,44 @@ var bufPool = sync.Pool{
     	},
     }
     
    -func (s *NezhaHandler) CreateStream(streamId string, creatorUserID uint64, targetServerID uint64) {
    -	s.CreateStreamWithPurpose(streamId, creatorUserID, targetServerID, PurposeLegacy)
    +const (
    +	maxStreamsPerUser   = 20
    +	maxStreamsPerServer = 40
    +)
    +
    +var (
    +	ErrTooManyStreamsForUser   = errors.New("too many concurrent streams for this user")
    +	ErrTooManyStreamsForServer = errors.New("too many concurrent streams for this server")
    +)
    +
    +func (s *NezhaHandler) CreateStream(streamId string, creatorUserID uint64, targetServerID uint64) error {
    +	return s.CreateStreamWithPurpose(streamId, creatorUserID, targetServerID, PurposeLegacy)
     }
     
    -func (s *NezhaHandler) CreateStreamWithPurpose(streamId string, creatorUserID uint64, targetServerID uint64, purpose StreamPurpose) {
    +func (s *NezhaHandler) CreateStreamWithPurpose(streamId string, creatorUserID uint64, targetServerID uint64, purpose StreamPurpose) error {
     	s.ioStreamMutex.Lock()
     	defer s.ioStreamMutex.Unlock()
     
    +	var perUser, perServer int
    +	for _, ctx := range s.ioStreams {
    +		if creatorUserID != 0 && ctx.creatorUserID == creatorUserID {
    +			perUser++
    +		}
    +		if ctx.targetServerID == targetServerID {
    +			perServer++
    +		}
    +	}
    +	// creatorUserID==0 is a dashboard-internal stream (NAT, server transfer,
    +	// MCP transfer); only end-user-initiated streams are capped per user, but
    +	// every stream counts toward the per-server cap so one server cannot be
    +	// flooded regardless of who opened the streams.
    +	if creatorUserID != 0 && perUser >= maxStreamsPerUser {
    +		return ErrTooManyStreamsForUser
    +	}
    +	if perServer >= maxStreamsPerServer {
    +		return ErrTooManyStreamsForServer
    +	}
    +
     	s.ioStreams[streamId] = &ioStreamContext{
     		creatorUserID:    creatorUserID,
     		targetServerID:   targetServerID,
    @@ -64,6 +94,7 @@ func (s *NezhaHandler) CreateStreamWithPurpose(streamId string, creatorUserID ui
     		agentIoConnectCh: make(chan struct{}),
     		revokedCh:        make(chan struct{}),
     	}
    +	return nil
     }
     
     // IsStreamAuthorizedForAgent reports whether the connecting agent is the
    @@ -270,8 +301,6 @@ func (s *NezhaHandler) CloseStream(streamId string) error {
     	return nil
     }
     
    -
    -
     // UserConnected publishes the user-side IO under ioStreamMutex so concurrent
     // Revoke* / WaitForAgent / StartStream see a consistent stream view.
     // Without the lock, the bare assignment to stream.userIo races with the
    
  • service/rpc/io_stream_leak_test.go+67 0 added
    @@ -0,0 +1,67 @@
    +package rpc
    +
    +import (
    +	"runtime"
    +	"testing"
    +	"time"
    +)
    +
    +// settleGoroutines lets transient goroutines wind down so the count reflects
    +// only durable leaks, not in-flight teardown.
    +func settleGoroutines() int {
    +	var n int
    +	for i := 0; i < 50; i++ {
    +		runtime.GC()
    +		time.Sleep(20 * time.Millisecond)
    +		n = runtime.NumGoroutine()
    +	}
    +	return n
    +}
    +
    +// TestStartStream_NoGoroutineLeakAfterClose verifies the bidirectional relay in
    +// StartStream does not strand a goroutine. StartStream launches two
    +// io.CopyBuffer goroutines (user<-agent and agent<-user) but returns after the
    +// first one finishes. The second goroutine stays blocked in CopyBuffer until
    +// its endpoints are closed. CloseStream closes both endpoints, which must
    +// unblock and drain that second goroutine. If it doesn't, every terminal / fm /
    +// NAT session leaks one goroutine for the lifetime of the dashboard.
    +func TestStartStream_NoGoroutineLeakAfterClose(t *testing.T) {
    +	base := settleGoroutines()
    +
    +	const n = 20
    +	for i := 0; i < n; i++ {
    +		h := NewNezhaHandler()
    +		const id = "leak-stream"
    +
    +		if err := h.CreateStream(id, 1, 1); err != nil {
    +			t.Fatalf("CreateStream: %v", err)
    +		}
    +
    +		userIo, agentIo := newPipeReadWriter(), newPipeReadWriter()
    +		h.AgentConnected(id, agentIo)
    +		h.UserConnected(id, userIo)
    +
    +		done := make(chan struct{})
    +		go func() {
    +			_ = h.StartStream(id, time.Second*5)
    +			close(done)
    +		}()
    +
    +		// Close one endpoint so the first CopyBuffer returns and StartStream
    +		// unblocks, mirroring a peer disconnect.
    +		time.Sleep(10 * time.Millisecond)
    +		userIo.Close()
    +		<-done
    +
    +		// The caller's defer CloseStream closes both endpoints, which must
    +		// drain the still-blocked second copy goroutine.
    +		_ = h.CloseStream(id)
    +		agentIo.Close()
    +	}
    +
    +	after := settleGoroutines()
    +	if grew := after - base; grew > 2 {
    +		t.Fatalf("goroutine leak in StartStream relay: ran %d streams, goroutines grew by %d (base=%d after=%d)",
    +			n, grew, base, after)
    +	}
    +}
    
  • service/rpc/io_stream_test.go+126 0 modified
    @@ -1,6 +1,8 @@
     package rpc
     
     import (
    +	"errors"
    +	"fmt"
     	"io"
     	"reflect"
     	"testing"
    @@ -98,6 +100,130 @@ func TestIOStream(t *testing.T) {
     	})
     }
     
    +// The WebSocket stream endpoints (terminal / fm) were unbounded: an
    +// authenticated member could open thousands of streams, each spawning
    +// goroutines, a 1 MiB buffer, and an agent-side PTY, exhausting dashboard and
    +// agent resources (GHSA-jg62-j5h6-8mpq). CreateStream now caps concurrent
    +// streams per user and per server. These tests pin the caps and the
    +// dashboard-internal (uid==0) exemption.
    +
    +// Baseline: a normal operator opening a terminal and a file-manager session
    +// against one server (the everyday case) must always succeed — the cap exists
    +// to stop floods, not to interfere with ordinary use.
    +func TestCreateStreamNormalUserEverydayUseSucceeds(t *testing.T) {
    +	h := NewNezhaHandler()
    +	const uid, serverID = uint64(7), uint64(1)
    +
    +	if err := h.CreateStream("term", uid, serverID); err != nil {
    +		t.Fatalf("opening a terminal must succeed for a normal user, got %v", err)
    +	}
    +	if err := h.CreateStream("fm", uid, serverID); err != nil {
    +		t.Fatalf("opening a file manager alongside a terminal must succeed, got %v", err)
    +	}
    +}
    +
    +// Several normal users working at the same time must not interfere: one user's
    +// streams do not consume another user's per-user budget.
    +func TestCreateStreamNormalUsersAreIndependent(t *testing.T) {
    +	h := NewNezhaHandler()
    +
    +	for u := uint64(1); u <= 5; u++ {
    +		for i := 0; i < maxStreamsPerUser; i++ {
    +			id := fmt.Sprintf("u%d-s%d", u, i)
    +			if err := h.CreateStream(id, u, 100+u); err != nil {
    +				t.Fatalf("user %d stream %d must succeed; per-user budgets must be independent, got %v", u, i, err)
    +			}
    +		}
    +	}
    +}
    +
    +func TestCreateStreamEnforcesPerUserCap(t *testing.T) {
    +	h := NewNezhaHandler()
    +	const uid = uint64(42)
    +
    +	for i := 0; i < maxStreamsPerUser; i++ {
    +		if err := h.CreateStream(fmt.Sprintf("u-%d", i), uid, uint64(i)); err != nil {
    +			t.Fatalf("stream %d within the per-user cap must succeed, got %v", i, err)
    +		}
    +	}
    +
    +	err := h.CreateStream("u-over", uid, 9999)
    +	if !errors.Is(err, ErrTooManyStreamsForUser) {
    +		t.Fatalf("the (maxStreamsPerUser+1)-th stream must be rejected with ErrTooManyStreamsForUser, got %v", err)
    +	}
    +}
    +
    +func TestCreateStreamEnforcesPerServerCap(t *testing.T) {
    +	h := NewNezhaHandler()
    +	const serverID = uint64(7)
    +
    +	for i := 0; i < maxStreamsPerServer; i++ {
    +		if err := h.CreateStream(fmt.Sprintf("s-%d", i), uint64(i+1), serverID); err != nil {
    +			t.Fatalf("stream %d within the per-server cap must succeed, got %v", i, err)
    +		}
    +	}
    +
    +	err := h.CreateStream("s-over", 99999, serverID)
    +	if !errors.Is(err, ErrTooManyStreamsForServer) {
    +		t.Fatalf("the (maxStreamsPerServer+1)-th stream to one server must be rejected with ErrTooManyStreamsForServer, got %v", err)
    +	}
    +}
    +
    +// Dashboard-internal streams (NAT, server transfer, MCP transfer) pass
    +// creatorUserID==0. They must NOT be capped per user, or those features would
    +// throttle themselves; but they must still count toward the per-server cap so
    +// no single server can be flooded regardless of the originating path.
    +func TestCreateStreamExemptsInternalStreamsFromPerUserCap(t *testing.T) {
    +	h := NewNezhaHandler()
    +
    +	for i := 0; i < maxStreamsPerUser*3; i++ {
    +		if err := h.CreateStream(fmt.Sprintf("internal-%d", i), 0, uint64(i)); err != nil {
    +			t.Fatalf("internal stream %d (uid==0) must never hit the per-user cap, got %v", i, err)
    +		}
    +	}
    +}
    +
    +func TestCreateStreamInternalStreamsStillCountTowardPerServerCap(t *testing.T) {
    +	h := NewNezhaHandler()
    +	const serverID = uint64(3)
    +
    +	for i := 0; i < maxStreamsPerServer; i++ {
    +		if err := h.CreateStream(fmt.Sprintf("internal-s-%d", i), 0, serverID); err != nil {
    +			t.Fatalf("internal stream %d within the per-server cap must succeed, got %v", i, err)
    +		}
    +	}
    +
    +	err := h.CreateStream("internal-s-over", 0, serverID)
    +	if !errors.Is(err, ErrTooManyStreamsForServer) {
    +		t.Fatalf("internal streams must still be subject to the per-server cap, got %v", err)
    +	}
    +}
    +
    +// Closing a stream must free its slot so a user who hit the cap can open new
    +// streams after old ones end — otherwise normal churn would permanently lock
    +// a user out.
    +func TestCreateStreamFreesSlotAfterClose(t *testing.T) {
    +	h := NewNezhaHandler()
    +	const uid = uint64(55)
    +
    +	for i := 0; i < maxStreamsPerUser; i++ {
    +		if err := h.CreateStream(fmt.Sprintf("c-%d", i), uid, 1); err != nil {
    +			t.Fatalf("setup stream %d must succeed, got %v", i, err)
    +		}
    +	}
    +	if err := h.CreateStream("c-over", uid, 1); !errors.Is(err, ErrTooManyStreamsForUser) {
    +		t.Fatalf("expected per-user cap to be hit, got %v", err)
    +	}
    +
    +	if err := h.CloseStream("c-0"); err != nil {
    +		t.Fatalf("CloseStream failed: %v", err)
    +	}
    +
    +	if err := h.CreateStream("c-after-close", uid, 1); err != nil {
    +		t.Fatalf("after closing one stream the user must be able to open another, got %v", err)
    +	}
    +}
    +
     func newPipeReadWriter() io.ReadWriteCloser {
     	r, w := io.Pipe()
     	return struct {
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

1

News mentions

0

No linked articles in our index yet.