lnworker: limit max number of incoming channel-less peers
What changed, and why it matters
This commit adds a cap on how many incoming Lightning Network peers without channels can connect to an Electrum node at once. Before this change, an attacker could potentially open unlimited incoming peer connections, consuming memory, CPU, and network resources and possibly disrupting normal operation. The fix limits such peers to 100 and closes new connections once that limit is reached. It also fixes a related bug where an existing initialized peer connection was not being closed when a duplicate arrived.
Treat this as a security hardening / DoS mitigation patch. Users running Electrum Lightning nodes should upgrade to a version containing this commit. Monitor for follow-up improvements such as IP-based slot accounting mentioned in the TODO.
Security signals we found
Unbounded resource consumption by incoming channel-less peers mitigated by a hard limit
Connection tracking set added to enforce per-state limit
New connections over the limit are closed immediately
Duplicate initialized peer transport now closed instead of leaked
Code comments discuss attacker-driven slot exhaustion and self-DoS trade-offs
Evidence from the diff
The patch introduces _channelless_incoming_peers, a set tracking node IDs of incoming peers that have no funded channels. In _add_peer_from_transport, when the transport is an LNResponderTransport and the peer has no funded channels, the code checks whether more than 100 such peers are already connected. If so, it closes the new transport and returns None, preventing unbounded growth of idle incoming connections. It also adds cleanup of the tracking set when peers are removed and fixes a leak where a duplicate initialized peer’s transport was left open. The commit message and comments explicitly frame this as a DoS mitigation.
Changed components
electrum/lnworker.pyLNWorker incoming peer acceptance logicLightning Network transport handlingInspect captured patch +24 / −2
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 6f5c26d..6395aa3 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -202,6 +202,7 @@ class LNWorker(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
self.lock = threading.RLock()
self.node_keypair = node_keypair
self._peers = {} # type: Dict[bytes, Peer] # pubkey -> Peer # needs self.lock
+ self._channelless_incoming_peers = set() # type: Set[bytes] # node_ids # needs self.lock
self.taskgroup = OldTaskGroup()
self.listen_server = None # type: Optional[asyncio.AbstractServer]
self.features = features
@@ -252,13 +253,15 @@ class LNWorker(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
return
addr = str(netaddr.host)
- async def cb(reader, writer):
+ async def cb(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
transport = LNResponderTransport(self.node_keypair.privkey, reader, writer)
try:
node_id = await transport.handshake()
except Exception as e:
self.logger.info(f'handshake failure from incoming connection: {e!r}')
return
+ peername = writer.get_extra_info('peername')
+ self.logger.debug(f"handshake done for incoming peer: {peername=}, node_id={node_id.hex()}")
await self._add_peer_from_transport(node_id=node_id, transport=transport)
try:
self.listen_server = await asyncio.start_server(cb, addr, netaddr.port)
@@ -315,11 +318,29 @@ class LNWorker(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
# both keep trying to reconnect, resulting in neither being usable.
if existing_peer.is_initialized():
# give priority to the existing connection
- return
+ transport.close()
+ return None
else:
# Use the new connection. (e.g. old peer might be an outgoing connection
# for an outdated host/port that will never connect)
existing_peer.close_and_cleanup()
+ # limit max number of incoming channel-less peers.
+ # what to do if limit is reached?
+ # - chosen strategy: we don't allow new connections.
+ # - drawback: attacker can use up all our slots
+ # - alternative: kick oldest channel-less peer
+ # - drawback: if many legit peers want to connect to us, we will keep kicking them
+ # in round-robin, and they will keep reconnecting. no stable state -> we self-DOS
+ # TODO make slots IP-based?
+ if isinstance(transport, LNResponderTransport):
+ assert node_id not in self._channelless_incoming_peers
+ chans = [chan for chan in self.channels_for_peer(node_id).values() if chan.is_funded()]
+ if not chans:
+ if len(self._channelless_incoming_peers) > 100:
+ transport.close()
+ return None
+ self._channelless_incoming_peers.add(node_id)
+ # checks done: we are adding this peer.
peer = Peer(self, node_id, transport)
assert node_id not in self._peers
self._peers[node_id] = peer
@@ -331,6 +352,7 @@ class LNWorker(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
peer2 = self._peers.get(peer.pubkey)
if peer2 is peer:
self._peers.pop(peer.pubkey)
+ self._channelless_incoming_peers.discard(peer.pubkey)
def num_peers(self) -> int:
return sum([p.is_initialized() for p in self.peers.values()])
Why this scored 62/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.