mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
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:
+45
-3
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user