Skip to content

security: fix audit data-race crashes + blunt block-height poisoning (A6/A8/B1-B3)#521

Merged
oten91 merged 22 commits into
mainfrom
security/audit-race-consensus-fixes
Jul 17, 2026
Merged

security: fix audit data-race crashes + blunt block-height poisoning (A6/A8/B1-B3)#521
oten91 merged 22 commits into
mainfrom
security/audit-race-consensus-fixes

Conversation

@oten91

@oten91 oten91 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the highest-severity findings from the 2026-07-15 full security audit (SECURITY_AUDIT_2026-07-15.md): three data races that crash the pod, and the block-height poisoning / traffic-hijack class (blunted cheaply here; full median fix deferred to its own canary PR).

Note: branched off #520, so until #520 merges this PR's diff also contains #520's commits (deps/WS-hardening/C1/C2/H6/histogram). Review the 3 commits below, or merge #520 first and this shrinks to just them.

Data races (all go test -race-detectable, crash under normal traffic)

  • B1 — concurrent map iteration crash (evm + cosmos). getDisqualifiedEndpointsResponse ranged endpointStore.endpoints without holding endpointsMu while writers update it on every observation → fatal error: concurrent map iteration and map write (uncatchable), reachable by scraping GET /disqualified_endpoints. Now snapshots under RLock and validates outside it.
  • B2 — UnifiedServicesConfig.Services race. The 5-min external-config refresh mutates/appends Services with no lock while every request ranges it; the append→realloc path can hand a reader a torn slice header → panic. Adds a copylock-safe *sync.RWMutex (init in HydrateDefaults, before concurrency; nil-safe for tests), guards readers, and copy-on-writes the HealthChecks pointer.
  • B3 — protocol per-relay requestContext race. handleParallelRelayRequests fans out one goroutine per batch payload, all sharing rc and writing currentRPCType / currentRelayMinerError / requestErrorObservation while others read them (torn *RelayMinerError read → crash). All parallel relays hit the same endpoint, so it's a pure memory-safety issue — converted the three fields to sync/atomic. Verified with go test -race ./protocol/shannon/....

Block-height poisoning — defense-in-depth (A6 + A8)

A single endpoint reporting a height far above the real tip filters out every honest endpoint (100% traffic hijack). The full fix is outlier-resistant median consensus for the non-EVM QoS types (deferred — high blast radius, own PR + canary). These two cut the worst case now:

  • A8 — plausibility guard. qos.IsPlausibleBlockHeight (absolute ceiling 1e12; reject jumps >1e7 above an established perceived height) applied at the bare-max update sites (cosmos, solana×2, noop). A MaxUint64 report (the demonstrated attack) is ignored + logged instead of becoming the perceived height. Does not stop small over-reports — those need the median.
  • A6 — Redis perceived_block TTL. The max-wins CAS SET had no expiry, so a poisoned/too-high value persisted forever (honest lower writes dropped; re-read on restart; needed manual DEL). Now a 1h TTL refreshed only on the update path: a value no honest write can beat expires and self-heals, while a normally advancing chain keeps the key alive.

Verification

  • go build ./..., go vet ./... clean
  • Unit tests green across qos / gateway / protocol / reputation (+ new: IsPlausibleBlockHeight, and -race on the protocol package)
  • Redis integration tests require a Docker Redis container (testcontainers) — run in CI, not locally

Deferred (tracked in the report)

A1–A5 median-consensus port + EVM accept-band tightening (own PR + canary); mediums (pprof→localhost+auth, /admin auth, SSRF egress filter, CI action pinning + least-privilege permissions, endpoint_blocks TTL); CORS/CheckOrigin/heuristic-substring lows.

🤖 Generated with Claude Code

oten91 and others added 22 commits July 12, 2026 22:06
…bump errors to `critical`

- Updated all `eth_chainId` health checks to include chain ID assertions.
- Guarded endpoints against incorrect chain routing (e.g., tron/Base incidents).
- Adjusted `reputation_signal` from `major_error` to `critical_error` for stricter enforcement.
- Added initial rules for previously unchecked services (e.g., Manta, CosmosHub, Provenance, etc.), improving coverage.
- Included chain-specific tuning to prevent false positives by tailoring sync allowances and intervals.
…rs to `critical`

