What changed, and why it matters
This commit tightens how Electrum's Lightning peer handler accepts and queues certain ordered messages. It caps per-channel message queues at 10 entries, rejects ordered messages for unknown channels, and limits a temporary-channel tracking dictionary that previously grew without cleanup. These changes reduce the risk that a malicious or misbehaving peer could exhaust memory or fill queues to disrupt the node.
Review whether maxsize=10 is sufficient under normal Lightning protocol load and whether the exception on unknown channel IDs is safely handled by upper layers. Monitor for any peer disconnects caused by the new temp_id_to_id limit.
Security signals we found
Resource exhaustion mitigation: bounded asyncio.Queue prevents unbounded per-channel queue growth.
Unbounded memory growth fix: temp_id_to_id cleanup replaces prior TODO noting unbounded growth until disconnect.
Input validation: ordered messages for unknown channel IDs are now rejected instead of queued.
Disconnect threshold: temp_id_to_id size capped at 25 to limit peer-induced state growth.
Evidence from the diff
The patch modifies electrum/lnpeer.py to: (1) bound ordered_message_queues with maxsize=10 via functools.partial, (2) reject ORDERED_MESSAGES whose channel_id/temporary_channel_id is not present in self.channels, self.temp_id_to_id, or its values, and (3) add _cleanup_temp_channelids() to prune temp_id_to_id entries whose mapped channel is known and raise an exception if more than 25 temporary entries remain. The removed TODO comments explicitly noted the dict previously grew unbounded until disconnect.
Changed components
electrum/lnpeer.pyPeer.ordered_message_queuesPeer.temp_id_to_idPeer._cleanup_temp_channelidsLightning peer message handlingInspect captured patch +19 / −3
diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py
index 1b10d2c..90ad535 100644
--- a/electrum/lnpeer.py
+++ b/electrum/lnpeer.py
@@ -12,6 +12,7 @@ import time
from typing import Tuple, Dict, TYPE_CHECKING, Optional, Union, Set, Callable, Awaitable, List
from datetime import datetime
import functools
+from functools import partial
import electrum_ecc as ecc
from electrum_ecc import ecdsa_sig64_from_r_and_s, ecdsa_der_sig_from_ecdsa_sig64, ECPubkey
@@ -112,7 +113,7 @@ class Peer(Logger, EventListener):
self.our_gossip_timestamp_filter = None # type: Optional[GossipTimestampFilter]
self.their_gossip_timestamp_filter = None # type: Optional[GossipTimestampFilter]
self.outgoing_gossip_reply = False # type: bool
- self.ordered_message_queues = defaultdict(asyncio.Queue) # type: Dict[bytes, asyncio.Queue] # for messages that are ordered
+ self.ordered_message_queues = defaultdict(partial(asyncio.Queue, maxsize=10)) # type: Dict[bytes, asyncio.Queue] # for messages that are ordered
self.temp_id_to_id = {} # type: Dict[bytes, Optional[bytes]] # to forward error messages
self.funding_created_sent = set() # for channels in PREOPENING
self.funding_signed_sent = set() # for channels in PREOPENING
@@ -228,6 +229,12 @@ class Peer(Logger, EventListener):
return
if message_type in self.ORDERED_MESSAGES:
chan_id = payload.get('channel_id') or payload["temporary_channel_id"]
+ if (
+ chan_id not in self.channels
+ and chan_id not in self.temp_id_to_id
+ and chan_id not in self.temp_id_to_id.values()
+ ):
+ raise Exception(f"received {message_type} for unknown {chan_id.hex()=}")
self.ordered_message_queues[chan_id].put_nowait((message_type, payload))
else:
if message_type not in ('error', 'warning') and 'channel_id' in payload:
@@ -1085,8 +1092,8 @@ class Peer(Logger, EventListener):
int.from_bytes(per_commitment_secret_first, 'big'))
# store the temp id now, so that it is recognized for e.g. 'error' messages
- # TODO: this is never cleaned up; the dict grows unbounded until disconnect
self.temp_id_to_id[temp_channel_id] = None
+ self._cleanup_temp_channelids()
self.send_message(
"open_channel",
temporary_channel_id=temp_channel_id,
@@ -1305,8 +1312,8 @@ class Peer(Logger, EventListener):
feerate = payload['feerate_per_kw'] # note: we are not validating this
temp_chan_id = payload['temporary_channel_id']
# store the temp id now, so that it is recognized for e.g. 'error' messages
- # TODO: this is never cleaned up; the dict grows unbounded until disconnect
self.temp_id_to_id[temp_chan_id] = None
+ self._cleanup_temp_channelids()
channel_opening_fee = open_channel_tlvs.get('channel_opening_fee') if open_channel_tlvs else None
if channel_opening_fee:
# todo check that the fee is reasonable
@@ -1448,6 +1455,15 @@ class Peer(Logger, EventListener):
self.send_channel_ready(chan)
self.lnworker.add_new_channel(chan)
+ def _cleanup_temp_channelids(self) -> None:
+ self.temp_id_to_id = {
+ tmp_id: chan_id for (tmp_id, chan_id) in self.temp_id_to_id.items()
+ if chan_id not in self.channels
+ }
+ if len(self.temp_id_to_id) > 25:
+ # which one of us is opening all these chans?! let's disconnect
+ raise Exception("temp_id_to_id is getting too large.")
+
async def request_force_close(self, channel_id: bytes):
"""Try to trigger the remote peer to force-close."""
await self.initialized
Why this scored 63/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.