Repository navigation
fix(server): filter blacklisted nodes from /api/analytics/topology - #92
Merged
Merged
Conversation
…91) Handler tests over data built by the real computeAnalyticsTopology (maps, not the Topology* structs). A seed places a target repeater in all five pubkey-bearing parts — topRepeaters, topPairs as pubkeyA and pubkeyB, bestPathList, multiObsNodes and perObserverReach for two observers — and every test first proves the target is present before blacklisting it. Covers case/whitespace variants, empty blacklists, multiple nodes, both pair sides, hidden-name prefixes, the topoCache and recomputer-snapshot paths (never modified by filtering), window queries, concurrent requests with blacklist changes and cache invalidation, a missing part, other known shapes, and unknown shapes that must fail closed. All fail on master 1ee44d7: the target stays in every part. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
filterBlacklistedFromTopology type-asserted []TopRepeater, []TopPair, []BestPathEntry, []MultiObsNode and map[string]*ObserverReach, but computeAnalyticsTopology builds maps and slices of maps, so every branch was skipped and blacklisted nodes were returned unfiltered. - One central filter (topology_privacy.go) that works on the shape the store produces, also reads JSON-decoded and typed shapes, and fails closed: a part or entry it cannot read is dropped, never passed on. - Matching uses Config.IsBlacklisted (trimmed, case-insensitive) and HiddenNamePrefixes; a pair goes if either side is hidden. - The handler gets the shared cached object (recomputer snapshot or topoCache); the filter never writes to it and copies only the parts that change. With nothing hidden the input is returned as is. - The handler gate uses the new Config.HasNodeBlacklist, which reads the atomic set (race-free against SetNodeBlacklist), and also runs the filter when only HiddenNamePrefixes are configured, as the other privacy-filtered handlers do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idden (#91) With a blacklist configured but none of its nodes in the topology, the filter allocated on every request: - perObserverReach always built a new observer map and ring slice; - json.Unmarshal into the named result / outer variable moved those to the heap on every call, the native path included; - bound method values passed as predicates escaped. Copy-on-write for observers and rings, local unmarshal targets confined to the conversion branches, and method expressions for the predicates. Measured on the seeded store: 0 B / 0 allocs per call without a match (~58 ns per entry, dominated by Config.IsBlacklisted's normalisation); warm endpoint allocations equal to master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d names (#91) Independent review of the fix (no blockers) found gaps: - with HiddenNamePrefixes set, bestPathList ran isPubkeyHidden — one SQLite query per entry, up to 50 per request; a server without a database now makes any per-entry lookup panic (red on 342864b); - nothing proved unresolved hops (pubkey nil) are kept; - nothing proved an entry whose name is not a string is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#91) bestPathList used isPubkeyHidden, a GetNodeByPubkey query per entry when HiddenNamePrefixes is configured. The entry already carries the node's resolved name, so bestPathList now uses the same predicate as the other parts (pubkey blacklisted or name hidden) and the filter decides from the response alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tracked in issue #91 (separate from the QA hardening in #87, which detects this bug).
Privacy bug
GET /api/analytics/topologyreturned nodes that are listed innodeBlacklist. It exposed their pubkey, name, repeater rank, pairings and per-observer reachability, even though the node endpoints hide them correctly.Root cause
filterBlacklistedFromTopologytype-asserted[]TopRepeater,[]TopPair,[]BestPathEntry,[]MultiObsNodeandmap[string]*ObserverReach.computeAnalyticsTopologybuilds those parts as[]map[string]interface{}/map[string]interface{}. Every assertion missed, each branch was skipped, and the data went out unfiltered. No server test covered the blacklist on this route.A second hazard sat under it. The handler receives the store's shared cached object (the steady-state recomputer snapshot, or a
topoCacheentry), and the filter wrote its result back into that map. A filter that matched would have modified the cache in place and raced with concurrent readers.Fix (
cmd/server/topology_privacy.go, one central filter)[]interface{}) and typed-struct shapes, so a change in how the store builds the result cannot quietly turn the filter back into a no-op.topRepeaters[].pubkeytopPairs[].pubkeyA/pubkeyB(the pair goes if either side is hidden)bestPathList[].pubkeymultiObsNodes[].pubkeyperObserverReach{}.rings[].nodes[].pubkeyConfig.IsBlacklisted(trimmed, case-insensitive), plusHiddenNamePrefixes(Feature request: Hide and remove nodes from database with the emoji 🚫 in the beginning Kpa-clawbot/CoreScope#1181) on the entry's name.Config.HasNodeBlacklist(), which reads the same atomic set asIsBlacklisted, instead oflen(cfg.NodeBlacklist). The gate also runs when onlyHiddenNamePrefixesis configured, as the other privacy-filtered handlers do (routes.gosubpath/detail).nodes.namecolumn.The topology computation, and responses for nodes that are not hidden, are unchanged.
Red → green (test-first)
cmd/server/topology_blacklist_filter_test.godrives the real handler over data built by the realcomputeAnalyticsTopology. A seed places a target repeater in all five parts:topPairsas bothpubkeyAandpubkeyB,multiObsNodesvia two observers, andperObserverReachfor both. Every test first proves the target is present before blacklisting, so an "absent afterwards" result can never pass vacuously.f116d414(tests only) against master's code: every test fails with the intended assertions (target still in all six positions, unreadable shapes leak it, input mutated). No precondition fails.ba7922ce/342864b7/90cff688: green.Coverage:
topoCacheentry and the recomputer snapshot are never modified; cold (startup config) and warm requests; window queries;Mutations
18 targeted mutants, all killed on the intended assertion (checked on the final head):
topRepeaters,bestPathList,multiObsNodes,perObserverReachskipped;len(cfg.NodeBlacklist)gate (caught as a DATA RACE);isPubkeyHiddenlookup added back.An independent reviewer ran 35 more mutants. Every non-equivalent survivor was closed in
2adf1ae8/90cff688.Race / cache
cmd/serversuite under-race: green on342864b7(328 s) and on the final90cff688(266 s).TestTopologyBlacklist_Concurrent: 12 readers × 15 requests racing cache eviction, in two phases (blacklisted / not). No response ever disagrees with the blacklist in force. A third phase togglesSetNodeBlacklistagainst live requests under-race.Performance
Same seeded store (200 nodes, 2000 paths), medians of interleaved runs. The filter runs only when a blacklist or hidden-name prefixes are configured.
bestPathListentry; caught in review)The cost is dominated by the shared
Config.IsBlacklistednormalisation.End-to-end with the merged QA script (#87)
Run in an isolated Docker environment on the demo host, not staging. It used an
--internalnetwork with no published ports (outbound verified blocked), synthetic data, and apps built from master1ee44d72and from this branch. The script ran through a runner container and a disposable sshd target, both understrace.Topology probe (target in topRepeaters / pairsA / pairsB / bestPath / multiObs / reach):
true ×6, 24 pubkey fields.true ×6(the leak).false ×6, 13 other fields intact.true ×6again.qa/scripts/blacklist-test.sh, using an upper-caseTEST_NODE_PUBKEY:hide-failed: /api/analytics/topology lists the blacklisted pubkeytopology clean (13 pubkey fields checked)After every run:
Across the final runs: 452
execve(runner and target sshd), with 0 hits for the pubkey (either case),SELECT,from_pubkey,/api/nodesor/api/analytics.Verification
go build,go vet ./..., andgofmton the changed files are clean.git diff --checkis clean.qa/scripts/test-blacklist-sql.sh) passes 849/0.cmd/serversuite passes under-race.08716f43(test(reach-rank): wait for the remounted board before typing; prove Back restores the search #90, a single e2e test file) during the work. There is no overlap, the merge is clean, and build, vet, the relevant tests under-raceand the QA suite pass on the merge result.Independent review
A fresh reviewer who did not write the fix found no blockers. Its should-fix items are fixed and re-confirmed (no blockers):
bestPathList;It confirmed that dropping
isPubkeyHiddenis safe, because the entry name and the stored name come from the samenodes.namecolumn.Limitations / follow-ups (not in this PR)
observers[].id, theperObserverReachkeys and theobserver_idfields hold observer IDs, which can be node pubkeys. A node-blacklisted node that is also an observer still appears there. This matches/api/observers, which only checks the observer blacklist. Whether node blacklisting should cover observers is a product decision.qa/scripts/blacklist-test.sh.isPubkeyHiddenelsewhere readsHiddenNamePrefixeswithout the atomic. That only matters for tests that callSetHiddenNamePrefixesconcurrently.🤖 Generated with Claude Code