- Modified `eth_chainId` health check for Sei mainnet to validate against chain ID `0x531`, ensuring correctness.
- Upgraded `reputation_signal` from `major_error` to `critical_error` for stricter reliability enforcement.
…speaks-first connections

The WebSocket bridge starts reading before the gateway assigns its protocol
context: BuildWebsocketRequestContextForEndpoint starts the bridge internally
(go b.start()) and only returns the protocol context afterward, which the gateway
then assigns. A frame arriving in that window — e.g. an endpoint-speaks-first
backend pushing a frame on connect — reached the message processor while
protocolCtx was still nil and was rejected with errWebsocketProtocolContextNotReady.
The bridge treats any processor error as fatal, so the whole connection was torn
down with close code 1011 (the trailing "use of closed network connection" log
line is just the aftermath of writing to the closed socket).

The existing protocolCtxMu mutex only fixed the older nil-receiver panic; it
converted the panic into a connection-fatal error but left the underlying race
live.

Gate both message processors on a ready channel that is closed once protocolCtx
is assigned, instead of rejecting a nil context. A setup-window frame now waits
for setup to complete rather than killing the connection. The wait is bounded by
the connection context, which is canceled on every failure path, so there is no
deadlock; the channel is closed only on the single success path, so there is no
double-close. The nil check is retained as a should-never-happen safety net.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…alid responses and session exhaustion

- Updated `invalidResponseTimeout` comments to clarify the relationship with session length and endpoint recovery behavior.
- Revised `sessionExhaustionTTL` comments for improved readability and precision regarding memory growth and session bounding.
…ening)

Two robustness gaps in the same class as the setup-window race, both
unbounded-resource DoS on long-lived WS connections:

1. No read size limit. connLoop called ReadMessage() with no SetReadLimit,
   and gorilla's default is unlimited — a single oversized frame (from a
   malicious client OR a compromised/malicious endpoint) is buffered whole
   and can OOM the process. Set SetReadLimit(maxMessageBytes) on both
   connections in newConnection. 15MB matches go-ethereum's WS
   wsMessageSizeLimit: large enough for legitimate EVM responses over a
   subscription, but bounded. On exceed, ReadMessage errors and the
   connection is torn down cleanly via the existing handleDisconnect path.

2. No write deadline. bridge WriteMessage calls had no SetWriteDeadline, and
   WriteMessage does not observe the context. A client that stops reading
   (its TCP receive buffer fills) blocks the bridge's single processing
   goroutine indefinitely — it can never reach its ctx.Done() select, so
   pingLoop canceling the context cannot free it, wedging the connection and
   its resources until the OS TCP timeout. Add writeMessage(), which sets a
   writeWaitDuration deadline before each data write so a stalled reader
   fails fast. Control writes (ping/close) already pass their own deadline.

Adds Test_Connection_ReadLimit covering the oversized-frame teardown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…utines

Completes the WebSocket DoS-hardening pass (audit H4 + a goroutine-growth
sibling) alongside the read-limit/write-deadline fixes.

1. Concurrent-connection cap (H4). Each live WS connection costs ~5-7
   goroutines + 2 sockets + buffers, and nothing inside PATH bounded the
   number of live connections — a flood of upgrades or a slow drain of
   never-closing connections could exhaust goroutines/FDs. Envoy rate-limits
   connection *attempts* at the edge, but this is the missing internal
   backstop. Adds WebsocketConnectionLimiter (nil-safe, lock-free atomic
   counter) wired through RouterConfig.MaxConcurrentWebsocketConnections
   (default 10000; negative disables). A slot is reserved at upgrade and
   released either immediately on setup failure or when the connection
   terminates (via websocketCtx), guaranteeing exactly one release per
   acquire. Over-cap upgrades are rejected with 503.

2. Bounded observation processing. BroadcastMessageObservations spawned a
   goroutine per endpoint message; a high-frequency subscription (e.g. a
   Polygon logs/newHeads firehose) could spawn unbounded goroutines racing
   on shared observation state. It now runs inline in the per-connection
   listener goroutine, which already reads the observation channel
   sequentially. Bursts are absorbed by the buffered channel and the
   bridge's existing non-blocking drop-on-full send.

