diff --git a/cmd/server/multi_node_cap_test.go b/cmd/server/multi_node_cap_test.go new file mode 100644 index 00000000..cacf3e99 --- /dev/null +++ b/cmd/server/multi_node_cap_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// GET /api/packets?nodes=... resolves every entry with a SQLite lookup while +// holding the packet store's read lock, so the list must be capped. +func TestMultiNodePacketsListCap(t *testing.T) { + _, router := setupTestServerWithAPIKey(t, "") + + get := func(n int) *httptest.ResponseRecorder { + keys := make([]string, n) + for i := range keys { + keys[i] = fmt.Sprintf("%064x", i+1) + } + req := httptest.NewRequest("GET", "/api/packets?nodes="+strings.Join(keys, ",")+"&limit=1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + t.Run("at the cap is accepted", func(t *testing.T) { + if w := get(maxMultiNodePubkeys); w.Code != http.StatusOK { + t.Fatalf("expected 200 for %d nodes, got %d (body: %s)", maxMultiNodePubkeys, w.Code, w.Body.String()) + } + }) + + t.Run("one over the cap is rejected with 400", func(t *testing.T) { + if w := get(maxMultiNodePubkeys + 1); w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for %d nodes, got %d (body: %s)", maxMultiNodePubkeys+1, w.Code, w.Body.String()) + } + }) +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 404f0907..b5be6cb4 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1076,6 +1076,10 @@ func (s *Server) handlePerfReset(w http.ResponseWriter, r *http.Request) { // --- Packet Handlers --- +// maxMultiNodePubkeys caps the comma-separated `nodes=` list on GET +// /api/packets. No UI page sends more than a handful. +const maxMultiNodePubkeys = 50 + func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) { // Multi-node filter: comma-separated pubkeys (Node.js parity) if nodesParam := r.URL.Query().Get("nodes"); nodesParam != "" { @@ -1087,6 +1091,13 @@ func (s *Server) handlePackets(w http.ResponseWriter, r *http.Request) { cleaned = append(cleaned, pk) } } + // Each entry costs one SQLite lookup (resolveNodePubkey) while the + // packet store's read lock is held. A 1 MB URL fits ~15k pubkeys, + // which is seconds of work per request, so cap the list. + if len(cleaned) > maxMultiNodePubkeys { + writeError(w, 400, fmt.Sprintf("too many nodes (max %d)", maxMultiNodePubkeys)) + return + } order := "DESC" if r.URL.Query().Get("order") == "asc" { order = "ASC"