What changed, and why it matters
This commit adds rate limits on incoming ping messages in LND's peer connection handler. Without these limits, a malicious or misconfigured peer could send a flood of ping messages, forcing the node to waste CPU, memory, and bandwidth processing them and sending pong replies. The fix counts every incoming ping and disconnects peers that exceed a flood threshold, while also limiting how often pong replies are sent. This is a defensive hardening change against a potential denial-of-service attack.
Treat as a security hardening fix. Review whether the chosen rate limits are appropriate for your deployment's keepalive cadence and peer topology. Monitor logs for the new 'ping flood limit exceeded' disconnect reason. Consider backporting to maintained release branches.
Security signals we found
Adds rate limiting to inbound protocol messages
Disconnects peers on flood threshold exhaustion
Introduces stable sentinel error for flood teardown paths
Separates reply suppression from connection teardown
References BOLT 1 pong reply requirement
Evidence from the diff
The change introduces two token-bucket rate limiters per peer connection in peer/ping_limits.go: pingLimiter (10/sec, burst 200) counts every decoded Ping before generic routing and disconnects the peer via a stable errPingFlood sentinel if exhausted; pongLimiter (1/sec, burst 20) suppresses Pong replies when the remote ping rate is high, while keeping the connection alive. The limiters are wired into Brontide in peer/brontide.go and enforced in the main read loop. This addresses unbounded ping handling that could be exploited for CPU/amplification DoS.
Changed components
peer/brontide.gopeer/ping_limits.goBrontide peer read looplnwire.Ping handlinglnwire.Pong reply generationInspect captured patch +86 / −0
### peer/brontide.go
@@ -112,6 +112,11 @@ var (
// either the Brontide doesn't know of it, or the channel in question
// is pending.
ErrChannelNotFound = fmt.Errorf("channel not found")
+
+ // errPingFlood gives every flood-teardown path one stable identity. The
+ // peer still records the descriptive text, while callers and tests can
+ // match wrapped instances without depending on that text.
+ errPingFlood = errors.New("ping flood limit exceeded")
)
// outgoingMsg packages an lnwire.Message to be sent out on the wire, along with
@@ -585,6 +590,9 @@ type Brontide struct {
pingManager *PingManager
+ // pingLimits owns the two per-connection inbound Ping policies.
+ pingLimits pingLimits
+
// lastPingPayload stores an unsafe pointer wrapped as an atomic
// variable which points to the last payload the remote party sent us
// as their ping.
@@ -746,6 +754,7 @@ func NewBrontide(cfg Config) *Brontide {
activeSignal: make(chan struct{}),
sendQueue: make(chan outgoingMsg),
outgoingQueue: make(chan outgoingMsg),
+ pingLimits: defaultPingLimits(),
addedChannels: &lnutils.SyncMap[lnwire.ChannelID, struct{}]{},
activeChannels: &lnutils.SyncMap[
lnwire.ChannelID, *lnwallet.LightningChannel,
@@ -2316,6 +2325,22 @@ out:
}
}
+ // Count before routing; consuming endpoints skip the switch.
+ // All Pings, including oversized ones, use the flood budget.
+ if _, ok := nextMsg.(*lnwire.Ping); ok &&
+ !p.pingLimits.pingLimiter.Allow() {
+
+ p.storeError(errPingFlood)
+ p.log.Warnf("%v", errPingFlood)
+
+ // Stop Ping management before peer cancellation.
+ // Keep queue handling active so a Ping send can finish
+ // through outgoingQueue without deadlock.
+ p.Disconnect(errPingFlood)
+
+ break out
+ }
+
// If a message router is active, then we'll try to have it
// handle this message. If it can, then we're able to skip the
// rest of the message handling logic.
@@ -2355,6 +2380,14 @@ out:
continue
}
+ // BOLT 1 requires a Pong for every Ping below the size
+ // ceiling. We limit reply frequency to guard against
+ // floods; normal keepalives remain below this limit.
+ if !p.pingLimits.pongLimiter.Allow() {
+ p.log.Debugf("Pong reply rate limited")
+ continue
+ }
+
// Next, we'll send over the amount of specified pong
// bytes.
pong := lnwire.NewPong(p.cfg.PongBuf[0:msg.NumPongBytes])
### peer/ping_limits.go
@@ -0,0 +1,53 @@
+package peer
+
+import "golang.org/x/time/rate"
+
+const (
+ // pongReplyRate refills the reply budget quickly enough for normal
+ // keepalives while bounding sustained amplification from remote Pings.
+ pongReplyRate rate.Limit = 1
+
+ // pongReplyBurst absorbs short keepalive bursts without suppressing a
+ // reply before the sustained-rate policy has time to take effect.
+ pongReplyBurst = 20
+
+ // pingFloodRate admits substantially more inbound Pings than an honest
+ // keepalive cadence while placing a finite bound on sustained floods.
+ pingFloodRate rate.Limit = 10
+
+ // pingFloodBurst tolerates transient bursts before the peer is treated
+ // as a flood source and disconnected by the read loop.
+ pingFloodBurst = 200
+)
+
+// pingLimits holds the stateful limiters for the two inbound Ping policies.
+// Keeping them together makes their different outcomes explicit without
+// exposing fixed denial-of-service thresholds as operator configuration.
+type pingLimits struct {
+ // pongLimiter controls whether a valid Ping receives a Pong. Exhausting
+ // this limiter suppresses the reply but leaves the connection active.
+ pongLimiter *rate.Limiter
+
+ // pingLimiter counts every inbound Ping. Exhausting this limiter
+ // disconnects the peer, including for Pings that request no reply.
+ pingLimiter *rate.Limiter
+}
+
+// defaultPingLimits constructs independent limiter state for a new peer. The
+// selected rates leave ample room above normal keepalive traffic while
+// separating reply suppression from flood teardown.
+func defaultPingLimits() pingLimits {
+ return pingLimits{
+ // Refill one Pong per second and absorb a 20-Ping burst,
+ // leaving wide headroom above honest keepalives.
+ pongLimiter: rate.NewLimiter(
+ pongReplyRate, pongReplyBurst,
+ ),
+
+ // Permit ten Pings per second and a burst of 200 before
+ // treating the connection as a flood source.
+ pingLimiter: rate.NewLimiter(
+ pingFloodRate, pingFloodBurst,
+ ),
+ }
+}Why this scored 65/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.