Skip to content

Commit f0caea3

Browse files
tcoratgerclaude
andauthored
fix(gossipsub): bound per-RPC frame length before buffering (leanEthereum#950)
The incoming-RPC framing loop accepted any varint-declared frame length and waited across reads until that many bytes arrived, with no upper bound. A peer could declare a huge frame and force the buffer to grow without ever decoding, exhausting memory. Reject any frame whose declared length exceeds the existing payload cap before waiting for the bytes, matching the reqresp codec guard. The oversized frame disconnects the peer cleanly via the receive loop's existing error handling. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 87130e2 commit f0caea3

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

src/lean_spec/node/networking/gossipsub/behavior.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,13 @@ async def _receive_loop(self, peer_id: PeerId, stream: QuicStreamAdapter) -> Non
10101010
# Incomplete varint -- wait for more data.
10111011
break
10121012

1013+
# A declared length is attacker-controlled.
1014+
# Without an upper bound, a peer can claim a huge frame
1015+
# and force us to buffer reads forever without ever decoding.
1016+
# Reject and disconnect before waiting for any of those bytes.
1017+
if length > MAX_PAYLOAD_SIZE:
1018+
raise ValueError(f"RPC frame too large: {length} > {MAX_PAYLOAD_SIZE}")
1019+
10131020
# Not enough bytes yet -- wait for the next read.
10141021
if len(buffer) < varint_size + length:
10151022
break

tests/node/networking/gossipsub/test_behavior.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import logging
1011
import time
1112

1213
import pytest
@@ -33,6 +34,7 @@
3334
SubOpts,
3435
)
3536
from lean_spec.node.networking.gossipsub.types import MessageId, Timestamp, TopicId
37+
from lean_spec.node.networking.varint import encode_varint
3638
from lean_spec.node.snappy import compress as snappy_compress
3739
from tests.node.networking.gossipsub.conftest import add_peer, make_behavior, make_peer
3840

@@ -1178,3 +1180,47 @@ async def test_unsubscribe_prunes_mesh_peers(self) -> None:
11781180
assert sub_sends == [(p1, sub_rpc), (p2, sub_rpc)]
11791181
assert {peer_id for peer_id, _ in prune_sends} == {p1, p2}
11801182
assert all(rpc == prune_rpc for _, rpc in prune_sends)
1183+
1184+
1185+
class TestReceiveLoop:
1186+
"""Tests for the incoming-RPC framing loop."""
1187+
1188+
@pytest.mark.asyncio
1189+
async def test_oversized_declared_frame_disconnects_without_buffering(
1190+
self, caplog: pytest.LogCaptureFixture
1191+
) -> None:
1192+
"""An over-limit declared frame length disconnects the peer after one read."""
1193+
behavior, _ = make_behavior()
1194+
peer_id = add_peer(behavior, "peerA")
1195+
1196+
# A peer claims a frame one byte larger than the payload cap,
1197+
# but sends none of the promised bytes.
1198+
# A bounded loop must reject on the length alone, not wait for the bytes.
1199+
oversized_frame_prefix = encode_varint(MAX_PAYLOAD_SIZE + 1)
1200+
1201+
read_count = 0
1202+
handled_rpcs: list[RPC] = []
1203+
1204+
async def fake_read(n: int | None = None) -> bytes:
1205+
nonlocal read_count
1206+
read_count += 1
1207+
# Serve the oversized length prefix once, then empty on any later read.
1208+
return oversized_frame_prefix if read_count == 1 else b""
1209+
1210+
async def record_handled_rpc(_peer_id: object, rpc: RPC) -> None:
1211+
handled_rpcs.append(rpc)
1212+
1213+
behavior._handle_rpc = record_handled_rpc # type: ignore[assignment]
1214+
1215+
fake_stream = type("FakeStream", (), {"read": staticmethod(fake_read)})()
1216+
with caplog.at_level(logging.WARNING):
1217+
await behavior._receive_loop(peer_id, fake_stream)
1218+
1219+
# Rejected on the declared length alone: a single read, no decode, peer gone.
1220+
assert read_count == 1
1221+
assert handled_rpcs == []
1222+
assert peer_id not in behavior._peers
1223+
assert caplog.messages == [
1224+
f"Error receiving from {peer_id}: "
1225+
f"RPC frame too large: {MAX_PAYLOAD_SIZE + 1} > {MAX_PAYLOAD_SIZE}"
1226+
]

0 commit comments

Comments
 (0)