From 49b0702334aab2e35e5861280c746d0660f9968c Mon Sep 17 00:00:00 2001 From: kc1awv Date: Thu, 15 Jan 2026 13:05:14 -0500 Subject: [PATCH 1/4] add max_msg_body_bytes limit and validation for message body size --- rrcd/cli.py | 15 +++++++++++++++ rrcd/config.py | 1 + rrcd/router.py | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/rrcd/cli.py b/rrcd/cli.py index a6c5945..6d39a47 100644 --- a/rrcd/cli.py +++ b/rrcd/cli.py @@ -151,8 +151,15 @@ include_joined_member_list = false nick_max_chars = 32 # Limits. +# These limits help mitigate abuse and resource exhaustion, but can be adjusted +# based on your use case. +# +# N.B. max_msg_body_bytes should not allow messages so large that they cannot +# fit within the link MTU after UTF-8 encoding and envelope overhead. The +# default of 350 bytes is a safe choice for the default Reticulum MTU of 500. max_rooms_per_session = 32 max_room_name_len = 64 +max_msg_body_bytes = 350 rate_limit_msgs_per_minute = 240 # Hub-initiated liveness checks (0 disables). @@ -332,6 +339,12 @@ def _build_arg_parser() -> argparse.ArgumentParser: default=None, help="Per-link message rate limit", ) + p.add_argument( + "--max-msg-body-bytes", + type=int, + default=None, + help="Maximum message body size in UTF-8 bytes", + ) p.add_argument( "--ping-interval", @@ -420,6 +433,8 @@ def main(argv: list[str] | None = None) -> None: cfg = replace( cfg, rate_limit_msgs_per_minute=int(args.rate_limit_msgs_per_minute) ) + if args.max_msg_body_bytes is not None: + cfg = replace(cfg, max_msg_body_bytes=int(args.max_msg_body_bytes)) if args.ping_interval is not None: cfg = replace(cfg, ping_interval_s=float(args.ping_interval)) diff --git a/rrcd/config.py b/rrcd/config.py index 2c62a11..8c9429c 100644 --- a/rrcd/config.py +++ b/rrcd/config.py @@ -28,6 +28,7 @@ class HubRuntimeConfig: nick_max_chars: int = 32 max_rooms_per_session: int = 32 max_room_name_len: int = 64 + max_msg_body_bytes: int = 350 rate_limit_msgs_per_minute: int = 240 ping_interval_s: float = 0.0 ping_timeout_s: float = 0.0 diff --git a/rrcd/router.py b/rrcd/router.py index 9e7429a..8b6d135 100644 --- a/rrcd/router.py +++ b/rrcd/router.py @@ -686,6 +686,26 @@ class MessageRouter: text="message requires room name", ) return + + # Validate message body size (UTF-8 bytes) + if isinstance(body, str): + body_bytes = len(body.encode('utf-8', errors='replace')) + if body_bytes > self.hub.config.max_msg_body_bytes: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text=f"message too large: {body_bytes} bytes > {self.hub.config.max_msg_body_bytes} bytes", + ) + self.log.info( + "Rejected oversized message peer=%s nick=%r body_bytes=%s limit=%s", + self.hub._fmt_hash(peer_hash), + sess.get("nick"), + body_bytes, + self.hub.config.max_msg_body_bytes, + ) + return elif t == T_NOTICE: if not isinstance(room, str) or not room: return From 02edfd7e3885c88b62a9a81cebd602b8359753c9 Mon Sep 17 00:00:00 2001 From: kc1awv Date: Thu, 15 Jan 2026 15:43:35 -0500 Subject: [PATCH 2/4] update nickname and room name limits to use byte size instead of character count --- rrcd/cli.py | 26 +++++++++++++++++--------- rrcd/config.py | 4 ++-- rrcd/router.py | 16 ++++++++-------- rrcd/service.py | 16 ++++++++++------ rrcd/session.py | 14 +++++++++++--- rrcd/stats.py | 4 ++-- rrcd/util.py | 21 +++++++++++---------- 7 files changed, 61 insertions(+), 40 deletions(-) diff --git a/rrcd/cli.py b/rrcd/cli.py index 6d39a47..f9bdd01 100644 --- a/rrcd/cli.py +++ b/rrcd/cli.py @@ -146,10 +146,6 @@ room_invite_timeout_s = 900.0 # Optional behaviors. include_joined_member_list = false -# Nickname policy. -# Maximum accepted nickname length (Unicode characters). 0 disables length limiting. -nick_max_chars = 32 - # Limits. # These limits help mitigate abuse and resource exhaustion, but can be adjusted # based on your use case. @@ -157,9 +153,10 @@ nick_max_chars = 32 # N.B. max_msg_body_bytes should not allow messages so large that they cannot # fit within the link MTU after UTF-8 encoding and envelope overhead. The # default of 350 bytes is a safe choice for the default Reticulum MTU of 500. -max_rooms_per_session = 32 -max_room_name_len = 64 +max_nick_bytes = 32 +max_room_name_bytes = 64 max_msg_body_bytes = 350 +max_rooms_per_session = 32 rate_limit_msgs_per_minute = 240 # Hub-initiated liveness checks (0 disables). @@ -330,7 +327,16 @@ def _build_arg_parser() -> argparse.ArgumentParser: p.add_argument("--max-rooms", type=int, default=None, help="Max rooms per session") p.add_argument( - "--max-room-name-len", type=int, default=None, help="Max room name length" + "--max-nick-bytes", + type=int, + default=None, + help="Max nickname size in UTF-8 bytes", + ) + p.add_argument( + "--max-room-name-bytes", + type=int, + default=None, + help="Max room name size in UTF-8 bytes", ) p.add_argument( @@ -426,8 +432,10 @@ def main(argv: list[str] | None = None) -> None: if args.max_rooms is not None: cfg = replace(cfg, max_rooms_per_session=int(args.max_rooms)) - if args.max_room_name_len is not None: - cfg = replace(cfg, max_room_name_len=int(args.max_room_name_len)) + if args.max_nick_bytes is not None: + cfg = replace(cfg, max_nick_bytes=int(args.max_nick_bytes)) + if args.max_room_name_bytes is not None: + cfg = replace(cfg, max_room_name_bytes=int(args.max_room_name_bytes)) if args.rate_limit_msgs_per_minute is not None: cfg = replace( diff --git a/rrcd/config.py b/rrcd/config.py index 8c9429c..00ba6ec 100644 --- a/rrcd/config.py +++ b/rrcd/config.py @@ -25,9 +25,9 @@ class HubRuntimeConfig: room_registry_prune_interval_s: float = 3600.0 room_invite_timeout_s: float = 900.0 include_joined_member_list: bool = False - nick_max_chars: int = 32 + max_nick_bytes: int = 32 max_rooms_per_session: int = 32 - max_room_name_len: int = 64 + max_room_name_bytes: int = 64 max_msg_body_bytes: int = 350 rate_limit_msgs_per_minute: int = 240 ping_interval_s: float = 0.0 diff --git a/rrcd/router.py b/rrcd/router.py index 8b6d135..3704bdd 100644 --- a/rrcd/router.py +++ b/rrcd/router.py @@ -300,7 +300,7 @@ class MessageRouter: new_nick = None if isinstance(nick, str): - n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) + n = normalize_nick(nick, max_bytes=self.hub.config.max_nick_bytes) if n is not None: new_nick = n sess["nick"] = n @@ -311,7 +311,7 @@ class MessageRouter: if new_nick is None: legacy_nick = body.get(B_HELLO_NICK_LEGACY) n2 = normalize_nick( - legacy_nick, max_chars=self.hub.config.nick_max_chars + legacy_nick, max_bytes=self.hub.config.max_nick_bytes ) if n2 is not None: new_nick = n2 @@ -360,7 +360,7 @@ class MessageRouter: new_nick = None if isinstance(nick, str): - n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) + n = normalize_nick(nick, max_bytes=self.hub.config.max_nick_bytes) if n is not None: new_nick = n sess["nick"] = n @@ -370,7 +370,7 @@ class MessageRouter: if new_nick is None: legacy_nick = body.get(B_HELLO_NICK_LEGACY) n2 = normalize_nick( - legacy_nick, max_chars=self.hub.config.nick_max_chars + legacy_nick, max_bytes=self.hub.config.max_nick_bytes ) if n2 is not None: new_nick = n2 @@ -686,10 +686,10 @@ class MessageRouter: text="message requires room name", ) return - + # Validate message body size (UTF-8 bytes) if isinstance(body, str): - body_bytes = len(body.encode('utf-8', errors='replace')) + body_bytes = len(body.encode("utf-8", errors="replace")) if body_bytes > self.hub.config.max_msg_body_bytes: if self.hub.identity is not None: self.hub.message_helper.emit_error( @@ -781,7 +781,7 @@ class MessageRouter: incoming_nick = env.get(K_NICK) if incoming_nick is not None: - n = normalize_nick(incoming_nick, max_chars=self.hub.config.nick_max_chars) + n = normalize_nick(incoming_nick, max_bytes=self.hub.config.max_nick_bytes) if n is not None: old_session_nick = sess.get("nick") if old_session_nick != n: @@ -794,7 +794,7 @@ class MessageRouter: env.pop(K_NICK, None) else: nick = sess.get("nick") - n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) + n = normalize_nick(nick, max_bytes=self.hub.config.max_nick_bytes) if n is not None: env[K_NICK] = n diff --git a/rrcd/service.py b/rrcd/service.py index 316e072..7f43436 100644 --- a/rrcd/service.py +++ b/rrcd/service.py @@ -97,10 +97,10 @@ class HubService: self.destination.hash.hex() if self.destination else "-", ) self.log.info( - "Policy nick_max_chars=%s max_rooms=%s max_room_name_len=%s rate_limit_msgs_per_minute=%s", - self.config.nick_max_chars, + "Policy max_nick_bytes=%s max_rooms=%s max_room_name_bytes=%s rate_limit_msgs_per_minute=%s", + self.config.max_nick_bytes, self.config.max_rooms_per_session, - self.config.max_room_name_len, + self.config.max_room_name_bytes, self.config.rate_limit_msgs_per_minute, ) @@ -330,7 +330,7 @@ class HubService: f"banned={len(old_banned)}->{len(new_banned)} " f"registered_rooms={len(old_registry)}->{len(new_registry)}" ) - lines.append(f"policy: nick_max_chars={new_cfg.nick_max_chars}") + lines.append(f"policy: max_nick_bytes={new_cfg.max_nick_bytes}") if cfg_changes: lines.append("config_changes:") @@ -503,8 +503,12 @@ class HubService: r = room.strip().lower() if not r: raise ValueError("room name must not be empty") - if len(r) > int(self.config.max_room_name_len): - raise ValueError("room name too long") + # Check UTF-8 byte length + room_bytes = len(r.encode("utf-8", errors="replace")) + if room_bytes > int(self.config.max_room_name_bytes): + raise ValueError( + f"room name too long: {room_bytes} bytes > {self.config.max_room_name_bytes} bytes" + ) return r def _on_packet(self, link: RNS.Link, data: bytes) -> None: diff --git a/rrcd/session.py b/rrcd/session.py index 4939062..3a9c2c7 100644 --- a/rrcd/session.py +++ b/rrcd/session.py @@ -135,16 +135,24 @@ class SessionManager: self.hub.room_manager.remove_member(room, link) if remaining_members and peer_hash and self.hub.identity: - notification_body = [peer_hash] if self.hub.config.include_joined_member_list else None + notification_body = ( + [peer_hash] if self.hub.config.include_joined_member_list else None + ) member_notification = make_envelope( - T_PARTED, src=self.hub.identity.hash, room=room, body=notification_body + T_PARTED, + src=self.hub.identity.hash, + room=room, + body=notification_body, ) member_notification_payload = encode(member_notification) for member_link in remaining_members: try: import RNS + RNS.Packet(member_link, member_notification_payload).send() - self.hub.stats_manager.inc("bytes_out", len(member_notification_payload)) + self.hub.stats_manager.inc( + "bytes_out", len(member_notification_payload) + ) except Exception: pass diff --git a/rrcd/stats.py b/rrcd/stats.py index 56fcb0e..bcc506e 100644 --- a/rrcd/stats.py +++ b/rrcd/stats.py @@ -109,8 +109,8 @@ class StatsManager: lines.append( f"limits: rate_limit_msgs_per_minute={self.hub.config.rate_limit_msgs_per_minute} " f"max_rooms_per_session={self.hub.config.max_rooms_per_session} " - f"max_room_name_len={self.hub.config.max_room_name_len} " - f"nick_max_chars={self.hub.config.nick_max_chars}" + f"max_room_name_bytes={self.hub.config.max_room_name_bytes} " + f"max_nick_bytes={self.hub.config.max_nick_bytes}" ) lines.append( f"features: ping_interval_s={self.hub.config.ping_interval_s} " diff --git a/rrcd/util.py b/rrcd/util.py index 6afbe29..e2702f2 100644 --- a/rrcd/util.py +++ b/rrcd/util.py @@ -2,14 +2,14 @@ from __future__ import annotations import os -_DEFAULT_NICK_MAX_CHARS = 32 +_DEFAULT_NICK_MAX_BYTES = 32 def expand_path(p: str) -> str: return os.path.expanduser(os.path.expandvars(p)) -def normalize_nick(value, *, max_chars: int = _DEFAULT_NICK_MAX_CHARS) -> str | None: +def normalize_nick(value, *, max_bytes: int = _DEFAULT_NICK_MAX_BYTES) -> str | None: if not isinstance(value, str): return None @@ -18,19 +18,20 @@ def normalize_nick(value, *, max_chars: int = _DEFAULT_NICK_MAX_CHARS) -> str | return None try: - limit = int(max_chars) + limit = int(max_bytes) except Exception: - limit = int(_DEFAULT_NICK_MAX_CHARS) + limit = int(_DEFAULT_NICK_MAX_BYTES) - if limit > 0 and len(s) > limit: + # Check UTF-8 byte length + try: + encoded = s.encode("utf-8", "strict") + except UnicodeError: + return None + + if limit > 0 and len(encoded) > limit: return None if "\n" in s or "\r" in s or "\x00" in s: return None - try: - s.encode("utf-8", "strict") - except UnicodeError: - return None - return s From 47ae1930a7e70221816831aa5f87f5127efe156d Mon Sep 17 00:00:00 2001 From: kc1awv Date: Thu, 15 Jan 2026 15:59:31 -0500 Subject: [PATCH 3/4] add hub limits to constants and include in WELCOME message construction --- rrcd/constants.py | 8 ++++++++ rrcd/messages.py | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/rrcd/constants.py b/rrcd/constants.py index 789cc95..89ee811 100644 --- a/rrcd/constants.py +++ b/rrcd/constants.py @@ -45,6 +45,14 @@ B_HELLO_NICK_LEGACY = 64 B_WELCOME_HUB = 0 B_WELCOME_VER = 1 B_WELCOME_CAPS = 2 +B_WELCOME_LIMITS = 3 + +# Hub Limits map keys (within B_WELCOME_LIMITS) +B_LIMIT_MAX_NICK_BYTES = 0 +B_LIMIT_MAX_ROOM_NAME_BYTES = 1 +B_LIMIT_MAX_MSG_BODY_BYTES = 2 +B_LIMIT_MAX_ROOMS_PER_SESSION = 3 +B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE = 4 # Capabilities map keys (values are advisory). Keep these small and numeric. CAP_RESOURCE_ENVELOPE = 0 diff --git a/rrcd/messages.py b/rrcd/messages.py index bef5bbf..9c68ffc 100644 --- a/rrcd/messages.py +++ b/rrcd/messages.py @@ -7,7 +7,19 @@ from typing import TYPE_CHECKING, Any import RNS from .codec import encode -from .constants import B_WELCOME_HUB, B_WELCOME_VER, T_ERROR, T_NOTICE, T_WELCOME +from .constants import ( + B_LIMIT_MAX_MSG_BODY_BYTES, + B_LIMIT_MAX_NICK_BYTES, + B_LIMIT_MAX_ROOM_NAME_BYTES, + B_LIMIT_MAX_ROOMS_PER_SESSION, + B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE, + B_WELCOME_HUB, + B_WELCOME_LIMITS, + B_WELCOME_VER, + T_ERROR, + T_NOTICE, + T_WELCOME, +) from .envelope import make_envelope if TYPE_CHECKING: @@ -118,9 +130,19 @@ class MessageHelper: from . import __version__ + # Build hub limits map with integer keys per spec + limits: dict[int, int] = { + B_LIMIT_MAX_NICK_BYTES: self.hub.config.max_nick_bytes, + B_LIMIT_MAX_ROOM_NAME_BYTES: self.hub.config.max_room_name_bytes, + B_LIMIT_MAX_MSG_BODY_BYTES: self.hub.config.max_msg_body_bytes, + B_LIMIT_MAX_ROOMS_PER_SESSION: self.hub.config.max_rooms_per_session, + B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE: self.hub.config.rate_limit_msgs_per_minute, + } + body_w: dict[int, Any] = { B_WELCOME_HUB: self.hub.config.hub_name, B_WELCOME_VER: str(__version__), + B_WELCOME_LIMITS: limits, } welcome = make_envelope(T_WELCOME, src=self.hub.identity.hash, body=body_w) From 8c63b4737bad6825c9e7a7c86c0d79e5fafb5d1f Mon Sep 17 00:00:00 2001 From: kc1awv Date: Thu, 15 Jan 2026 16:23:20 -0500 Subject: [PATCH 4/4] bump version to 0.2.2 and update dependencies in pyproject.toml --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 6 +++--- rrcd/__init__.py | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e71d29..99f5603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ This project follows the versioning policy in VERSIONING.md. +## 0.2.2 - 2026-01-09 + +- **Protocol constants and welcome message limits**: Added new constants for hub + limits in welcome messages and updated message construction accordingly +- Added `max_nick_bytes` configuration option to specify maximum nickname size in UTF-8 bytes +- Updated CLI to allow overriding `max_nick_bytes` via command-line argument +- Updated documentation to reflect new nickname size limit configuration +- + ## 0.2.1 - 2026-01-08 - **JOINED/PARTED room notifications**: Existing room members now receive real-time notifications when users join or leave diff --git a/pyproject.toml b/pyproject.toml index 501728d..ee58fa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "cbor2>=5.6.0", - "rns>=0.8.0", + "rns>=0.9.6", "tomlkit>=0.13.2", ] @@ -34,8 +34,8 @@ Issues = "https://github.com/kc1awv/rrcd/issues" [project.optional-dependencies] dev = [ - "pytest>=8.0.0", - "black>=24.0.0", + "pytest>=9.0.2", + "black>=25.12.0", "ruff>=0.6.0", "mypy>=1.0.0", ] diff --git a/rrcd/__init__.py b/rrcd/__init__.py index b6caf65..ee7eba4 100644 --- a/rrcd/__init__.py +++ b/rrcd/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.2.1" +__version__ = "0.2.2"