mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 16:47:53 +00:00
This PR replaces the strict, hardcoded limits on API list endpoints (introduced in the recent security patch) with a new operator-configurable `listLimits` block. This change is needed as issue 1540's implementation introduced a 500max node limit on the live map or any other function that leverages the api/nodes backend. Previously, we attempted to bypass public caps for internal UI requests using a heuristic based on browser headers (`Sec-Fetch-Site`). Following review, we decided to drop that heuristic entirely to eliminate any security-by-browser-convention surface area. Instead, `queryLimit()` returns to its original, mathematically simple bounds-checking shape, and the absolute maximums are now drawn from `config.json`. This provides equal DoS protection against all callers while allowing server operators to tune the ceilings based on the size of their mesh (e.g. embedded devices can tighten the knobs, regional hubs can raise them). ### Changes Made: - **`config.go`**: Introduced a `ListLimits` config struct containing `PacketsMax`, `NodesMax`, `AnalyticsMax`, and `ChannelMessagesMax`. Added safe initialization to ensure default caps (10000, 2000, 200, 500 respectively) apply even if the block is omitted from the config. - **`clamp_limit.go`**: Deleted `isInternalUIRequest` entirely and restored `queryLimit` to its original signature (`r, def, max`). - **`routes.go`**: Replaced all hardcoded integer ceilings on list endpoints (`/api/packets`, `/api/nodes`, etc.) with `s.cfg.ListLimits.*`. - **`config.example.json`**: Added the `listLimits` block with documentation to guide new operators. - **`clamp_limit_test.go`**: Purged all header-heuristic testing. ### Verification: - All 611 backend unit tests pass (`npm run test:unit`). - Bounds-checking math continues to enforce hard DoS clipping exactly at the operator's specified configuration limit. --------- Co-authored-by: mc-bot <bot@openclaw.local> Co-authored-by: openclaw-bot <bot@openclaw>
145 lines
5.2 KiB
Go
145 lines
5.2 KiB
Go
// Issue #1008: subpath + pathHop index builds must move off the
|
|
// synchronous Load() critical path into a background goroutine.
|
|
//
|
|
// Contract:
|
|
// 1. Immediately after Load() returns, SubpathIndexReady() and
|
|
// PathHopIndexReady() report false (the goroutine has not finished).
|
|
// 2. Analytics handlers that depend on those indices respond 503 with
|
|
// Retry-After: 5 until the corresponding ready flag flips true.
|
|
// 3. After the background build completes (waitable via a helper),
|
|
// both flags flip true and handlers respond 200.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestIssue1008_SubpathIndexReadyFalseImmediatelyAfterLoad asserts the
|
|
// subpath ready flag is false the instant Load() returns. Red commit: the
|
|
// stub returns true → assertion fires. Green commit: the flag is owned by
|
|
// the background goroutine, which has not yet run, so the assertion holds.
|
|
func TestIssue1008_SubpathIndexReadyFalseImmediatelyAfterLoad(t *testing.T) {
|
|
db := setupRichTestDB(t)
|
|
defer db.Close()
|
|
store := NewPacketStore(db, nil)
|
|
if err := store.Load(); err != nil {
|
|
t.Fatalf("Load() error: %v", err)
|
|
}
|
|
if store.SubpathIndexReady() {
|
|
t.Fatal("expected SubpathIndexReady()==false immediately after Load(); want background-deferred build (#1008)")
|
|
}
|
|
}
|
|
|
|
// TestIssue1008_PathHopIndexReadyFalseImmediatelyAfterLoad: same contract
|
|
// for the path-hop index.
|
|
func TestIssue1008_PathHopIndexReadyFalseImmediatelyAfterLoad(t *testing.T) {
|
|
db := setupRichTestDB(t)
|
|
defer db.Close()
|
|
store := NewPacketStore(db, nil)
|
|
if err := store.Load(); err != nil {
|
|
t.Fatalf("Load() error: %v", err)
|
|
}
|
|
if store.PathHopIndexReady() {
|
|
t.Fatal("expected PathHopIndexReady()==false immediately after Load(); want background-deferred build (#1008)")
|
|
}
|
|
}
|
|
|
|
// TestIssue1008_HandlerReturns503WhileSubpathIndexLoading asserts the
|
|
// analytics/subpaths handler returns 503 + Retry-After: 5 + a JSON body
|
|
// matching the triage spec while the subpath index is still building.
|
|
func TestIssue1008_HandlerReturns503WhileSubpathIndexLoading(t *testing.T) {
|
|
db := setupRichTestDB(t)
|
|
defer db.Close()
|
|
store := NewPacketStore(db, nil)
|
|
if err := store.Load(); err != nil {
|
|
t.Fatalf("Load() error: %v", err)
|
|
}
|
|
// Don't wait for the background build — we want to observe the
|
|
// not-ready window.
|
|
cfg := &Config{}
|
|
cfg.applyListLimitsDefaults()
|
|
srv := &Server{store: store, cfg: cfg}
|
|
|
|
req := httptest.NewRequest("GET", "/api/analytics/subpaths?minLen=2&maxLen=4&limit=10", nil)
|
|
rec := httptest.NewRecorder()
|
|
srv.handleAnalyticsSubpaths(rec, req)
|
|
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503 (subpath index loading, #1008)", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Retry-After"); got != "5" {
|
|
t.Errorf("Retry-After header = %q, want %q", got, "5")
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("body not valid JSON: %v (body=%s)", err, rec.Body.String())
|
|
}
|
|
if body["error"] != "index loading" {
|
|
t.Errorf(`body["error"] = %v, want "index loading"`, body["error"])
|
|
}
|
|
}
|
|
|
|
// TestIssue1008_HandlerRecoversAfterIndexReady asserts that, once the
|
|
// background build completes, the handler returns 200.
|
|
func TestIssue1008_HandlerRecoversAfterIndexReady(t *testing.T) {
|
|
db := setupRichTestDB(t)
|
|
defer db.Close()
|
|
store := NewPacketStore(db, nil)
|
|
if err := store.Load(); err != nil {
|
|
t.Fatalf("Load() error: %v", err)
|
|
}
|
|
|
|
// Wait up to 5s for both background builds to finish on this small
|
|
// fixture (rich test DB has ~3 packets; build is sub-millisecond).
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if store.SubpathIndexReady() && store.PathHopIndexReady() {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !store.SubpathIndexReady() {
|
|
t.Fatal("SubpathIndexReady() never flipped true within 5s")
|
|
}
|
|
if !store.PathHopIndexReady() {
|
|
t.Fatal("PathHopIndexReady() never flipped true within 5s")
|
|
}
|
|
|
|
cfg := &Config{}
|
|
cfg.applyListLimitsDefaults()
|
|
srv := &Server{store: store, cfg: cfg}
|
|
req := httptest.NewRequest("GET", "/api/analytics/subpaths?minLen=2&maxLen=4&limit=10", nil)
|
|
rec := httptest.NewRecorder()
|
|
srv.handleAnalyticsSubpaths(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status after ready = %d, want 200 (body=%s)", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestIssue1008_m7_BothFlagsSetAfterParallelStart verifies that the
|
|
// parallel two-goroutine version of startBackgroundIndexBuilds (review
|
|
// m7) sets BOTH ready flags after a bounded wait, regardless of which
|
|
// goroutine wins the race to s.mu.Lock(). Sanity check that breaking
|
|
// the two builds apart didn't drop the pathHop flag flip.
|
|
func TestIssue1008_m7_BothFlagsSetAfterParallelStart(t *testing.T) {
|
|
db := setupRichTestDB(t)
|
|
defer db.Close()
|
|
store := NewPacketStore(db, nil)
|
|
if err := store.Load(); err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if !store.WaitIndexesReady(5 * time.Second) {
|
|
t.Fatal("indexes never ready after parallel start (#1008 m7)")
|
|
}
|
|
if !store.SubpathIndexReady() {
|
|
t.Error("subpath flag not set after WaitIndexesReady returned true")
|
|
}
|
|
if !store.PathHopIndexReady() {
|
|
t.Error("pathHop flag not set after WaitIndexesReady returned true")
|
|
}
|
|
}
|