mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
143 lines
5.7 KiB
Go
143 lines
5.7 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/model"
|
|
"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
|
|
}
|
|
|
|
// TestCreateTerminalEnforcesPerUserStreamQuota verifies that once a user has
|
|
// reached the per-user stream cap, subsequent createTerminal calls are rejected
|
|
// with ErrTooManyStreamsForUser. This directly tests the GHSA-jg62-j5h6-8mpq
|
|
// fix at the HTTP handler layer.
|
|
func TestCreateTerminalEnforcesPerUserStreamQuota(t *testing.T) {
|
|
cleanup, _ := setupQuotaTest(t)
|
|
defer cleanup()
|
|
|
|
// Fill the per-user quota.
|
|
for i := 0; i < quotaTestUserCap; i++ {
|
|
req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7})
|
|
_, err := createTerminal(req)
|
|
require.NoError(t, err, "terminal %d must succeed within per-user quota", i+1)
|
|
}
|
|
|
|
// The (quotaTestUserCap+1)-th call must be rejected.
|
|
req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7})
|
|
_, err := createTerminal(req)
|
|
require.Error(t, err, "createTerminal 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)
|
|
}
|
|
|
|
// TestCreateFMEnforcesPerUserStreamQuota is the FM counterpart of the terminal
|
|
// quota test: POST /file must also be 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)
|
|
}
|
|
|
|
// TestCreateTerminalEnforcesPerServerStreamQuota verifies that even when a
|
|
// single user's quota is not yet reached, createTerminal rejects streams once
|
|
// the per-server cap is hit. This guards against a distributed attack where
|
|
// many users flood one server.
|
|
func TestCreateTerminalEnforcesPerServerStreamQuota(t *testing.T) {
|
|
cleanup, _ := setupQuotaTest(t)
|
|
defer cleanup()
|
|
|
|
// Pre-fill the per-server quota with dashboard-internal streams
|
|
// (creatorUserID=0 bypasses the per-user cap so we can reach the server cap
|
|
// without needing quotaTestServerCap distinct users).
|
|
for i := 0; i < quotaTestServerCap; i++ {
|
|
require.NoError(t,
|
|
rpc.NezhaHandlerSingleton.CreateStream(fmt.Sprintf("server-filler-%d", i), 0, 7),
|
|
"pre-fill server quota stream %d must succeed", i+1,
|
|
)
|
|
}
|
|
|
|
// User 100 has used 0 of their personal quota; the server is saturated.
|
|
req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7})
|
|
_, err := createTerminal(req)
|
|
require.Error(t, err, "createTerminal 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|