Adds unit tests for the limiter (cap, release, nil-disabled, concurrency)
and updates RouterConfig default expectations + schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
io.ReadAll(httpReq.Body) ran with no size limit on the raw client body
(the observation-capture path reads and buffers the whole body before any
downstream limit applies), so a single request with a multi-GB body could
OOM the process.

Wrap the request body with http.MaxBytesReader at the router's single HTTP
choke point (handleServiceRequest), before any handler reads it, so every
downstream read (RPC-type detection, observation capture, relay) draws from
a capped source and a read past the limit fails instead of allocating
without bound. Websocket upgrades are unaffected (they do not read req.Body).

Configurable via RouterConfig.MaxRequestBodyBytes (default 10MB — well above
any legitimate JSON-RPC payload including large batches; negative disables).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…udit C6, C7)

C6: The metrics server served http.DefaultServeMux (ListenAndServe(addr,
nil)). Importing net/http/pprof (in metrics/pprof.go) registers the
/debug/pprof/* handlers on DefaultServeMux via init(), so profiling
endpoints (heap dumps, goroutine stacks, CPU profiles) were reachable on
the public metrics port. Give the metrics server its own ServeMux so it
exposes only /metrics; pprof stays on its dedicated ServePprof port.

C7: Pin rymndhng/release-on-push-action from the mutable @master branch to
the immutable commit SHA aebba2bbce07a9474bf95e8710e5ee8a9e922fe2 (v0.28.0).
A moving branch ref means a compromised upstream would get arbitrary code
execution in CI with GITHUB_TOKEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oroutines (audit C2)

handleBatchRelayRequest spawned one goroutine per batch payload with no
pre-spawn cap — the max_batch_payloads limit was only enforced deep inside
each spawned goroutine. Combined with per-payload retry/hedge fan-out, a
single oversized batch request could spawn tens of thousands of goroutines
before any rejection.

- Enforce max_batch_payloads BEFORE the spawn loop; oversized batches are
  rejected up front (per-service override honored, global default otherwise).
- Bound concurrent payload goroutines with a semaphore acquired BEFORE each
  spawn (sized to max_concurrent_relays), so fan-out stays bounded even if
  the batch cap is disabled. Result channel is fully buffered, so workers
  always drain and release — no deadlock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oop)

The limiter's Acquire used an optimistic add-then-rollback, so the counter
could transiently read cap+1 during a concurrent rejection's rollback
window — grants stayed correctly capped, but a concurrent Active() read
could momentarily exceed max (flaked the concurrency test). Switch to a
lock-free compare-and-swap loop that only increments when strictly below
the cap, so the counter never overshoots even transiently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rflow (audit H6)

perceivedBlock - syncAllowance is a uint64 subtraction computed at five
endpoint-selection sites (evm x3, cosmos, noop). When the sync allowance
exceeds the perceived block height — early in a chain's life, low heights,
or a misconfigured allowance — it underflows to ~MaxUint64, making the
"endpoint block >= minAllowed" check fail for every endpoint and rejecting
the entire endpoint set.

Add qos.MinAllowedBlockNumber(perceivedBlock, syncAllowance), which saturates
at 0 (allowance larger than height => no floor => all endpoints pass), and
route all five sites through it. Includes unit tests covering the underflow
and MaxUint64 cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
More headroom for the largest legitimate payloads (big batches, large
eth_getLogs/debug traces, contract deploys). Still bounds a single
request's allocation to prevent multi-GB OOM (audit C1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
request_bytes_received_total is a counter, so it only yields totals and
averages — there was no way to see p99/max request sizes (e.g. to validate
max_request_body_bytes against real traffic). Closes the existing TODO in
http_request_context.go.

Adds path_request_body_size_bytes (HistogramVec) recorded alongside the
existing counter in RecordRequestSize, only for non-zero bodies.

Cardinality is bounded by design: it reuses the same fixed rpc_type/service_id
labels as the counter (no new label dimensions) and a fixed 11-bucket set
(256B..128MB). The Observe runs on the per-request observation-publish path
that already executes per request, so the added hot-path cost is a single
sub-microsecond histogram Observe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1. Concurrent map iteration crash (evm + cosmos). getDisqualifiedEndpointsResponse
   ranged endpointStore.endpoints WITHOUT holding endpointsMu, while writers
   update that map under the lock on every observation. Go turns a concurrent
   iterate+write into an unrecoverable `fatal error: concurrent map iteration
   and map write` — reachable by scraping GET /disqualified_endpoints under
   normal traffic. Now snapshots the map under RLock and validates outside it
   (basicEndpointValidation takes its own lock, so we release first to avoid
   lock-order inversion).

2. Unsynchronized UnifiedServicesConfig.Services (gateway). The 5-minute
   external-config refresh mutates/appends Services (SetServiceSyncAllowance)
   with no lock while every request ranges it; the append→realloc path can hand
   a reader a torn slice header → panic. Adds a copylock-safe *sync.RWMutex
   (initialized in HydrateDefaults, before any concurrency; nil-safe for tests),
   guards the readers, and copy-on-writes the HealthChecks pointer in the writer
   so a reader holding a prior copy is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleParallelRelayRequests fans out one goroutine per batch payload, all
sharing the requestContext and all targeting the single selected endpoint.
Each goroutine writes currentRPCType (RPC-type fallback), currentRelayMinerError
(trackRelayMinerError), and requestErrorObservation (handleInternalError) while
other goroutines read them to build observations, reputation keys, and metrics.
These are unsynchronized writes/reads on shared fields — a data race, and a torn
read of the *RelayMinerError pointer could crash. Reachable whenever >1 payload
reaches HandleServiceRequest (multi-payload relay + health checks).

Convert the three per-relay fields to sync/atomic (atomic.Int32 for the RPC-type
enum, atomic.Pointer for the two observation pointers). Because all parallel
relays hit the SAME endpoint, there is no cross-supplier misattribution — this
is purely a memory-safety fix; behavior is unchanged. heuristicRPCType is set
once before parallelization (happens-before) so it stays a plain field.

Verified with `go test -race ./protocol/shannon/...`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Defense-in-depth against the block-height poisoning / traffic-hijack class
(a single endpoint reporting a height far above the real tip filters out every
honest endpoint). The full fix is outlier-resistant median consensus for the
non-EVM QoS types; these two changes cut the worst case cheaply.

A8 — plausibility guard on perceived-height updates. Add
qos.IsPlausibleBlockHeight (absolute ceiling 1e12; reject jumps >1e7 above an
established perceived height) and apply it at the bare-max update sites
(cosmos, solana x2, noop). A report of MaxUint64 (the demonstrated attack) or
any absurd value is now ignored and logged as a possible poisoning attempt,
rather than becoming the perceived height. Does NOT stop small over-reports —
those still require median consensus.

A6 — TTL on the Redis perceived_block key. Previously the max-wins CAS SET had
no expiry, so a poisoned/too-high value persisted forever: honest lower writes
are dropped (max-wins), every replica re-read it on restart, and it needed a
manual DEL. The SET now carries a 1h TTL, refreshed ONLY on the update path so
a value no honest write can beat expires and self-heals, while a normally
advancing chain keeps the key alive.

qos tests pass; the Redis integration tests require a Docker Redis container
(unavailable locally) and run in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lastCloseCode/lastCloseText on websocketConnection were read and written
without synchronization:

  - handleDisconnect() writes both fields and is called concurrently from
    connLoop (read error) and pingLoop (ping-write failure) of the same
    connection — on a dead socket both fire at once (write/write).
  - GetCloseInfo() reads them from the bridge shutdown path while the peer
    connection's loops may still be writing (read/write). The happens-before
    established by cancelCtx only covers the connection that triggered the
    shutdown, not the other one.

lastCloseText is a string (pointer+len), so a torn read is undefined
behavior, and CI's -race build flags the access. Same class as the B1/B2/B3
race fixes on this branch.

Guard both fields with a dedicated mutex, locked in handleDisconnect (write)
and GetCloseInfo (read). Adds a -race regression test that reproduces both
races before the fix and passes after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a client Websocket upgrade succeeds but the subsequent dial to the
selected upstream endpoint fails (supplier refuses/resets the connection),
StartBridge closed the already-upgraded client connection with a raw
gorilla Close() — no Websocket close handshake. Clients surface that as an
abnormal closure (1006) or a protocol error (1002), which is misleading:
the fault is an unavailable upstream, not a client protocol violation. It
also gives no reconnect guidance, so clients cannot tell that simply
retrying would likely draw a healthy supplier.

Send a proper close frame with code 1013 (Try Again Later) and a legible
reason before closing the client connection. 1013 correctly signals a
transient upstream problem and invites the client to reconnect.

Contained to the establishment-failure path in StartBridge, before the
bridge struct exists (so the normal shutdown() close-frame path is not yet
available). Adds a test that dials a client through a bridge whose upstream
is dead and asserts the client receives a 1013 frame (verified to fail with
a 1006 abnormal closure before the fix).

Note: this does not change endpoint selection — the gateway still selects
WebSocket endpoints without reputation filtering (filterByReputation=false),
so it can still hand clients to suppliers with failing WS endpoints. Evicting
those suppliers is tracked separately (WEBSOCKET_QOS_PLAN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gorilla/websocket synthesizes a *CloseError{Code: 1006} when a peer drops
the TCP connection without a close handshake (verified: an abrupt
UnderlyingConn().Close() surfaces as "close 1006 (abnormal closure)").
extractCloseInfo caches that code and determineCloseCodeAndMessage forwards
it verbatim to the other peer, so PATH would put 1006 into a close frame via
FormatCloseMessage. Per RFC 6455 §7.4.1, codes 1005/1006/1015 are reserved
for internal endpoint use and MUST NOT appear in a close frame — relay miners
reject it ("websocket: bad close code 1006") and clients see a malformed
frame. (Production-observed on relay-miner logs.)

Add sanitizeCloseCode (1005/1006/1015 -> 1011 Internal Error) and apply it at
the single emit choke point in shutdown(), just before FormatCloseMessage.
This covers every propagation path while leaving the real captured code in
the debug logs. Adds a regression test that drives an abrupt endpoint TCP
close through the bridge and asserts the client receives 1011, not 1006
(verified to fail with 1006 before the fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The WebSocket connection duration metric (path_websocket_connection_duration_seconds)
always recorded 0, so every service showed an identical, meaningless
distribution (p50=0.5s, p90=0.9s — the [0,1) bucket) regardless of how long
connections actually lived.

Cause: buildWebsocketConnectionObservation stamped ConnectionEstablishedTimestamp
to time.Now() on BOTH the established and closed events and never set
ConnectionClosedTimestamp. The gateway computes duration as
closedTimestamp - establishedTimestamp only when both are present, so the
duration was always skipped and recorded as 0.

Fix: capture the real establishment time in the bridge lifecycle goroutine and
carry it through to the closure observation, which now also stamps the actual
close time. The established observation carries only the establishment timestamp;
the closed observation carries both, yielding a correct duration.

Also add sub-second histogram buckets (0.1/0.25/0.5) so an immediate supplier
reset — a connection that establishes then dies in well under a second — is
visible and distinguishable from healthy long-lived subscriptions. This is what
makes the reset rate (the real WebSocket supplier-quality problem) measurable.

Adds a regression test asserting the closure observation carries both timestamps
and a non-zero duration (verified to fail before the fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aturation

Two per-supplier metric families dominate gateway Prometheus cardinality
and silently truncate at the 25K series guard. Bound them and make guard
saturation observable.

Hedge (was ~39% of all gateway series):
- Remove hedge_supplier_latency_seconds{supplier,role} (~12 series/tuple).
- Add hedge_supplier_outcome_total{supplier,role} counter — win-rate at
  2 series/supplier, stays under the guard so it is complete (no
  first-seen bias).
- Add hedge_role_latency_seconds{role} histogram — latency distribution,
  supplier-less (~24 series). Same operator signal, ~89K fewer series/pod.

supplier_signal_total:
- Collapse the 8 reputation signal_type values to a 3-class severity
  label (ok/slow/error), cutting the per-(supplier,service) fan 8x -> 3x.
  Full error taxonomy remains per-domain on relays_total (status_code +
  reputation_signal).

Cardinality guard:
- Emit a one-time WARN when a metric first saturates. Previously silent —
  a capped metric looked identical to a healthy one on the wire. Pairs
  with the existing path_metrics_label_dropped_total counter. Logger
  wired at metrics startup.

Breaking label changes on the two metrics above; grep confirms no repo
dashboard references either old name (all hedge/error panels use
path_relays_total). No functional change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@oten91
oten91 merged commit 4cdd2bf into main Jul 17, 2026
8 of 13 checks passed
@oten91
oten91 deleted the security/audit-race-consensus-fixes branch July 17, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant