mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-10 12:37:57 +00:00
fix(ingestor): bound the unauthenticated /neighbors report (#2122)
`handleNeighborsReport` trusted whatever an observer published on the `/neighbors` topic. The sender chose `origin_id` (whose "self" it is) and could list any pubkey as a responded neighbor; each got its `configured_scope` written with any scope string, stamped with the sender's own timestamp. The store is last-write-wins on that timestamp and `normalizeReportTS` accepted any RFC3339 time, so one report dated years ahead was written once and then blocked every genuine later report for that node until someone edited the database. The value is shown on the reach page as the confirmed scope and feeds `/api/scope-audit`. **Fix (three guards):** - pubkeys must be 64 hex chars — anything else cannot match a node anyway, so it is dropped instead of running UPDATEs that never match - the normalised scope list is capped at 256 bytes - a report stamped more than 5 minutes ahead of our clock is dropped, so a far-future timestamp can no longer lock the node **Tests:** `neighbors_guard_test.go` covers each guard, including that a genuine report still lands after a future-stamped one was rejected and that ordinary clock skew is still accepted. Full ingestor suite 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Mythos 5.1
parent
a981420d21
commit
2e7a4ebcfe
@@ -2321,10 +2321,19 @@ func (s *Store) UpdateNodeConfiguredScope(pubkey, scope, reportedAt string) erro
|
||||
// chronological, not lexicographic (see normalizeReportTS). Stored values
|
||||
// are therefore always canonical or empty.
|
||||
reportedAt = normalizeReportTS(reportedAt)
|
||||
// The report's timestamp is chosen by the publisher. Last-write-wins
|
||||
// below means a report stamped far in the future would be written once
|
||||
// and then block every genuine later report, so drop those.
|
||||
if reportTooFarInFuture(reportedAt) {
|
||||
return nil
|
||||
}
|
||||
// Canonicalise the scope syntax for the same reason: a value that is stored
|
||||
// differently from default_scope cannot be compared against it (see
|
||||
// normalizeScopeList).
|
||||
scope = normalizeScopeList(scope)
|
||||
if len(scope) > maxConfiguredScopeLen {
|
||||
return nil
|
||||
}
|
||||
// Last-write-wins: skip if the stored confirmation is newer-or-equal.
|
||||
if reportedAt != "" {
|
||||
var curAt sql.NullString
|
||||
|
||||
@@ -1865,6 +1865,12 @@ func handleNeighborsReport(store *Store, tag string, observerID string, msg map[
|
||||
originID = observerID
|
||||
}
|
||||
originID = strings.ToLower(originID)
|
||||
// The report is unauthenticated: any publisher can name any key. Only a
|
||||
// 64-hex node key can match a nodes row, so drop anything else here
|
||||
// instead of running UPDATEs that can never match.
|
||||
if !targetPubkeyRe.MatchString(originID) {
|
||||
originID = ""
|
||||
}
|
||||
if self, ok := msg["self"].(map[string]interface{}); ok && originID != "" {
|
||||
if sc, ok := self["scopes"].(string); ok {
|
||||
if err := store.UpdateNodeConfiguredScope(originID, sc, reportedAt); err != nil {
|
||||
@@ -1885,7 +1891,7 @@ func handleNeighborsReport(store *Store, tag string, observerID string, msg map[
|
||||
}
|
||||
pubkey, _ := n["pubkey"].(string)
|
||||
pubkey = strings.ToLower(pubkey)
|
||||
if pubkey == "" {
|
||||
if !targetPubkeyRe.MatchString(pubkey) {
|
||||
continue
|
||||
}
|
||||
scopes, _ := n["scopes"].(string)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// Limits on the unauthenticated /neighbors report (#1865). Any MQTT publisher
|
||||
// can send one, so the values it carries are bounded before they reach the
|
||||
// nodes table.
|
||||
const (
|
||||
// maxConfiguredScopeLen caps the normalised comma-separated scope list.
|
||||
// Real repeaters carry a handful of short region names.
|
||||
maxConfiguredScopeLen = 256
|
||||
// maxReportFuture is how far ahead of our clock a report may be stamped
|
||||
// and still be accepted. Allows ordinary clock skew, rejects a timestamp
|
||||
// chosen to win last-write-wins forever.
|
||||
maxReportFuture = 5 * time.Minute
|
||||
)
|
||||
|
||||
// reportNow is swapped in tests.
|
||||
var reportNow = time.Now
|
||||
|
||||
// reportTooFarInFuture reports whether a canonical RFC3339 timestamp (the
|
||||
// output of normalizeReportTS) is more than maxReportFuture ahead of now.
|
||||
// Empty or unparseable input is not "in the future".
|
||||
func reportTooFarInFuture(canonical string) bool {
|
||||
if canonical == "" {
|
||||
return false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, canonical)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return t.After(reportNow().Add(maxReportFuture))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A /neighbors report can come from any MQTT publisher, so the values it
|
||||
// carries must be bounded. These tests cover the three guards: a timestamp
|
||||
// far in the future (which would lock last-write-wins), a non-hex pubkey, and
|
||||
// an oversized scope list.
|
||||
|
||||
func TestNeighborsReportFutureTimestampIsDropped(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
pk := "ee00000000000000000000000000000000000000000000000000000000000001"
|
||||
seedNode(t, store, pk)
|
||||
|
||||
fixed := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
|
||||
reportNow = func() time.Time { return fixed }
|
||||
t.Cleanup(func() { reportNow = time.Now })
|
||||
|
||||
// A report stamped a year ahead must not be written...
|
||||
if err := store.UpdateNodeConfiguredScope(pk, "evil", "2027-10-07T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sc, _ := configuredScope(t, store, pk); sc.Valid && sc.String != "" {
|
||||
t.Fatalf("future-stamped report was stored: %q", sc.String)
|
||||
}
|
||||
// ...and a genuine report with a normal timestamp must still land after it.
|
||||
if err := store.UpdateNodeConfiguredScope(pk, "eu", "2026-10-07T11:59:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sc, _ := configuredScope(t, store, pk); sc.String != "#eu" {
|
||||
t.Fatalf("genuine report blocked: configured_scope = %q, want '#eu'", sc.String)
|
||||
}
|
||||
// Ordinary clock skew (inside maxReportFuture) is still accepted.
|
||||
if err := store.UpdateNodeConfiguredScope(pk, "de", "2026-10-07T12:03:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sc, _ := configuredScope(t, store, pk); sc.String != "#de" {
|
||||
t.Fatalf("slightly-ahead report rejected: configured_scope = %q, want '#de'", sc.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborsReportIgnoresNonHexPubkeys(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
pk := "ee00000000000000000000000000000000000000000000000000000000000002"
|
||||
seedNode(t, store, pk)
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"timestamp": "2026-10-06T12:00:00Z",
|
||||
"origin_id": "not-a-pubkey",
|
||||
"self": map[string]interface{}{"scopes": "eu"},
|
||||
"neighbors": []interface{}{
|
||||
map[string]interface{}{"pubkey": "../etc/passwd", "status": "responded", "scopes": "eu"},
|
||||
map[string]interface{}{"pubkey": strings.ToUpper(pk), "status": "responded", "scopes": "dk"},
|
||||
},
|
||||
}
|
||||
handleNeighborsReport(store, "test", "not-a-pubkey-either", msg)
|
||||
|
||||
// The valid neighbor was written; nothing errored on the junk keys.
|
||||
if sc, _ := configuredScope(t, store, pk); sc.String != "#dk" {
|
||||
t.Fatalf("valid neighbor not written: %q", sc.String)
|
||||
}
|
||||
var n int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE configured_scope IS NOT NULL AND configured_scope != ''`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected exactly 1 node with a configured scope, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeighborsReportOversizedScopeIsDropped(t *testing.T) {
|
||||
store := openNeighborsStore(t)
|
||||
pk := "ee00000000000000000000000000000000000000000000000000000000000003"
|
||||
seedNode(t, store, pk)
|
||||
|
||||
huge := strings.Repeat("region,", 200) // ~1.6 KB after normalisation
|
||||
if err := store.UpdateNodeConfiguredScope(pk, huge, "2026-10-06T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sc, _ := configuredScope(t, store, pk); sc.Valid && sc.String != "" {
|
||||
t.Fatalf("oversized scope list was stored (%d bytes)", len(sc.String))
|
||||
}
|
||||
// A normal list is unaffected.
|
||||
if err := store.UpdateNodeConfiguredScope(pk, "eu,dk,dk-aarhus", "2026-10-06T12:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sc, _ := configuredScope(t, store, pk); sc.String != "#eu,#dk,#dk-aarhus" {
|
||||
t.Fatalf("normal scope list mangled: %q", sc.String)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user