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:
nullrouten0
2026-10-08 15:58:45 +02:00
committed by GitHub
co-authored by Claude Mythos 5.1
parent a981420d21
commit 2e7a4ebcfe
4 changed files with 143 additions and 1 deletions
+9
View File
@@ -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
+7 -1
View File
@@ -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)
+33
View File
@@ -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))
}
+94
View File
@@ -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)
}
}