fix(chains): bound pendingContext (RED HIGH DoS) + reap stale (MEDIUM re-strand) + poller lifecycle ctx (LOW)

RED review of cert-carry catch-up found the frontier-sync wiring let a Byzantine
peer flood AcceptedFrontier with distinct tips -> unbounded pendingContext -> OOM.
requestContext now reaps entries past pendingContextTTL (30s) and hard-caps at
maxPendingContext (4096); the frontier poller now stops with the chain (no leak).
Cert-gate/ordering/wire safety unchanged (RED: sound). PoC inverted -> regression guard.
This commit is contained in:
zeekay
2026-06-26 14:35:22 -07:00
parent 21e031fef2
commit cc360a9120
2 changed files with 147 additions and 3 deletions
+45 -3
View File
@@ -1457,7 +1457,9 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
// it periodically asks peers for their accepted tip and pulls the gap (with
// certs) through the catch-up transport. Node-lifetime goroutine (same ctx
// model as the WaitForEvent bridge above).
go bh.runFrontierPoller(context.Background())
pollerCtx, pollerCancel := context.WithCancel(context.Background())
bh.pollerCancel = pollerCancel
go bh.runFrontierPoller(pollerCtx)
createdChain = &chainInfo{
Name: chainName,
VMID: chainParams.VMID,
@@ -2345,6 +2347,7 @@ type blockHandler struct {
requestIDCounter uint32 // Counter for generating unique request IDs
maxContextBlocks int // Max context blocks to request/serve (default: 256)
contextRequestMu sync.Mutex // Protects pendingContext and requestIDCounter
pollerCancel context.CancelFunc // Cancels runFrontierPoller when the handler stops (RED LOW: no goroutine leak on chain re-creation)
// Qbit event buffering - when we receive a Qbit for a block we don't have yet,
// buffer the event and drain when the block arrives
@@ -2526,6 +2529,19 @@ func isMissingContextError(err error) bool {
strings.Contains(errStr, "not found") // parent block not in local state
}
// maxPendingContext hard-bounds the in-flight catch-up request map. A Byzantine
// peer can stream AcceptedFrontier replies naming distinct random tips, each
// landing in requestContext; without a bound pendingContext grows without limit
// and OOMs the node (RED HIGH). pendingContextTTL reaps a request once its
// deadline has well passed, so a peer that takes the request then withholds
// Context no longer pins the slot — the block becomes re-requestable from an
// honest peer (RED MEDIUM re-strand). The GetAncestors deadline below is 10s; a
// 30s TTL is comfortably past it.
const (
maxPendingContext = 4096
pendingContextTTL = 30 * time.Second
)
// requestContext sends a context request (wire: GetAncestors) to fetch missing blocks from a peer
func (b *blockHandler) requestContext(ctx context.Context, nodeID ids.NodeID, blockID ids.ID) {
if b.net == nil || b.msgCreator == nil {
@@ -2539,6 +2555,28 @@ func (b *blockHandler) requestContext(ctx context.Context, nodeID ids.NodeID, bl
return
}
// BOUND the map (RED HIGH + MEDIUM). Reap requests past their TTL first: a
// withheld Context no longer pins the slot, so the block is re-requestable.
now := time.Now()
for id, req := range b.pendingContext {
if now.Sub(req.timestamp) > pendingContextTTL {
delete(b.pendingContext, id)
}
}
// Then hard-cap: if still at the bound (a genuine flood inside one TTL window),
// evict the oldest so the map can NEVER exceed maxPendingContext.
if len(b.pendingContext) >= maxPendingContext {
var oldestID ids.ID
var oldestT time.Time
first := true
for id, req := range b.pendingContext {
if first || req.timestamp.Before(oldestT) {
oldestID, oldestT, first = id, req.timestamp, false
}
}
delete(b.pendingContext, oldestID)
}
// Generate a new request ID
b.requestIDCounter++
requestID := b.requestIDCounter
@@ -2548,7 +2586,7 @@ func (b *blockHandler) requestContext(ctx context.Context, nodeID ids.NodeID, bl
nodeID: nodeID,
requestID: requestID,
blockID: blockID,
timestamp: time.Now(),
timestamp: now,
}
b.contextRequestMu.Unlock()
@@ -3266,7 +3304,11 @@ func (b *blockHandler) StateSummary(ctx context.Context, nodeID ids.NodeID, requ
func (b *blockHandler) Connected(ctx context.Context, nodeID ids.NodeID) error { return nil }
func (b *blockHandler) Disconnected(ctx context.Context, nodeID ids.NodeID) error { return nil }
func (b *blockHandler) HealthCheck(ctx context.Context) (interface{}, error) { return nil, nil }
func (b *blockHandler) Stop(ctx context.Context) {}
func (b *blockHandler) Stop(ctx context.Context) {
if b.pollerCancel != nil {
b.pollerCancel()
}
}
func (b *blockHandler) HandleInbound(ctx context.Context, msg handler.Message) error {
// Dispatch based on Op type
switch msg.Op {
+102
View File
@@ -0,0 +1,102 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// red_pendingcontext_dos_test.go — regression guard for the catch-up DoS RED found
// on the cert-carrying catch-up branch.
//
// The frontier-sync wiring connects the AcceptedFrontier handler to
// requestContext(), which records each requested blockID in b.pendingContext.
// Originally NOTHING evicted from that map, so a Byzantine peer streaming
// AcceptedFrontier frames each naming a distinct random tip grew it without bound
// → OOM (and a peer that took a request then withheld Context re-stranded the
// victim forever). requestContext now reaps entries past pendingContextTTL and
// hard-caps the map at maxPendingContext. These tests pin both properties; before
// the fix the first asserted N=50_000 entries with ZERO eviction.
package chains
import (
"context"
"testing"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
"github.com/luxfi/node/message"
"github.com/luxfi/node/network"
"github.com/luxfi/node/proto/p2p"
)
// redStubNet implements the one Network method requestContext uses (Send); every
// other method is inherited from the embedded nil interface and is never called.
type redStubNet struct {
network.Network
sends int
}
func (s *redStubNet) Send(_ message.OutboundMessage, nodeIDs set.Set[ids.NodeID], _ ids.ID, _ uint32) set.Set[ids.NodeID] {
s.sends++
return nodeIDs
}
// redStubMsg implements the one OutboundMsgBuilder method requestContext uses.
type redStubMsg struct {
message.OutboundMsgBuilder
}
func (redStubMsg) GetAncestors(_ ids.ID, _ uint32, _ time.Duration, _ ids.ID, _ p2p.EngineType) (message.OutboundMessage, error) {
return nil, nil
}
func newRedTestHandler(net network.Network) *blockHandler {
return &blockHandler{
logger: log.NewNoOpLogger(),
net: net,
msgCreator: redStubMsg{},
chainID: ids.GenerateTestID(),
pendingContext: make(map[ids.ID]contextRequest),
}
}
// TestPendingContext_BoundedUnderFlood: a peer streaming distinct fake tips into
// requestContext can NEVER grow pendingContext past maxPendingContext. Inverts the
// original RED PoC, which asserted unbounded growth to 50_000 → OOM.
func TestPendingContext_BoundedUnderFlood(t *testing.T) {
stubNet := &redStubNet{}
bh := newRedTestHandler(stubNet)
const N = 10_000 // ≫ the maxPendingContext cap — enough to prove "stays bounded"
from := ids.GenerateTestNodeID()
for i := 0; i < N; i++ {
bh.requestContext(context.Background(), from, ids.GenerateTestID())
}
if got := len(bh.pendingContext); got > maxPendingContext {
t.Fatalf("pendingContext unbounded: %d entries exceeds cap %d (the RED HIGH DoS)", got, maxPendingContext)
}
t.Logf("bounded: %d entries after a %d-distinct-tip flood (cap %d, sends=%d)",
len(bh.pendingContext), N, maxPendingContext, stubNet.sends)
}
// TestPendingContext_StaleEntriesReaped: a request whose Context is withheld past
// its TTL is reaped on the next requestContext, so the block is re-requestable from
// an honest peer (fixes the RED MEDIUM re-strand).
func TestPendingContext_StaleEntriesReaped(t *testing.T) {
bh := newRedTestHandler(&redStubNet{})
from := ids.GenerateTestNodeID()
stale := ids.GenerateTestID()
bh.pendingContext[stale] = contextRequest{
nodeID: from,
requestID: 1,
blockID: stale,
timestamp: time.Now().Add(-2 * pendingContextTTL),
}
// Any later request runs the reaper before recording its own entry.
bh.requestContext(context.Background(), from, ids.GenerateTestID())
if _, stillThere := bh.pendingContext[stale]; stillThere {
t.Fatalf("stale pendingContext entry (%v old) not reaped → re-strand persists", 2*pendingContextTTL)
}
}