From 548e4cd4a4f15533e8631463e20390dca4cca804 Mon Sep 17 00:00:00 2001 From: nullrouten0 Date: Thu, 8 Oct 2026 06:58:07 -0700 Subject: [PATCH] fix(api): cap the nodes= list on /api/packets at 50 entries (#2120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each entry in the comma-separated `nodes=` list on `GET /api/packets` costs one SQLite lookup (`resolveNodePubkey`) while the packet store's read lock is held. A 1 MB URL fits about 15,000 entries. On a test instance 12,000 entries took 1.3 s per request, against 0.9 ms for one entry, and the lock stalls the poller's writes for that long. A few parallel clients can keep the site busy and the live feed stale. **Fix:** lists longer than 50 entries get HTTP 400 with a clear message. No UI page sends more than a handful. **Tests:** `multi_node_cap_test.go` — 50 entries return 200, 51 return 400. Full `go test ./...` in `cmd/server` passes. Running in production on our instance since 2026-10-07. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Mythos 5.1 --- cmd/server/multi_node_cap_test.go | 38 +++++++++++++++++++++++++++++++ cmd/server/routes.go | 11 +++++++++ 2 files changed, 49 insertions(+) create mode 100644 cmd/server/multi_node_cap_test.go 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"