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.
This commit is contained in:
agessaman
2026-07-16 14:28:55 -07:00
parent 5d390ef694
commit 1a4df1a151
2 changed files with 139 additions and 7 deletions
+30 -6
View File
@@ -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
)
+109 -1
View File
@@ -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.