From 1a4df1a151e0b6d1f7be7faf196fcc0c56f1de9b Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 16 Jul 2026 14:28:55 -0700 Subject: [PATCH] fix(room_server): enforce firmware post text budget in bytes The room post limit was 160 Python characters; firmware caps post text at MAX_POST_TEXT_LEN = 160-9 = 151 bytes (the 160-byte encrypted text budget minus the timestamp/flags/author-prefix header). Store posts truncated to 151 UTF-8 bytes at a codepoint boundary, and clamp again when pushing so previously stored oversized posts cannot produce oversized frames. --- repeater/handler_helpers/room_server.py | 36 +++++-- tests/test_handler_helpers_room_server.py | 110 +++++++++++++++++++++- 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/repeater/handler_helpers/room_server.py b/repeater/handler_helpers/room_server.py index 8139a33..3680dbc 100644 --- a/repeater/handler_helpers/room_server.py +++ b/repeater/handler_helpers/room_server.py @@ -27,7 +27,10 @@ PUSH_TIMEOUT_BASE_MS = 4000 PUSH_ACK_TIMEOUT_FACTOR_MS = 2000 # Safety limits and protections -MAX_MESSAGE_LENGTH = 160 # Match C++ MAX_POST_TEXT_LEN (151 bytes for text) +# Match C++ MAX_POST_TEXT_LEN from examples/simple_room_server/MyMesh.h: +# #define MAX_POST_TEXT_LEN (160-9) -- 160-byte encrypted text budget minus the +# 9-byte prefix (4-byte timestamp + 1-byte flags/attempt + 4-byte author pubkey prefix). +MAX_POST_TEXT_LEN = 151 MAX_POSTS_PER_CLIENT_PER_MINUTE = 10 # Prevent spam MAX_CLIENTS_PER_ROOM = 50 # From ACL default MAX_PUSH_FAILURES = 3 # Evict after this many consecutive failures @@ -47,6 +50,19 @@ _global_push_lock = asyncio.Lock() GLOBAL_MIN_GAP_BETWEEN_MESSAGES = 1.1 # 1.1s minimum gap between transmissions +def _truncate_utf8(text: str, max_bytes: int) -> str: + """Truncate ``text`` to at most ``max_bytes`` of its UTF-8 encoding. + + Cuts at a codepoint boundary so a partial multi-byte sequence is never + emitted (matches firmware's byte-length text budget, but stays + UTF-8-safe rather than the firmware's raw ``strncpy``). + """ + encoded = text.encode("utf-8") + if len(encoded) <= max_bytes: + return text + return encoded[:max_bytes].decode("utf-8", errors="ignore") + + class GlobalRateLimiter: def __init__(self, min_gap_seconds: float = 0.1): self.min_gap = min_gap_seconds # Minimum gap between consecutive messages @@ -234,13 +250,15 @@ class RoomServer: ) -> bool: try: - # SAFETY: Validate message length - if len(message_text) > MAX_MESSAGE_LENGTH: + # SAFETY: Validate message length (byte length, matching firmware's + # MAX_POST_TEXT_LEN text budget), cutting at a UTF-8 codepoint boundary. + encoded_len = len(message_text.encode("utf-8")) + if encoded_len > MAX_POST_TEXT_LEN: logger.warning( f"Room '{self.room_name}': Message from {client_pubkey[:4].hex()} " - f"exceeds max length ({len(message_text)} > {MAX_MESSAGE_LENGTH}), truncating" + f"exceeds max length ({encoded_len} > {MAX_POST_TEXT_LEN} bytes), truncating" ) - message_text = message_text[:MAX_MESSAGE_LENGTH] + message_text = _truncate_utf8(message_text, MAX_POST_TEXT_LEN) # SAFETY: Rate limit per client client_key = client_pubkey.hex() @@ -362,7 +380,13 @@ class RoomServer: author_prefix = author_pubkey[:4] # Plaintext: timestamp(4) + flags(1) + author_prefix(4) + text - message_bytes = post["message_text"].encode("utf-8") + # SAFETY: Clamp at push time too, in case a legacy stored post + # (from before this limit was enforced on write) exceeds + # MAX_POST_TEXT_LEN bytes -- truncate at a UTF-8 codepoint boundary. + message_text = post["message_text"] + if len(message_text.encode("utf-8")) > MAX_POST_TEXT_LEN: + message_text = _truncate_utf8(message_text, MAX_POST_TEXT_LEN) + message_bytes = message_text.encode("utf-8") plaintext = ( timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes ) diff --git a/tests/test_handler_helpers_room_server.py b/tests/test_handler_helpers_room_server.py index 683daca..e1ee422 100644 --- a/tests/test_handler_helpers_room_server.py +++ b/tests/test_handler_helpers_room_server.py @@ -5,10 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from repeater.handler_helpers.room_server import ( + MAX_POST_TEXT_LEN, MAX_UNSYNCED_POSTS, TXT_TYPE_PLAIN, TXT_TYPE_SIGNED_PLAIN, RoomServer, + _truncate_utf8, ) @@ -91,7 +93,7 @@ async def test_room_server_add_post_truncates_and_rate_limits_client(): first_ok = await rs.add_post(client_key, long_msg, sender_timestamp=5) assert first_ok is True args = db.insert_room_message.call_args.kwargs - assert len(args["message_text"]) == 160 + assert len(args["message_text"].encode("utf-8")) == MAX_POST_TEXT_LEN # Force client to appear at post-per-minute limit. rs.client_post_times[client_key.hex()] = [time.time() - 1] * 10 @@ -99,6 +101,71 @@ async def test_room_server_add_post_truncates_and_rate_limits_client(): assert second_ok is False +@pytest.mark.asyncio +async def test_room_server_add_post_ascii_over_limit_stores_exactly_max_bytes(): + db = _FakeDB() + rs = _make_room_server(db=db) + + long_msg = "a" * 200 # ASCII: 1 byte per char, well over MAX_POST_TEXT_LEN + ok = await rs.add_post(b"G" * 32, long_msg, sender_timestamp=1) + assert ok is True + + stored = db.insert_room_message.call_args.kwargs["message_text"] + assert len(stored.encode("utf-8")) == MAX_POST_TEXT_LEN + assert stored == "a" * MAX_POST_TEXT_LEN + + +@pytest.mark.asyncio +async def test_room_server_add_post_multibyte_utf8_truncates_on_codepoint_boundary(): + db = _FakeDB() + rs = _make_room_server(db=db) + + # Each emoji is 4 bytes in UTF-8; padding forces the cut to land mid-emoji + # if truncation were byte-naive instead of codepoint-aware. + padding = "a" * (MAX_POST_TEXT_LEN - 2) + msg = padding + "\U0001f600\U0001f600\U0001f600" # grinning face emoji x3 + assert len(msg.encode("utf-8")) > MAX_POST_TEXT_LEN + + ok = await rs.add_post(b"H" * 32, msg, sender_timestamp=2) + assert ok is True + + stored = db.insert_room_message.call_args.kwargs["message_text"] + encoded = stored.encode("utf-8") + assert len(encoded) <= MAX_POST_TEXT_LEN + # Must decode cleanly (no partial multi-byte sequence) and round-trip. + assert encoded.decode("utf-8") == stored + + +@pytest.mark.asyncio +async def test_room_server_add_post_exact_limit_text_untouched(): + db = _FakeDB() + rs = _make_room_server(db=db) + + msg = "y" * MAX_POST_TEXT_LEN + ok = await rs.add_post(b"I" * 32, msg, sender_timestamp=3) + assert ok is True + + stored = db.insert_room_message.call_args.kwargs["message_text"] + assert stored == msg + assert len(stored.encode("utf-8")) == MAX_POST_TEXT_LEN + + +def test_truncate_utf8_helper_boundary_cases(): + # Under the limit: untouched. + assert _truncate_utf8("short", 151) == "short" + + # Exactly at the limit: untouched. + exact = "z" * 151 + assert _truncate_utf8(exact, 151) == exact + + # Multi-byte straddling the boundary: cuts cleanly, decodes, stays <= limit. + text = ("b" * 149) + "\U0001f600\U0001f600" # 149 + 4 + 4 = 157 bytes + result = _truncate_utf8(text, 151) + encoded = result.encode("utf-8") + assert len(encoded) <= 151 + assert encoded.decode("utf-8") == result + + @pytest.mark.asyncio async def test_room_server_add_post_returns_false_on_db_insert_failure(): db = _FakeDB() @@ -192,6 +259,47 @@ async def test_room_server_push_post_to_client_success_direct_route_sets_path_an rs.global_limiter.release.assert_called_once() +@pytest.mark.asyncio +async def test_room_server_push_post_to_client_clamps_oversized_legacy_stored_text(): + """A post stored before the write-time limit was enforced (or otherwise + over MAX_POST_TEXT_LEN bytes) must still be clamped at push time so the + outgoing frame's text portion never exceeds the firmware's budget.""" + db = _FakeDB() + db.get_client_sync.return_value = {"push_failures": 0} + injector = AsyncMock(return_value=True) + rs = _make_room_server(db=db, injector=injector) + rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock()) + rs._handle_ack_received = AsyncMock() + + client = _FakeClient(pubkey=b"E" * 32, out_path=b"\xaa\xbb", out_path_len=2) + oversized_text = "q" * (MAX_POST_TEXT_LEN + 50) + post = { + "author_pubkey": (b"F" * 32).hex(), + "message_text": oversized_text, + "post_timestamp": 1234.5, + } + + packet = SimpleNamespace(path=bytearray(), path_len=0) + with ( + patch( + "repeater.handler_helpers.room_server.CryptoUtils.sha256", + return_value=b"\x01\x02\x03\x04abcd", + ), + patch( + "repeater.handler_helpers.room_server.PacketBuilder.create_datagram", + return_value=packet, + ) as create_datagram, + ): + ok = await rs.push_post_to_client(client, post) + + assert ok is True + plaintext = create_datagram.call_args.kwargs["plaintext"] + # Prefix is timestamp(4) + flags(1) + author_prefix(4) = 9 bytes. + text_portion = plaintext[9:] + assert len(text_portion) == MAX_POST_TEXT_LEN + assert text_portion.decode("utf-8") == "q" * MAX_POST_TEXT_LEN + + @pytest.mark.asyncio async def test_room_server_push_expected_ack_matches_firmware_signed_ack(): """The pending ACK CRC must match what a signed-plain receiver sends back.