diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index cf5024bc..418230db 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -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 diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index f6ee83b8..f6956863 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -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) diff --git a/cmd/ingestor/neighbors_guard.go b/cmd/ingestor/neighbors_guard.go new file mode 100644 index 00000000..3142074b --- /dev/null +++ b/cmd/ingestor/neighbors_guard.go @@ -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)) +} diff --git a/cmd/ingestor/neighbors_guard_test.go b/cmd/ingestor/neighbors_guard_test.go new file mode 100644 index 00000000..77cf0db9 --- /dev/null +++ b/cmd/ingestor/neighbors_guard_test.go @@ -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) + } +}