peer: gate onion message ingress on having an open channel
What changed, and why it matters
This change closes a denial-of-service weakness in LND's onion-message forwarding. Previously, an attacker could create unlimited free peer identities and burn through the global byte-budget reserved for onion messages, starving real peers. The patch now requires a peer to share at least one funded, fully open Lightning channel before any onion message is accepted, so each attacker identity must lock up real bitcoin. It also adds a fast O(1) atomic counter so this check does not slow down every incoming message.
Treat this as a security-hardening fix and include it in the next release. Operators running nodes that accept onion messages should upgrade. Review related prior commits that added the byte-bucket limiters to ensure they are deployed together, since this gate depends on them for defense-in-depth.
Security signals we found
Adds a Sybil-resistance gate requiring funded, non-pending channels for onion message ingress
Channel gate runs before per-peer and global rate limiters, preventing no-channel peers from consuming any token budget
Introduces atomic shadow counter for O(1) hot-path checks on every incoming onion packet
Uses SyncMap.Swap/LoadAndDelete to keep counter and map consistent under concurrent mutation and race conditions
Explicitly excludes pending channels from satisfying the gate
Updates integration tests to open channels before onion-message tests, confirming behavior change
Evidence from the diff
The commit gates onion-message ingress on hasActiveChannels(), which returns true only when Brontide.numActiveChans > 0. The counter shadows the count of non-nil entries in activeChannels and is maintained atomically via new SyncMap.Swap and LoadAndDelete helpers. allowOnionMessage now runs the channel gate before consulting either the per-peer or global IngressLimiter; no-channel peers are dropped with ErrNoChannel and never allocate rate-limiter state or debit either bucket. Pending channels are intentionally excluded because they are cheap and can get stuck. Integration tests are updated to open channels before sending onion messages, and unit tests verify the gate ordering and counter transitions under -race.
Changed components
peer/brontide.gopeer/onion_ratelimit.golnutils/sync_map.goitest/lnd_onion_message_forward_test.goitest/lnd_onion_message_test.goInspect captured patch +336 / −53
diff --git a/itest/lnd_onion_message_forward_test.go b/itest/lnd_onion_message_forward_test.go
index 3048132..049e33a 100644
--- a/itest/lnd_onion_message_forward_test.go
+++ b/itest/lnd_onion_message_forward_test.go
@@ -5,6 +5,7 @@ import (
"time"
"github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcutil"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnrpc"
@@ -40,14 +41,22 @@ type onionMessageTestCase struct {
// multiple scenarios including forwarding by node ID, by SCID, and with
// concatenated blinded paths.
func testOnionMessageForwarding(ht *lntest.HarnessTest) {
- // Spin up three nodes for the test network.
- alice := ht.NewNodeWithCoins("Alice", nil)
- bob := ht.NewNodeWithCoins("Bob", nil)
- carol := ht.NewNode("Carol", nil)
-
- // Connect nodes so they can share gossip and forward messages.
- ht.ConnectNodesPerm(alice, bob)
- ht.ConnectNodesPerm(bob, carol)
+ // Spin up a three-node chain Alice -> Bob -> Carol, with both
+ // channels opened up front via CreateSimpleNetwork. Opening the
+ // channels before any forwarding run matters because onion message
+ // ingress is gated on having at least one fully open channel with
+ // the sending peer, so without these channels every hop would
+ // silently drop the message. The Bob -> Carol channel also doubles
+ // as the SCID source for the "forward via scid" test case, which
+ // keeps the per-test setup minimal.
+ chanPoints, nodes := ht.CreateSimpleNetwork(
+ [][]string{nil, nil, nil},
+ lntest.OpenChannelParams{
+ Amt: btcutil.Amount(100_000),
+ },
+ )
+ alice, bob, carol := nodes[0], nodes[1], nodes[2]
+ bobCarolChan := chanPoints[1]
testCases := []onionMessageTestCase{
{
@@ -69,16 +78,10 @@ func testOnionMessageForwarding(ht *lntest.HarnessTest) {
setup: func(ht *lntest.HarnessTest, alice, bob,
carol *node.HarnessNode) {
- // Open a channel between Bob and Carol so we
- // have an SCID to use.
- chanPoint := ht.OpenChannel(
- bob, carol,
- lntest.OpenChannelParams{Amt: 100000},
- )
-
- // Wait for the channel to be in the graph so
- // the SCID can be resolved.
- ht.AssertChannelInGraph(bob, chanPoint)
+ // The Bob -> Carol channel was opened up
+ // front; just wait for it to be in the graph
+ // so the SCID can be resolved.
+ ht.AssertChannelInGraph(bob, bobCarolChan)
},
buildPath: func(ht *lntest.HarnessTest, alice, bob,
carol *node.HarnessNode) (
diff --git a/itest/lnd_onion_message_test.go b/itest/lnd_onion_message_test.go
index 4301847..74ec0b8 100644
--- a/itest/lnd_onion_message_test.go
+++ b/itest/lnd_onion_message_test.go
@@ -4,6 +4,7 @@ import (
"time"
"github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcutil"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest"
@@ -15,7 +16,11 @@ import (
// testOnionMessage tests sending and receiving of the onion message type.
func testOnionMessage(ht *lntest.HarnessTest) {
- alice := ht.NewNode("Alice", nil)
+ // Alice needs coins to fund a channel with Bob: onion message ingress
+ // is gated on the sender and receiver sharing at least one fully open
+ // channel as the Sybil-resistance layer on top of the byte-granular
+ // rate limiter.
+ alice := ht.NewNodeWithCoins("Alice", nil)
bob := ht.NewNode("Bob", nil)
// Subscribe Alice to onion messages before we send any, so that we
@@ -45,8 +50,14 @@ func testOnionMessage(ht *lntest.HarnessTest) {
}
}()
- // Connect alice and bob so that they can exchange messages.
+ // Connect alice and bob and open a channel between them. Onion message
+ // ingress is gated on having at least one fully open channel with the
+ // sending peer, so without a channel Alice would silently drop Bob's
+ // message and the test would time out.
ht.EnsureConnected(alice, bob)
+ ht.OpenChannel(alice, bob, lntest.OpenChannelParams{
+ Amt: btcutil.Amount(100_000),
+ })
// Build a valid onion message destined for Alice.
alicePubKey, err := btcec.ParsePubKey(alice.PubKey[:])
diff --git a/lnutils/sync_map.go b/lnutils/sync_map.go
index 8815728..1ffd613 100644
--- a/lnutils/sync_map.go
+++ b/lnutils/sync_map.go
@@ -96,3 +96,24 @@ func (m *SyncMap[K, V]) LoadOrStore(key K, value V) (V, bool) {
return item, loaded
}
+
+// Swap stores value for the given key and returns the previously stored
+// value (if any). The second return value reports whether a previous
+// value was present. It is a thin typed wrapper around sync.Map.Swap so
+// callers that need to atomically read-modify-write a map entry — for
+// example, to update an atomic counter that shadows the map's
+// membership — can do so without dropping down to untyped interface{}
+// assertions.
+func (m *SyncMap[K, V]) Swap(key K, value V) (V, bool) {
+ prev, loaded := m.Map.Swap(key, value)
+ if !loaded {
+ return *new(V), false
+ }
+
+ item, ok := prev.(V)
+ if !ok {
+ return *new(V), false
+ }
+
+ return item, true
+}
diff --git a/lnutils/sync_map_test.go b/lnutils/sync_map_test.go
index 948d384..e672e30 100644
--- a/lnutils/sync_map_test.go
+++ b/lnutils/sync_map_test.go
@@ -202,3 +202,46 @@ func TestSyncMapLoadOrStore(t *testing.T) {
require.True(t, loaded)
require.Equal(t, "two", item)
}
+
+// TestSyncMapSwap tests the Swap method of the SyncMap type.
+func TestSyncMapSwap(t *testing.T) {
+ t.Parallel()
+
+ // Create a new SyncMap of string keys and integer values.
+ m := &lnutils.SyncMap[string, int]{}
+
+ // Swapping into an empty key should store the value and report no
+ // previous entry.
+ prev, loaded := m.Swap("foo", 42)
+ require.False(t, loaded)
+ require.Equal(t, 0, prev)
+
+ // The value should now be retrievable via Load.
+ value, ok := m.Load("foo")
+ require.True(t, ok)
+ require.Equal(t, 42, value)
+
+ // Swapping an existing key should return the previous value and
+ // report that it was present.
+ prev, loaded = m.Swap("foo", 99)
+ require.True(t, loaded)
+ require.Equal(t, 42, prev)
+
+ // Load should now return the new value.
+ value, ok = m.Load("foo")
+ require.True(t, ok)
+ require.Equal(t, 99, value)
+
+ // Swapping a second key should not affect the first.
+ prev, loaded = m.Swap("bar", 7)
+ require.False(t, loaded)
+ require.Equal(t, 0, prev)
+
+ value, ok = m.Load("foo")
+ require.True(t, ok)
+ require.Equal(t, 99, value)
+
+ value, ok = m.Load("bar")
+ require.True(t, ok)
+ require.Equal(t, 7, value)
+}
diff --git a/peer/brontide.go b/peer/brontide.go
index a946273..470c004 100644
--- a/peer/brontide.go
+++ b/peer/brontide.go
@@ -613,6 +613,17 @@ type Brontide struct {
activeChannels *lnutils.SyncMap[
lnwire.ChannelID, *lnwallet.LightningChannel]
+ // numActiveChans shadows the count of non-pending entries in
+ // activeChannels as an atomic integer. It exists so that hot-path
+ // callers — notably the onion message ingress gate, which runs on
+ // every incoming onion packet — can ask "does this peer have any
+ // active channel with us" in O(1) instead of iterating the
+ // activeChannels registry. It is maintained in lockstep with
+ // activeChannels via Swap and LoadAndDelete at every mutation
+ // site, so transitions from pending (nil value) to active and
+ // from active to closed are reflected atomically.
+ numActiveChans atomic.Int32
+
// addedChannels tracks any new channels opened during this peer's
// lifecycle. We use this to filter out these new channels when the time
// comes to request a reenable for active channels, since they will have
@@ -1339,7 +1350,7 @@ func (p *Brontide) loadActiveChannels(chans []*channeldb.OpenChannel) (
// channels. Adding them here would just be extra work as we'll
// tear them down when creating + adding the final link.
if lnChan.IsPending() {
- p.activeChannels.Store(chanID, nil)
+ p.markPendingChannel(chanID)
continue
}
@@ -1430,7 +1441,7 @@ func (p *Brontide) loadActiveChannels(chans []*channeldb.OpenChannel) (
"switch: %v", chanPoint, err)
}
- p.activeChannels.Store(chanID, lnChan)
+ p.storeActiveChannel(chanID, lnChan)
// We're using the old co-op close, so we don't need to init
// the new RBF chan closer.
@@ -2347,12 +2358,14 @@ out:
// Charge the limiter the on-the-wire size of the
// message so the byte-granular bucket reflects
// actual ingress bandwidth rather than raw message
- // counts. A rejection surfaces as a sentinel error
- // wrapped in fn.Result; errors.Is lets us pick the
- // right first-drop log path.
+ // counts. The channel-gate hint is sourced from the
+ // atomic active-channel counter so the check is
+ // O(1) on the hot path. A rejection surfaces as a
+ // sentinel error wrapped in fn.Result; errors.Is
+ // lets us pick the right first-drop log path.
result := allowOnionMessage(
p.cfg.OnionLimiter, p.PubKey(),
- msg.WireSize(),
+ msg.WireSize(), p.hasActiveChannels(),
)
if err := result.Err(); err != nil {
logFirstOnionDrop(
@@ -2486,6 +2499,51 @@ func (p *Brontide) hasChannel(chanID lnwire.ChannelID) bool {
return ok
}
+// hasActiveChannels reports whether this peer has at least one fully open
+// (non-pending) channel with us. Pending channels are excluded because
+// they do not yet provide the Sybil-resistance guarantees the onion
+// message ingress gate relies on. The check reads an atomic counter
+// maintained alongside activeChannels at every mutation site, so it is
+// O(1) and cheap enough to run on every incoming onion message without
+// iterating a map.
+func (p *Brontide) hasActiveChannels() bool {
+ return p.numActiveChans.Load() > 0
+}
+
+// markPendingChannel records chanID in activeChannels as a pending (nil)
+// entry; numActiveChans is unchanged.
+func (p *Brontide) markPendingChannel(chanID lnwire.ChannelID) {
+ p.activeChannels.Store(chanID, nil)
+}
+
+// storeActiveChannel installs lnChan under chanID and bumps
+// numActiveChans iff the prior entry was absent or pending (nil).
+func (p *Brontide) storeActiveChannel(chanID lnwire.ChannelID,
+ lnChan *lnwallet.LightningChannel) {
+
+ prev, loaded := p.activeChannels.Swap(chanID, lnChan)
+ if !loaded || prev == nil {
+ p.numActiveChans.Add(1)
+ }
+}
+
+// removeActiveChannel deletes chanID and decrements numActiveChans only
+// when the removed entry was a fully open (non-nil) channel.
+func (p *Brontide) removeActiveChannel(chanID lnwire.ChannelID) {
+ prev, loaded := p.activeChannels.LoadAndDelete(chanID)
+ if loaded && prev != nil {
+ p.numActiveChans.Add(-1)
+ }
+}
+
+// deletePendingChannel deletes a pending entry owned by the
+// channelManager goroutine; no counter check since pending entries never
+// contribute to numActiveChans. External callers must use
+// removeActiveChannel so a racing promotion is handled correctly.
+func (p *Brontide) deletePendingChannel(chanID lnwire.ChannelID) {
+ p.activeChannels.Delete(chanID)
+}
+
// storeError stores an error in our peer's buffer of recent errors with the
// current timestamp. Errors are only stored if we have at least one active
// channel with the peer to mitigate a dos vector where a peer costlessly
@@ -4712,7 +4770,10 @@ func WaitForChanToClose(bestHeight uint32, notifier chainntnfs.ChainNotifier,
func (p *Brontide) WipeChannel(chanPoint *wire.OutPoint) {
chanID := lnwire.NewChanIDFromOutPoint(*chanPoint)
- p.activeChannels.Delete(chanID)
+ // Remove the entry and adjust the active-channel counter atomically
+ // via the helper; it skips the decrement for pending (nil) entries
+ // since they never contributed to the counter in the first place.
+ p.removeActiveChannel(chanID)
// Instruct the HtlcSwitch to close this link as the channel is no
// longer active.
@@ -5433,8 +5494,11 @@ func (p *Brontide) addActiveChannel(c *lnpeer.NewChannel) error {
return fmt.Errorf("unable to create LightningChannel: %w", err)
}
- // Store the channel in the activeChannels map.
- p.activeChannels.Store(chanID, lnChan)
+ // Install the channel via the helper so the active-channel counter
+ // stays in lockstep: new inserts and pending-to-active promotions
+ // both bump the counter by exactly one, while the rare
+ // already-present case is a no-op.
+ p.storeActiveChannel(chanID, lnChan)
p.log.Infof("New channel active ChannelPoint(%v) with peer", chanPoint)
@@ -5557,7 +5621,7 @@ func (p *Brontide) handleNewPendingChannel(req *newChannelMsg) {
// This is a new channel, we now add it to the map `activeChannels`
// with nil value and mark it as a newly added channel in
// `addedChannels`.
- p.activeChannels.Store(chanID, nil)
+ p.markPendingChannel(chanID)
p.addedChannels.Store(chanID, struct{}{})
}
@@ -5584,8 +5648,16 @@ func (p *Brontide) handleRemovePendingChannel(req *newChannelMsg) {
p.log.Warnf("Channel(%v) not found, removing it anyway", chanID)
}
- // Remove the record of this pending channel.
- p.activeChannels.Delete(chanID)
+ // Delete the pending entry. handleRemovePendingChannel and
+ // handleNewActiveChannel are both arms of the channelManager
+ // select loop, so the Go runtime serializes them and the entry we
+ // delete here is guaranteed to be the pending (nil) one we stored
+ // via markPendingChannel — it cannot have been promoted behind our
+ // back. That rules out any numActiveChans decrement on this path,
+ // so we drop the defensive LoadAndDelete + conditional check the
+ // refactor left in place and use a plain Delete via
+ // deletePendingChannel.
+ p.deletePendingChannel(chanID)
p.addedChannels.Delete(chanID)
}
diff --git a/peer/brontide_test.go b/peer/brontide_test.go
index 8e0bd29..f4bb661 100644
--- a/peer/brontide_test.go
+++ b/peer/brontide_test.go
@@ -1767,3 +1767,79 @@ func TestCreateHtlcValidator(t *testing.T) {
})
}
}
+
+// TestHasActiveChannels exercises the atomic active-channel counter that
+// backs hasActiveChannels(). hasActiveChannels is on the hot path for
+// every incoming onion message — the onion message ingress gate calls
+// it per packet — so a correct, O(1) shadow of activeChannels is a
+// load-bearing invariant. This test walks the three state transitions
+// that have to keep numActiveChans in lockstep with activeChannels:
+// initial emptiness, pending entries (nil values) that must not count,
+// and pending-delete paths that must not decrement.
+func TestHasActiveChannels(t *testing.T) {
+ t.Parallel()
+
+ peer := NewBrontide(Config{})
+
+ // Initial state: no channels, counter is zero, gate is closed.
+ require.False(t, peer.hasActiveChannels())
+ require.Equal(t, int32(0), peer.numActiveChans.Load())
+
+ // Simulate the loadActiveChannels active-channel path: the entry
+ // is stored and the counter is incremented in lockstep. After
+ // this, hasActiveChannels must flip to true because the peer now
+ // holds a non-pending channel.
+ activeID := lnwire.ChannelID{0x01}
+ peer.activeChannels.Store(activeID, &lnwallet.LightningChannel{})
+ peer.numActiveChans.Add(1)
+ require.True(t, peer.hasActiveChannels())
+ require.Equal(t, int32(1), peer.numActiveChans.Load())
+
+ // Simulate the loadActiveChannels pending path: the entry is
+ // stored as nil and the counter must NOT move. This is the
+ // invariant the onion message gate relies on — pending channels
+ // are cheap to open and get stuck, so they must not satisfy the
+ // Sybil-resistance gate on their own.
+ pendingID := lnwire.ChannelID{0x02}
+ peer.activeChannels.Store(pendingID, nil)
+ require.Equal(t, int32(1), peer.numActiveChans.Load())
+ require.True(t, peer.hasActiveChannels())
+
+ // handleRemovePendingChannel walks the pending-delete path. It
+ // uses LoadAndDelete and must skip the counter decrement when
+ // the previous value was nil (pending). If this invariant ever
+ // broke, the counter would underflow every time a pending
+ // channel was cancelled and hasActiveChannels would return the
+ // wrong answer until the next reconnect.
+ errChan := make(chan error, 1)
+ peer.handleRemovePendingChannel(&newChannelMsg{
+ channelID: pendingID,
+ err: errChan,
+ })
+ require.Equal(t, int32(1), peer.numActiveChans.Load())
+ require.True(t, peer.hasActiveChannels())
+
+ // The pending entry must have been removed from the map.
+ _, found := peer.activeChannels.Load(pendingID)
+ require.False(t, found)
+
+ // Drain the request error channel so the test leaves no loose
+ // ends. handleRemovePendingChannel closes the err chan via
+ // defer, so we expect a closed-channel receive here.
+ _, reqOk := <-errChan
+ require.False(t, reqOk)
+
+ // Finally, simulate WipeChannel's decrement path directly via
+ // LoadAndDelete. We cannot call WipeChannel in this
+ // dummy-config harness because it also calls
+ // p.cfg.Switch.RemoveLink, but the counter-maintenance half of
+ // WipeChannel is exactly the LoadAndDelete + conditional Add(-1)
+ // we exercise here.
+ prev, loaded := peer.activeChannels.LoadAndDelete(activeID)
+ require.True(t, loaded)
+ require.NotNil(t, prev)
+ peer.numActiveChans.Add(-1)
+
+ require.False(t, peer.hasActiveChannels())
+ require.Equal(t, int32(0), peer.numActiveChans.Load())
+}
diff --git a/peer/onion_ratelimit.go b/peer/onion_ratelimit.go
index b9c9dcc..065bd6f 100644
--- a/peer/onion_ratelimit.go
+++ b/peer/onion_ratelimit.go
@@ -8,19 +8,35 @@ import (
"github.com/lightningnetwork/lnd/onionmessage"
)
-// allowOnionMessage delegates to the IngressLimiter for the
-// per-peer-then-global byte-granular rate limit check. A successful
-// result wraps fn.Unit; a rejection wraps one of the sentinel errors
-// onionmessage.ErrPeerRateLimit or onionmessage.ErrGlobalRateLimit so
+// ErrNoChannel is the sentinel error returned by allowOnionMessage when
+// the incoming peer has no fully open channel with us. It is the
+// primary Sybil-resistance layer on top of the byte-granular rate
+// limiters: an attacker that can cheaply spin up new identities cannot
+// burn any per-peer or global token budget because the channel gate
+// runs before the IngressLimiter is consulted at all.
+var ErrNoChannel = errors.New("peer has no open channel")
+
+// allowOnionMessage applies the channel-presence gate and then, if the
+// peer has at least one fully open channel with us, delegates to the
+// IngressLimiter for the per-peer-then-global byte-granular rate limit
+// check. The channel gate runs first on purpose: if it rejects, no rate
+// limiter state is allocated for the no-channel peer and neither bucket
+// is debited. A successful result wraps fn.Unit; a rejection wraps one
+// of the sentinel errors ErrNoChannel,
+// onionmessage.ErrPeerRateLimit, or onionmessage.ErrGlobalRateLimit so
// that callers can distinguish the drop reason via errors.Is.
//
// A nil IngressLimiter is treated as "disabled" and always accepts the
-// message. This preserves the behavior of test and disabled-onion-
-// messaging configurations without forcing callers to construct a real
-// limiter.
+// message once the channel gate passes. This preserves the behavior of
+// test and disabled-onion-messaging configurations without forcing
+// callers to construct a real limiter.
func allowOnionMessage(limiter onionmessage.IngressLimiter,
- peerKey [33]byte, msgBytes int) fn.Result[fn.Unit] {
+ peerKey [33]byte, msgBytes int,
+ hasChannel bool) fn.Result[fn.Unit] {
+ if !hasChannel {
+ return fn.Err[fn.Unit](ErrNoChannel)
+ }
if limiter == nil {
return fn.Ok(fn.Unit{})
}
diff --git a/peer/onion_ratelimit_log_test.go b/peer/onion_ratelimit_log_test.go
index ffac40d..002008b 100644
--- a/peer/onion_ratelimit_log_test.go
+++ b/peer/onion_ratelimit_log_test.go
@@ -2,7 +2,6 @@ package peer
import (
"bytes"
- "errors"
"testing"
"github.com/btcsuite/btclog/v2"
@@ -110,7 +109,7 @@ func TestLogFirstOnionDropUnknownReason(t *testing.T) {
peerLog, peerBuf := newCapturingLogger()
limiter := newRealIngressLimiter(t)
- logFirstOnionDrop(pkgLog, peerLog, errors.New("unknown"), limiter)
+ logFirstOnionDrop(pkgLog, peerLog, ErrNoChannel, limiter)
require.Empty(t, pkgBuf.String())
require.Empty(t, peerBuf.String())
diff --git a/peer/onion_ratelimit_test.go b/peer/onion_ratelimit_test.go
index af8031c..8434d2d 100644
--- a/peer/onion_ratelimit_test.go
+++ b/peer/onion_ratelimit_test.go
@@ -18,7 +18,9 @@ const testMsgBytes = 32 * 1024
// stubIngressLimiter is a test double for onionmessage.IngressLimiter
// that records every call and delegates the accept/reject decision to a
-// caller-supplied predicate.
+// caller-supplied predicate. It is used to exercise allowOnionMessage's
+// composition logic (channel gate → limiter) without standing up a
+// real token bucket.
type stubIngressLimiter struct {
// decide is invoked for every AllowN call. It receives the peer
// key and byte count and returns the error to embed in the
@@ -48,15 +50,49 @@ func (s *stubIngressLimiter) FirstPeerDropClaim() bool { return true }
// FirstGlobalDropClaim always returns true for the same reason.
func (s *stubIngressLimiter) FirstGlobalDropClaim() bool { return true }
+// acceptAll constructs a stubIngressLimiter whose AllowN always accepts.
+func acceptAll() *stubIngressLimiter {
+ return &stubIngressLimiter{
+ decide: func(_ [33]byte, _ int) error { return nil },
+ }
+}
+
// TestAllowOnionMessageNilLimiter verifies that allowOnionMessage treats
// a nil IngressLimiter as "disabled" and unconditionally accepts
-// messages.
+// messages, as long as the channel gate passes.
func TestAllowOnionMessageNilLimiter(t *testing.T) {
t.Parallel()
var peer [33]byte
- result := allowOnionMessage(nil, peer, testMsgBytes)
+ result := allowOnionMessage(nil, peer, testMsgBytes, true)
+ require.NoError(t, result.Err())
+}
+
+// TestAllowOnionMessageNoChannel verifies that messages from a peer
+// that does not have a fully open channel with us are dropped
+// unconditionally with ErrNoChannel, even when a real IngressLimiter
+// is configured. The stub records whether AllowN was consulted; it
+// must remain at zero to prove the channel gate runs before the
+// IngressLimiter.
+func TestAllowOnionMessageNoChannel(t *testing.T) {
+ t.Parallel()
+
+ limiter := acceptAll()
+
+ var key [33]byte
+ key[0] = 0x07
+
+ result := allowOnionMessage(limiter, key, testMsgBytes, false)
+ require.Error(t, result.Err())
+ require.True(t, errors.Is(result.Err(), ErrNoChannel))
+ require.Equal(t, uint64(0), limiter.calls.Load(),
+ "no-channel drop must not consult the IngressLimiter")
+
+ // Once the channel gate flips, the same key is accepted and the
+ // limiter is now consulted exactly once.
+ result = allowOnionMessage(limiter, key, testMsgBytes, true)
require.NoError(t, result.Err())
+ require.Equal(t, uint64(1), limiter.calls.Load())
}
// TestAllowOnionMessagePeerRejectsFirst verifies that a real
@@ -87,13 +123,13 @@ func TestAllowOnionMessagePeerRejectsFirst(t *testing.T) {
// First call drains the per-peer bucket; both limiters are
// consulted so global.calls bumps to 1.
- result := allowOnionMessage(limiter, key, testMsgBytes)
+ result := allowOnionMessage(limiter, key, testMsgBytes, true)
require.NoError(t, result.Err())
require.Equal(t, uint64(1), globalCalls.Load())
// Second call trips the per-peer limiter and must NOT consult
// the global limiter — globalCalls stays at 1.
- result = allowOnionMessage(limiter, key, testMsgBytes)
+ result = allowOnionMessage(limiter, key, testMsgBytes, true)
require.Error(t, result.Err())
require.True(t,
errors.Is(result.Err(), onionmessage.ErrPeerRateLimit),
@@ -138,7 +174,7 @@ func TestAllowOnionMessageGlobalRejects(t *testing.T) {
var key [33]byte
key[0] = 0x02
- result := allowOnionMessage(limiter, key, testMsgBytes)
+ result := allowOnionMessage(limiter, key, testMsgBytes, true)
require.Error(t, result.Err())
require.True(t,
errors.Is(result.Err(), onionmessage.ErrGlobalRateLimit),
@@ -167,7 +203,9 @@ func TestAllowOnionMessageHappyPath(t *testing.T) {
key[0] = 0x04
for i := 0; i < 10; i++ {
- result := allowOnionMessage(limiter, key, testMsgBytes)
+ result := allowOnionMessage(
+ limiter, key, testMsgBytes, true,
+ )
require.NoError(t, result.Err(), "iter %d", i)
}
require.Equal(t, uint64(0), peerLim.Dropped())
@@ -194,15 +232,19 @@ func TestAllowOnionMessagePeerIsolation(t *testing.T) {
// Drain peer A.
for i := 0; i < 2; i++ {
- result := allowOnionMessage(limiter, keyA, testMsgBytes)
+ result := allowOnionMessage(
+ limiter, keyA, testMsgBytes, true,
+ )
require.NoError(t, result.Err())
}
- result := allowOnionMessage(limiter, keyA, testMsgBytes)
+ result := allowOnionMessage(limiter, keyA, testMsgBytes, true)
require.Error(t, result.Err())
// Peer B must still have its full burst available.
for i := 0; i < 2; i++ {
- result := allowOnionMessage(limiter, keyB, testMsgBytes)
+ result := allowOnionMessage(
+ limiter, keyB, testMsgBytes, true,
+ )
require.NoError(t, result.Err(), "peer B slot %d", i)
}
}
@@ -240,7 +282,7 @@ func TestAllowOnionMessageConcurrent(t *testing.T) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
result := allowOnionMessage(
- limiter, key, testMsgBytes,
+ limiter, key, testMsgBytes, true,
)
if result.Err() == nil {
accepted.Add(1)
Why this scored 76/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.