Files

92 lines
3.3 KiB
Go

package controller
// TDD regression tests for GHSA-jg62-j5h6-8mpq (CVE-2026-53522):
// Unbounded WebSocket Streams — Resource Exhaustion DoS.
//
// The vulnerability: POST /api/v1/terminal and POST /api/v1/file insert a new
// context into an unbounded map with no per-user rate limit, global semaphore,
// or per-server connection cap, letting any authenticated user exhaust server
// resources until the dashboard crashes.
//
// The fix: createStreamLocked enforces maxStreamsPerUser (20) and
// maxStreamsPerServer (40). These tests verify the fix is effective end-to-end
// through the HTTP controller handlers, not just at the rpc layer.
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
const (
// Must match rpc.maxStreamsPerUser so the test fills exactly the right cap.
quotaTestUserCap = 20
// Must match rpc.maxStreamsPerServer.
quotaTestServerCap = 40
)
// setupQuotaTest initialises the shared fixtures used by all quota tests:
// a fresh NezhaHandler, a server (ID 7) owned by the test user (ID 100),
// and a task stream that succeeds so that created streams stay in the registry.
func setupQuotaTest(t *testing.T) (cleanup func(), successStream *failingRequestTaskStream) {
t.Helper()
cleanupFixture, _ := setupMCPTest(t)
originalHandler := rpc.NezhaHandlerSingleton
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
successStream = &failingRequestTaskStream{err: nil}
server, ok := singleton.ServerShared.Get(7)
require.True(t, ok)
server.SetTaskStream(successStream)
return func() {
rpc.NezhaHandlerSingleton = originalHandler
cleanupFixture()
}, successStream
}
// TestCreateFMEnforcesPerUserStreamQuota tests POST /file is blocked once
// the per-user stream cap is reached.
func TestCreateFMEnforcesPerUserStreamQuota(t *testing.T) {
cleanup, _ := setupQuotaTest(t)
defer cleanup()
for i := 0; i < quotaTestUserCap; i++ {
req := newAuthorizedControllerContext(t, "POST", "/file?id=7", nil)
req.Request.URL.RawQuery = "id=7"
_, err := createFM(req)
require.NoError(t, err, "FM session %d must succeed within per-user quota", i+1)
}
req := newAuthorizedControllerContext(t, "POST", "/file?id=7", nil)
req.Request.URL.RawQuery = "id=7"
_, err := createFM(req)
require.Error(t, err, "createFM must return an error when user quota is exhausted")
require.True(t, errors.Is(err, rpc.ErrTooManyStreamsForUser),
"error must be ErrTooManyStreamsForUser when user quota is exhausted, got: %v", err)
}
// TestCreateFMEnforcesPerServerStreamQuota is the FM counterpart: POST /file
// must also be blocked once the per-server stream cap is reached.
func TestCreateFMEnforcesPerServerStreamQuota(t *testing.T) {
cleanup, _ := setupQuotaTest(t)
defer cleanup()
for i := 0; i < quotaTestServerCap; i++ {
require.NoError(t,
rpc.NezhaHandlerSingleton.CreateStream(fmt.Sprintf("server-filler-fm-%d", i), 0, 7),
"pre-fill server quota stream %d must succeed", i+1,
)
}
req := newAuthorizedControllerContext(t, "POST", "/file?id=7", nil)
req.Request.URL.RawQuery = "id=7"
_, err := createFM(req)
require.Error(t, err, "createFM must return an error when server quota is exhausted")
require.True(t, errors.Is(err, rpc.ErrTooManyStreamsForServer),
"error must be ErrTooManyStreamsForServer when server quota is exhausted, got: %v", err)
}