Repository navigation
security(api): bound public analytics response caches - #430
Conversation
Give each public analytics response cache keyed by caller-controlled filter/time-window input a fixed entry ceiling (expired-first, then a deterministic single victim; a refresh never evicts another live key), and reject overlong region/area/channel filters and unknown areas before any cache/index/graph work. Reuses the channelListCache/evictReachLocked eviction shape. Response schemas and valid-filter results unchanged. Relates to #370 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review — CS-pve-agent1 PR#430 — head 2e42d4bDom: REQUEST CHANGES Independent, read-only review. The author was interrupted and posted no report, so the PR description is the only basis and every claim below was re-checked here. Evidence tags: [T] = tested/run by me, [A] = analysis/inference, [K] = read in code. The mechanism is sound. Valid requests are unchanged, and the bound holds live: 700 distinct Findings
Answers to the review pointsIssue items, one by one
Red before / green after [T]: with only the new helper and test files dropped onto
All pass on the PR tree. The helper-only unit tests have no meaningful red-before state, because the helpers are new. Scope. The PR touches only Invariants [T]:
CI [T]: all jobs on run 38390692820 for this head passed. "Go Build & Test", "Playwright E2E Tests", "Build & Publish Docker Image" and "Validate Docker Image" are green. Deploy, publish and release were skipped, as expected for a PR. Neither of the known flaky tests (#256, #267) was involved. Perf [T]:
These are consistent with the PR's darwin numbers. Live, 700 distinct-window rf misses had p50 0.51 ms and p95 0.73 ms on the PR, against 0.53 ms and 0.78 ms on master. Tests and mutantsAll runs are on the merged tree
Mutants (full
On its first run, mutant A was reported "killed" by Not verified
|
…he put (#430 review) Round 2 of the #370 analytics cache bounds, addressing review findings 1-7: - F1: table-driven wiring test drives all ten cachedResult write sites (rf, relay-airtime-share, topology, channels, distance, hash-sizes, hash-collisions, subpaths, subpaths-window, subpaths-bulk) plus the wardriving, path-inspect and regionObs sites past their ceilings, and asserts a refill at the ceiling evicts no other live key. - F2: handler test for all nine guarded handlers (plus neighbor-graph) snapshots every side effect of the expensive path and asserts a rejected request leaves it unchanged; a positive control proves the probe sees each handler's work. - F3: /api/analytics/neighbor-graph rejects an overlong region before the graph compute. - F4: putCachedResultLocked takes *cachedResult, so cmd/server non-test map[string]interface{} stays at the master count (730 -> 729). - F5: maxLen ceiling raised from 32 to 63, the firmware's maximum hop count (path_length bits 0-5), so results are unchanged; tested. - F6: test pins the stats-cache smallest-key tie-break. - F7: TestAnalyticsFilterError now asserts wantSubstring. Relates to #370 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rapport — CS-MacBook PR#430 runde-2 — head 05d598dReview feedback addressed (commit Evidence tags: [T] = tested/run by me, [A] = analysis/inference, [K] = read in code.
TestsAll runs are on head
MutantsEach mutant was run against the full
Red before / green after [T]:
CIRun 38411135474 for head
The known flaky tests (#271, #301) did not appear, and no job was re-run. Left for the owner
Not verified
|
Review — CS-pve-agent1 PR#430 — head 05d598dDom: APPROVE med nits Independent, read-only re-review after round 2. It checks the "Rapport — CS-MacBook PR#430 runde-2" against my round-1 review (findings 1–8 on Evidence tags: [T] = tested/run by me, [A] = analysis/inference, [K] = read in code. All four round-1 blockers are resolved:
Every mutant I ran is now killed, including the seven that survived in round 1 (A, B, C, D, E, F, G, I). Only nits are left. None of them blocks a merge. Findings
Answers to the review points
Tests and mutants
Not verified
|
Relates to #370
Summary
Public analytics requests could create unbounded in-memory cache entries under caller-controlled keys. The
map[string]*cachedResultpool incmd/server/store.go(and the maps that share it) only had TTL expiry — expiry does not cap how many distinct keys are retained inside one TTL window, and absolute?from=&to=ranges are not a closed alias set like?window=, so a client could mint an unlimited number of distinct keys. Region/area filters also reached expensive analytics work with no validation.This PR gives every affected cache a fixed entry ceiling (expired entries evicted first, then a deterministic single victim; a refresh of an existing key never evicts another live entry) and rejects overlong filters and unknown areas before any cache/index/graph work. Response schemas and valid-filter results are unchanged. No new
map[string]interface{};cmd/serverstays read-only (no DB writes);scripts/check-xss-sinks.sh --diff origin/masteris clean; fork-guards unchanged (9 indeploy.yml, 1 inrelease-fast-path.yml).Reuses the existing eviction shape from
channelListCache.put,evictReachLocked(#358) andsetNodeRegionEntryrather than adding a parallel mechanism. Recheck note: written against current master (4a5ec5e2), after #373/#394/#402 (XSS), #385 (request limits) and #358 (Reach) merged.Inventory — every
cmd/servercache keyed by filter / time-window inputrfCache(+ relay-airtime share)store.goregion|area|windowtopoCachestore.goregion|area|windowhashCachestore.goregion|areacollisionCachestore.goregion|areachanCachestore.goregion|area|windowdistCachestore.goregion|areasubpathCachestore.goregion|minLen|maxLen|limit|windowmaxLenclampwardrivingStatsCacheroutes.gowindow|channel(free-text channel)inspectCachepath_inspect.go|observer|since|until`regionObsCachestore.goregionAlready bounded by construction (documented, no change):
scopeStatsCache/hopDepthCachekey on a 3-value window whitelist;areaNodeCacheonly ever stores configured-area keys;hashSizeInfoCache/relayStatsCache/repeaterRelayCache/repeaterUsefulCachekey on internal node pubkeys, not HTTP params. The persistent neighbor graph is a single atomic snapshot, not per-filter.Mechanism
cmd/server/analytics_cache_bounds.go(new):putCachedResultLocked/evictCachedResultLockedfor the sharedcachedResultpool;evictStatsCacheLocked(generic, for the routes.go per-window stats cache);evictInspectCacheLocked;evictRegionObsLocked;analyticsFilterError+ therejectAnalyticsFiltershandler guard.regionObsCachehas no per-entry clock, so its deterministic victim is the smallest key.region/area/channellonger than 128 bytes → 400; a non-emptyareanot incfg.Areas→ 400 — both before any store work.?maxLen=on the subpath endpoints is clamped to 32 (second key-cardinality vector; the ceiling still backstops it). Empty region/area (global) and legitimate configured selections are untouched — the UI only ever sends configured areas and short channels.Perf note (AGENTS rule 0)
go test -benchon the shared helper (darwin/arm64, go1.26), ns/op:putCachedResultLocked, cache pinned full, cold missputCachedResultLocked, refresh (existing key)map[key]=…(pre-PR, growing)So the eviction adds a bounded ~3.5 µs to a cold-miss insertion only when the cache is pinned at the ceiling — negligible against the millisecond+ analytics compute each miss already pays, and nothing extra on cache hits or refreshes. No unbounded per-request work is added to insertion.
Tests (red before → green after) + mutants
New
cmd/server/analytics_cache_bounds_test.go:cachedResultpool bounded + refresh never evictsTestPutCachedResultLocked_BoundsEntries,_RefreshDoesNotEvict,TestGetAnalyticsRF_CacheBounded(wiring, red before wiring: 768→256),TestGetAnalyticsRF_ConcurrentBounded(-race)>=→<=ceiling off-by-one; drop refresh guard_ExpiredFirst,_DeterministicVictim,_TieBreakSmallestKeyk < victim→k > victimTestAnalyticsFilterError,_NilAreasRejectsArea,TestHandleAnalyticsRF_FilterRejection,TestHandleAnalyticsSubpathsBulk_RegionRejection,TestHandleWardrivingStats_RejectsOverlongChannel>→>=; drop unknown-area checkTestEvictStatsCacheLocked_Bounds,TestEvictInspectCacheLocked_Bounds,TestEvictRegionObsLocked_Bounds8 mutants total, each shown red then reverted.
Verification run
go test ./...(cmd/server) — pass (full package, ~43s); new tests also pass under-race.sh test-all.sh— 238/238 files pass.node test-frontend-helpers.js— 713/713.scripts/check-xss-sinks.sh --diff origin/master— clean (nopublic/**changes).e2e-fixture.db: valid (region/from-to/global) → 200, overlongregion/area/channel→ 400, unknownarea→ 400, across rf/topology/channels/distance/hash-sizes/hash-collisions/subpaths-bulk/wardriving.test-issue-205-analytics-subtab-deeplinks,test-issue-236-analytics-sorting,test-issue-1657-analytics-channels-group-sprites— pass against that server.Left for the owner
maxLenlimits are chosen to sit far above any legitimate UI selection. If a deployment configures many regions/areas and legitimately exceeds these, they are the knobs to revisit — no deployment or config change is made here.arearequests now return 400 instead of silently serving an empty/global result. The UI only offers configured areas, so this is not expected to affect clients, but it is a visible contract change for any direct API caller that relied on the old silent fallback.