lncfg+peer+server: add protocol.onion-msg-relay-all to bypass channel gate
What changed, and why it matters
This commit adds a new optional configuration flag, protocol.onion-msg-relay-all, that lets a node operator choose whether to accept onion messages only from peers that already have a payment channel (the default, safer behavior) or from any peer. It does not change the default behavior and does not fix a vulnerability; it is a feature addition that preserves the existing security gate unless explicitly disabled.
No security patch action required. Operators should leave protocol.onion-msg-relay-all at its default false unless they explicitly need to accept onion messages from non-channel peers and understand the described Sybil/DoS trade-off.
Security signals we found
Adds opt-in flag that weakens a Sybil-resistance gate when enabled
Default behavior unchanged; channel-presence gate remains active
Commentary explicitly describes the security trade-off of enabling the flag
No vulnerability fix, bounds check, or memory-safety change present
Evidence from the diff
The change introduces OnionMsgRelayAll in lncfg.ProtocolOptions (both default and integration build variants), passes it through server.go as peer.Config.OnionRelayAll, and uses it in peer/brontide.go’s allowOnionMessage call. The gate logic in peer/onion_ratelimit.go becomes if !relayAll && !hasChannel { drop }, so the default (relayAll=false) keeps the existing channel-presence gate. Tests and sample-lnd.conf are updated accordingly. No bug is fixed; the commit adds an opt-in policy toggle.
Changed components
lncfg/protocol.golncfg/protocol_integration.gopeer/brontide.gopeer/onion_ratelimit.gopeer/onion_ratelimit_test.gosample-lnd.confserver.goInspect captured patch +97 / −13
diff --git a/lncfg/protocol.go b/lncfg/protocol.go
index 67a4401..e95e3cf 100644
--- a/lncfg/protocol.go
+++ b/lncfg/protocol.go
@@ -97,6 +97,16 @@ type ProtocolOptions struct {
// with a zero rate, disables the global limiter.
OnionMsgGlobalBurstBytes uint64 `long:"onion-msg-global-burst-bytes" description:"token bucket burst for the global onion message limiter, in bytes; set both this and onion-msg-global-kbps to 0 to disable the global limiter"`
+ // OnionMsgRelayAll disables the channel-presence gate on the onion
+ // message ingress path. When false (the default), incoming onion
+ // messages from peers that do not have at least one fully open
+ // channel with us are dropped before the rate limiters are
+ // consulted: without a funded channel, a new peer identity is free
+ // and the global rate limiter alone is easy to saturate. Setting
+ // this to true admits onion messages from any peer into the
+ // limiter pipeline, at the cost of that Sybil-resistance property.
+ OnionMsgRelayAll bool `long:"onion-msg-relay-all" description:"accept incoming onion messages from peers with no fully open channel; by default only peers with at least one active channel are admitted to the onion message ingress path"`
+
// NoExperimentalAccountabilityOption disables experimental accountability.
NoExperimentalAccountabilityOption bool `long:"no-experimental-accountability" description:"do not forward experimental accountability signals"`
diff --git a/lncfg/protocol_integration.go b/lncfg/protocol_integration.go
index b28b031..4fcaa64 100644
--- a/lncfg/protocol_integration.go
+++ b/lncfg/protocol_integration.go
@@ -100,6 +100,16 @@ type ProtocolOptions struct {
// with a zero rate, disables the global limiter.
OnionMsgGlobalBurstBytes uint64 `long:"onion-msg-global-burst-bytes" description:"token bucket burst for the global onion message limiter, in bytes; set both this and onion-msg-global-kbps to 0 to disable the global limiter"`
+ // OnionMsgRelayAll disables the channel-presence gate on the onion
+ // message ingress path. When false (the default), incoming onion
+ // messages from peers that do not have at least one fully open
+ // channel with us are dropped before the rate limiters are
+ // consulted: without a funded channel, a new peer identity is free
+ // and the global rate limiter alone is easy to saturate. Setting
+ // this to true admits onion messages from any peer into the
+ // limiter pipeline, at the cost of that Sybil-resistance property.
+ OnionMsgRelayAll bool `long:"onion-msg-relay-all" description:"accept incoming onion messages from peers with no fully open channel; by default only peers with at least one active channel are admitted to the onion message ingress path"`
+
// NoExperimentalAccountabilityOption disables experimental accountability.
NoExperimentalAccountabilityOption bool `long:"no-experimental-accountability" description:"do not forward experimental accountability signals"`
diff --git a/peer/brontide.go b/peer/brontide.go
index 470c004..5ab4e8f 100644
--- a/peer/brontide.go
+++ b/peer/brontide.go
@@ -328,6 +328,15 @@ type Config struct {
// disabled.
OnionLimiter onionmessage.IngressLimiter
+ // OnionRelayAll, when true, disables the channel-presence gate on
+ // incoming onion messages: messages from peers with no fully open
+ // channel are admitted to the rate-limiter pipeline instead of
+ // being dropped at ingress. The default (false) keeps the gate in
+ // place so that a no-cost Sybil identity cannot burn a full
+ // per-peer byte budget on each of many connections and saturate
+ // the global limiter through sheer identity count.
+ OnionRelayAll bool
+
// OnionActorOpts returns ActorOptions for the onion peer actor
// being spawned for the given peer. This allows per-peer
// customization of mailbox size, drop predicates, etc.
@@ -2366,6 +2375,7 @@ out:
result := allowOnionMessage(
p.cfg.OnionLimiter, p.PubKey(),
msg.WireSize(), p.hasActiveChannels(),
+ p.cfg.OnionRelayAll,
)
if err := result.Err(); err != nil {
logFirstOnionDrop(
diff --git a/peer/onion_ratelimit.go b/peer/onion_ratelimit.go
index 065bd6f..d6a41ad 100644
--- a/peer/onion_ratelimit.go
+++ b/peer/onion_ratelimit.go
@@ -26,15 +26,21 @@ var ErrNoChannel = errors.New("peer has no open channel")
// onionmessage.ErrPeerRateLimit, or onionmessage.ErrGlobalRateLimit so
// that callers can distinguish the drop reason via errors.Is.
//
+// When relayAll is true, the channel gate is skipped entirely and the
+// message is admitted to the IngressLimiter regardless of hasChannel.
+// This is the opt-in policy for operators who want to accept onion
+// messages from peers with no channel, trading the Sybil-resistance
+// property of the gate for broader reachability.
+//
// A nil IngressLimiter is treated as "disabled" and always accepts the
// 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,
- hasChannel bool) fn.Result[fn.Unit] {
+ hasChannel, relayAll bool) fn.Result[fn.Unit] {
- if !hasChannel {
+ if !relayAll && !hasChannel {
return fn.Err[fn.Unit](ErrNoChannel)
}
if limiter == nil {
diff --git a/peer/onion_ratelimit_test.go b/peer/onion_ratelimit_test.go
index 8434d2d..380f979 100644
--- a/peer/onion_ratelimit_test.go
+++ b/peer/onion_ratelimit_test.go
@@ -64,7 +64,7 @@ func TestAllowOnionMessageNilLimiter(t *testing.T) {
t.Parallel()
var peer [33]byte
- result := allowOnionMessage(nil, peer, testMsgBytes, true)
+ result := allowOnionMessage(nil, peer, testMsgBytes, true, false)
require.NoError(t, result.Err())
}
@@ -82,7 +82,7 @@ func TestAllowOnionMessageNoChannel(t *testing.T) {
var key [33]byte
key[0] = 0x07
- result := allowOnionMessage(limiter, key, testMsgBytes, false)
+ result := allowOnionMessage(limiter, key, testMsgBytes, false, false)
require.Error(t, result.Err())
require.True(t, errors.Is(result.Err(), ErrNoChannel))
require.Equal(t, uint64(0), limiter.calls.Load(),
@@ -90,11 +90,50 @@ func TestAllowOnionMessageNoChannel(t *testing.T) {
// Once the channel gate flips, the same key is accepted and the
// limiter is now consulted exactly once.
- result = allowOnionMessage(limiter, key, testMsgBytes, true)
+ result = allowOnionMessage(limiter, key, testMsgBytes, true, false)
require.NoError(t, result.Err())
require.Equal(t, uint64(1), limiter.calls.Load())
}
+// TestAllowOnionMessageRelayAll verifies that enabling relayAll skips
+// the channel-presence gate: a peer with no fully open channel is
+// admitted into the IngressLimiter instead of being rejected at the
+// gate. Exercising the same (key, hasChannel=false) input with
+// relayAll flipped on and off proves the flag is the only thing that
+// decides the gate outcome.
+func TestAllowOnionMessageRelayAll(t *testing.T) {
+ t.Parallel()
+
+ limiter := acceptAll()
+
+ var key [33]byte
+ key[0] = 0x08
+
+ // Gate enforced: no-channel peer is rejected without consulting
+ // the IngressLimiter.
+ result := allowOnionMessage(limiter, key, testMsgBytes, false, false)
+ require.Error(t, result.Err())
+ require.True(t, errors.Is(result.Err(), ErrNoChannel))
+ require.Equal(t, uint64(0), limiter.calls.Load())
+
+ // Gate skipped: the same no-channel peer is now admitted and the
+ // IngressLimiter is consulted.
+ result = allowOnionMessage(limiter, key, testMsgBytes, false, true)
+ require.NoError(t, result.Err())
+ require.Equal(t, uint64(1), limiter.calls.Load())
+
+ // relayAll with a peer that also has a channel: the gate is
+ // trivially satisfied and the limiter is consulted again.
+ result = allowOnionMessage(limiter, key, testMsgBytes, true, true)
+ require.NoError(t, result.Err())
+ require.Equal(t, uint64(2), limiter.calls.Load())
+
+ // Nil limiter with relayAll is still accepted: disabled limiter +
+ // skipped gate = unconditional accept.
+ result = allowOnionMessage(nil, key, testMsgBytes, false, true)
+ require.NoError(t, result.Err())
+}
+
// TestAllowOnionMessagePeerRejectsFirst verifies that a real
// IngressLimiter consults the per-peer limiter before the global
// limiter: once the per-peer bucket is drained, the global bucket
@@ -123,13 +162,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, true)
+ result := allowOnionMessage(limiter, key, testMsgBytes, true, false)
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, true)
+ result = allowOnionMessage(limiter, key, testMsgBytes, true, false)
require.Error(t, result.Err())
require.True(t,
errors.Is(result.Err(), onionmessage.ErrPeerRateLimit),
@@ -174,7 +213,7 @@ func TestAllowOnionMessageGlobalRejects(t *testing.T) {
var key [33]byte
key[0] = 0x02
- result := allowOnionMessage(limiter, key, testMsgBytes, true)
+ result := allowOnionMessage(limiter, key, testMsgBytes, true, false)
require.Error(t, result.Err())
require.True(t,
errors.Is(result.Err(), onionmessage.ErrGlobalRateLimit),
@@ -204,7 +243,7 @@ func TestAllowOnionMessageHappyPath(t *testing.T) {
for i := 0; i < 10; i++ {
result := allowOnionMessage(
- limiter, key, testMsgBytes, true,
+ limiter, key, testMsgBytes, true, false,
)
require.NoError(t, result.Err(), "iter %d", i)
}
@@ -233,17 +272,17 @@ func TestAllowOnionMessagePeerIsolation(t *testing.T) {
// Drain peer A.
for i := 0; i < 2; i++ {
result := allowOnionMessage(
- limiter, keyA, testMsgBytes, true,
+ limiter, keyA, testMsgBytes, true, false,
)
require.NoError(t, result.Err())
}
- result := allowOnionMessage(limiter, keyA, testMsgBytes, true)
+ result := allowOnionMessage(limiter, keyA, testMsgBytes, true, false)
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, true,
+ limiter, keyB, testMsgBytes, true, false,
)
require.NoError(t, result.Err(), "peer B slot %d", i)
}
@@ -282,7 +321,7 @@ func TestAllowOnionMessageConcurrent(t *testing.T) {
defer wg.Done()
for i := 0; i < perWorker; i++ {
result := allowOnionMessage(
- limiter, key, testMsgBytes, true,
+ limiter, key, testMsgBytes, true, false,
)
if result.Err() == nil {
accepted.Add(1)
diff --git a/sample-lnd.conf b/sample-lnd.conf
index 4644b98..018741c 100644
--- a/sample-lnd.conf
+++ b/sample-lnd.conf
@@ -1502,6 +1502,14 @@
; protocol.onion-msg-global-kbps to 0.
; protocol.onion-msg-global-burst-bytes=1638400
+; If set, accept incoming onion messages from peers that do not have a
+; fully open channel with us. By default only peers with at least one
+; active channel are admitted to the onion message ingress path, so
+; that a new peer identity cannot burn onion bandwidth without first
+; paying the capital cost of opening a channel. Enable this only if
+; you want your node to accept onion messages from arbitrary peers.
+; protocol.onion-msg-relay-all=false
+
; Set to handle messages of a particular type that falls outside of the
; custom message number range (i.e. 513 is onion messages). Note that you can
; set this option as many times as you want to support more than one custom
diff --git a/server.go b/server.go
index d50eb2f..9eddf92 100644
--- a/server.go
+++ b/server.go
@@ -4511,6 +4511,7 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq,
SphinxPayment: s.sphinxPayment,
SpawnOnionActor: s.onionActorFactory,
OnionLimiter: s.onionLimiter,
+ OnionRelayAll: s.cfg.ProtocolOptions.OnionMsgRelayAll,
OnionActorOpts: func(_ [33]byte) []actor.ActorOption[
*onionmessage.Request, *onionmessage.Response,
] {
Why this scored 15/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.