Skip to content

Commit 87a305d

Browse files
authored
fix(relay): enforce destination reservation on Circuit Relay v2 CONNECT (#1343)
Fixes #1342. Closes a security-relevant Circuit Relay v2 opt-in bypass where CONNECT checked the source reservation instead of the destination's. Credit: Yann Lorwyn.
2 parents 39a3b6c + 84bd0c5 commit 87a305d

9 files changed

Lines changed: 231 additions & 13 deletions

File tree

libp2p/relay/circuit_v2/pb/circuit.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ message Status {
4949
PERMISSION_DENIED = 102;
5050
CONNECTION_FAILED = 200;
5151
DIAL_REFUSED = 201;
52+
NO_RESERVATION = 204;
5253
STOP_FAILED = 300;
5354
MALFORMED_MESSAGE = 400;
5455
}

libp2p/relay/circuit_v2/pb/circuit_pb2.py

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libp2p/relay/circuit_v2/pb/circuit_pb2.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ class Status(google.protobuf.message.Message):
166166
PERMISSION_DENIED: Status._Code.ValueType # 102
167167
CONNECTION_FAILED: Status._Code.ValueType # 200
168168
DIAL_REFUSED: Status._Code.ValueType # 201
169+
NO_RESERVATION: Status._Code.ValueType # 204
169170
STOP_FAILED: Status._Code.ValueType # 300
170171
MALFORMED_MESSAGE: Status._Code.ValueType # 400
171172

@@ -176,6 +177,7 @@ class Status(google.protobuf.message.Message):
176177
PERMISSION_DENIED: Status.Code.ValueType # 102
177178
CONNECTION_FAILED: Status.Code.ValueType # 200
178179
DIAL_REFUSED: Status.Code.ValueType # 201
180+
NO_RESERVATION: Status.Code.ValueType # 204
179181
STOP_FAILED: Status.Code.ValueType # 300
180182
MALFORMED_MESSAGE: Status.Code.ValueType # 400
181183

libp2p/relay/circuit_v2/protocol.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,7 @@ async def _handle_reserve(self, stream: INetStream, msg: HopMessage) -> None:
585585
status_msg_text = "Reservation accepted"
586586

587587
# Get the reservation object to access its voucher and sign it
588-
reservation_obj = self.resource_manager._reservations.get(peer_id)
588+
reservation_obj = self.resource_manager.get_reservation(peer_id)
589589
if not reservation_obj:
590590
raise ValueError(f"Failed to create reservation for peer {peer_id}")
591591

@@ -688,14 +688,27 @@ async def _handle_connect(self, stream: INetStream, msg: HopMessage) -> None:
688688
await stream.reset()
689689
return
690690

691-
# Check resource limits
692-
if not self.resource_manager.can_accept_connection(peer_id=source_addr):
691+
if not self.resource_manager.can_accept_connection(peer_id=peer_id):
692+
relay_envelope_bytes, _ = env_to_send_in_RPC(self.host)
693+
relay_envelope = unmarshal_envelope(relay_envelope_bytes)
694+
await self._send_status(
695+
stream,
696+
StatusCode.NO_RESERVATION,
697+
"Destination peer has no active reservation on this relay",
698+
relay_envelope,
699+
)
700+
await stream.reset()
701+
return
702+
703+
# Separately enforce the source peer's per-reservation connection limit.
704+
source_reservation = self.resource_manager.get_reservation(source_addr)
705+
if source_reservation and not source_reservation.can_accept_connection():
693706
relay_envelope_bytes, _ = env_to_send_in_RPC(self.host)
694707
relay_envelope = unmarshal_envelope(relay_envelope_bytes)
695708
await self._send_status(
696709
stream,
697710
StatusCode.RESOURCE_LIMIT_EXCEEDED,
698-
"Connection limit exceeded",
711+
"Source peer has exceeded its connection limit",
699712
relay_envelope,
700713
)
701714
await stream.reset()
@@ -772,7 +785,7 @@ async def _handle_connect(self, stream: INetStream, msg: HopMessage) -> None:
772785
logger.debug("Connection established for peer %s", peer_id)
773786

774787
# Update reservation connection count
775-
reservation = self.resource_manager._reservations.get(peer_id)
788+
reservation = self.resource_manager.get_reservation(peer_id)
776789
if reservation:
777790
reservation.active_connections += 1
778791
logger.debug(
@@ -848,7 +861,7 @@ async def _relay_data(
848861
"""
849862
try:
850863
# Get the reservation for tracking data usage
851-
reservation = self.resource_manager._reservations.get(peer_id)
864+
reservation = self.resource_manager.get_reservation(peer_id)
852865
total_bytes = 0
853866

854867
while True:
@@ -896,7 +909,7 @@ async def _relay_data(
896909
break
897910

898911
# Update resource usage
899-
reservation = self.resource_manager._reservations.get(peer_id)
912+
reservation = self.resource_manager.get_reservation(peer_id)
900913
if reservation:
901914
reservation.data_used += len(data)
902915
if reservation.data_used >= reservation.limits.data:

libp2p/relay/circuit_v2/protocol_buffer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class StatusCode(IntEnum):
2323
PERMISSION_DENIED = 102
2424
CONNECTION_FAILED = 200
2525
DIAL_REFUSED = 201
26+
NO_RESERVATION = 204
2627
STOP_FAILED = 300
2728
MALFORMED_MESSAGE = 400
2829

libp2p/relay/circuit_v2/resources.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ def can_accept_connection(self, peer_id: ID) -> bool:
461461
True if the connection can be accepted
462462
463463
"""
464-
reservation = self._reservations.get(peer_id)
464+
reservation = self.get_reservation(peer_id)
465465
return reservation is not None and reservation.can_accept_connection()
466466

467467
def track_data_transfer(self, peer_id: ID, bytes_transferred: int) -> bool:
@@ -552,3 +552,23 @@ def refresh_reservation(self, peer_id: ID) -> int:
552552
return self.limits.duration
553553

554554
return 0
555+
556+
def get_reservation(self, peer_id: ID) -> Reservation | None:
557+
"""
558+
Get an active reservation for a peer.
559+
560+
Parameters
561+
----------
562+
peer_id : ID
563+
The peer ID to get the reservation for
564+
565+
Returns
566+
-------
567+
Reservation | None
568+
The reservation if it exists and is active, None otherwise
569+
570+
"""
571+
reservation = self._reservations.get(peer_id)
572+
if reservation and not reservation.is_expired():
573+
return reservation
574+
return None

newsfragments/1342.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed Circuit Relay v2 issue where a source peer could connect to a destination peer without an active reservation on the relay.

tests/core/relay/test_circuit_v2_protocol.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,3 +1218,165 @@ async def test_reservation_fails_with_invalid_record_transfer():
12181218
logger.info("Invalid SPR was correctly rejected")
12191219

12201220
logger.info("Invalid SPR correctly rejected, peerstore protected")
1221+
1222+
1223+
@pytest.mark.trio
1224+
async def test_circuit_v2_connect_fails_without_reservation():
1225+
"""Test that relay rejects CONNECT requests for unreserved destinations."""
1226+
async with HostFactory.create_batch_and_listen(3) as hosts:
1227+
relay_host, source_host, dest_host = hosts
1228+
logger.info(
1229+
"Created hosts for test_circuit_v2_connect_fails_without_reservation"
1230+
)
1231+
1232+
# Setup relay
1233+
limits = RelayLimits(
1234+
duration=DEFAULT_RELAY_LIMITS.duration,
1235+
data=DEFAULT_RELAY_LIMITS.data,
1236+
max_circuit_conns=DEFAULT_RELAY_LIMITS.max_circuit_conns,
1237+
max_reservations=DEFAULT_RELAY_LIMITS.max_reservations,
1238+
)
1239+
relay_protocol = CircuitV2Protocol(relay_host, limits, allow_hop=True)
1240+
1241+
async with background_trio_service(relay_protocol):
1242+
await relay_protocol.event_started.wait()
1243+
1244+
# Connect source to relay
1245+
await connect(source_host, relay_host)
1246+
await trio.sleep(SLEEP_TIME)
1247+
1248+
# Source sends CONNECT request for dest (who has NO reservation)
1249+
stream = None
1250+
try:
1251+
with trio.fail_after(STREAM_TIMEOUT):
1252+
stream = await source_host.new_stream(
1253+
relay_host.get_id(), [PROTOCOL_ID]
1254+
)
1255+
1256+
connect_msg = proto.HopMessage(
1257+
type=proto.HopMessage.CONNECT,
1258+
peer=dest_host.get_id().to_bytes(),
1259+
)
1260+
1261+
await stream.write(connect_msg.SerializeToString())
1262+
logger.info(
1263+
"Sent CONNECT request for destination without reservation"
1264+
)
1265+
1266+
# Read response
1267+
response_bytes = await stream.read(MAX_READ_LEN)
1268+
assert response_bytes, "No response received"
1269+
1270+
response = proto.HopMessage()
1271+
response.ParseFromString(response_bytes)
1272+
1273+
# Verify response status is NO_RESERVATION (204)
1274+
assert response.type == proto.HopMessage.STATUS
1275+
assert response.status.code == proto.Status.NO_RESERVATION, (
1276+
"Expected status NO_RESERVATION(204), "
1277+
f"got {response.status.code}"
1278+
)
1279+
1280+
# Verify stream is reset (or EOF)
1281+
try:
1282+
next_data = await stream.read(MAX_READ_LEN)
1283+
assert not next_data, (
1284+
"Stream should be closed/reset after error status"
1285+
)
1286+
except (StreamEOF, StreamReset, StreamError):
1287+
pass
1288+
1289+
finally:
1290+
if stream:
1291+
await close_stream(stream)
1292+
1293+
1294+
@pytest.mark.trio
1295+
async def test_circuit_v2_connect_fails_when_source_limit_exceeded():
1296+
"""Test that relay rejects CONNECT when source exceeds connection limit."""
1297+
async with HostFactory.create_batch_and_listen(3) as hosts:
1298+
relay_host, source_host, dest_host = hosts
1299+
logger.info(
1300+
"Created hosts for test_circuit_v2_connect_fails_when_source_limit_exceeded"
1301+
)
1302+
1303+
limits = RelayLimits(
1304+
duration=DEFAULT_RELAY_LIMITS.duration,
1305+
data=DEFAULT_RELAY_LIMITS.data,
1306+
max_circuit_conns=1,
1307+
max_reservations=DEFAULT_RELAY_LIMITS.max_reservations,
1308+
)
1309+
relay_protocol = CircuitV2Protocol(relay_host, limits, allow_hop=True)
1310+
1311+
async with background_trio_service(relay_protocol):
1312+
await relay_protocol.event_started.wait()
1313+
1314+
async def send_reserve(host) -> None:
1315+
envelope_bytes, _ = env_to_send_in_RPC(host)
1316+
stream = await host.new_stream(relay_host.get_id(), [PROTOCOL_ID])
1317+
try:
1318+
reserve_msg = proto.HopMessage(
1319+
type=proto.HopMessage.RESERVE,
1320+
peer=host.get_id().to_bytes(),
1321+
senderRecord=envelope_bytes,
1322+
)
1323+
await stream.write(reserve_msg.SerializeToString())
1324+
await stream.read(MAX_READ_LEN)
1325+
finally:
1326+
await close_stream(stream)
1327+
1328+
await connect(dest_host, relay_host)
1329+
await connect(source_host, relay_host)
1330+
await trio.sleep(SLEEP_TIME)
1331+
1332+
await send_reserve(dest_host)
1333+
await send_reserve(source_host)
1334+
await trio.sleep(SLEEP_TIME)
1335+
1336+
source_reservation = relay_protocol.resource_manager.get_reservation(
1337+
source_host.get_id()
1338+
)
1339+
assert source_reservation is not None
1340+
source_reservation.active_connections = 1
1341+
1342+
stream = None
1343+
try:
1344+
with trio.fail_after(STREAM_TIMEOUT):
1345+
stream = await source_host.new_stream(
1346+
relay_host.get_id(), [PROTOCOL_ID]
1347+
)
1348+
1349+
connect_msg = proto.HopMessage(
1350+
type=proto.HopMessage.CONNECT,
1351+
peer=dest_host.get_id().to_bytes(),
1352+
)
1353+
await stream.write(connect_msg.SerializeToString())
1354+
logger.info(
1355+
"Sent CONNECT request with source connection limit exceeded"
1356+
)
1357+
1358+
response_bytes = await stream.read(MAX_READ_LEN)
1359+
assert response_bytes, "No response received"
1360+
1361+
response = proto.HopMessage()
1362+
response.ParseFromString(response_bytes)
1363+
1364+
assert response.type == proto.HopMessage.STATUS
1365+
assert (
1366+
response.status.code == proto.Status.RESOURCE_LIMIT_EXCEEDED
1367+
), (
1368+
"Expected status RESOURCE_LIMIT_EXCEEDED(101), "
1369+
f"got {response.status.code}"
1370+
)
1371+
1372+
try:
1373+
next_data = await stream.read(MAX_READ_LEN)
1374+
assert not next_data, (
1375+
"Stream should be closed/reset after error status"
1376+
)
1377+
except (StreamEOF, StreamReset, StreamError):
1378+
pass
1379+
1380+
finally:
1381+
if stream:
1382+
await close_stream(stream)

tests/core/relay/test_circuit_v2_transport.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from libp2p.peer.peerinfo import (
2222
PeerInfo,
2323
)
24+
from libp2p.peer.peerstore import env_to_send_in_RPC
2425
from libp2p.relay.circuit_v2.config import RelayConfig, RelayRole
2526
from libp2p.relay.circuit_v2.discovery import (
2627
RelayDiscovery,
@@ -373,7 +374,24 @@ async def app_echo_handler(stream):
373374

374375
await trio.sleep(SLEEP_TIME)
375376

376-
# Step 2: Source connects to Relay
377+
# Step 2: Destination makes a reservation on the relay.
378+
with trio.fail_after(CONNECT_TIMEOUT):
379+
dest_relay_stream = await target_host.new_stream(
380+
relay_host.get_id(), [PROTOCOL_ID]
381+
)
382+
envelope_bytes, _ = env_to_send_in_RPC(target_host)
383+
reserve_msg = HopMessage(
384+
type=HopMessage.RESERVE,
385+
peer=target_host.get_id().to_bytes(),
386+
senderRecord=envelope_bytes,
387+
)
388+
await dest_relay_stream.write(reserve_msg.SerializeToString())
389+
# Read and discard the STATUS response from the relay
390+
await dest_relay_stream.read(1024)
391+
392+
await trio.sleep(SLEEP_TIME)
393+
394+
# Step 3: Source connects to Relay
377395
with trio.fail_after(CONNECT_TIMEOUT):
378396
await connect(client_host, relay_host)
379397
assert relay_host.get_id() in client_host.get_network().connections
@@ -383,7 +401,7 @@ async def app_echo_handler(stream):
383401
relay_id = relay_host.get_id()
384402
client_discovery.get_relay = lambda: relay_id
385403

386-
# Step 3: Source tries to dial the destination via p2p-circuit and opens stream
404+
# Step 4: Source tries to dial the destination via p2p-circuit and opens stream
387405
relay_addr = relay_host.get_addrs()[0]
388406
dest_id = target_host.get_id()
389407
p2p_circuit_addr = Multiaddr(f"{relay_addr}/p2p-circuit/p2p/{dest_id}")

0 commit comments

Comments
 (0)