mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
fix(text): gate the CLI on the text type, not the text
simple_repeater::onPeerDataRecv and simple_room_server::onPeerDataRecv
both open their TXT_MSG branch by rejecting any flags outside
{PLAIN, CLI_DATA, CLI_COMMAND}. openHop had no such gate: it decided what
a message was purely from whether the text looked like a command, so a
SIGNED_PLAIN -- a room post, whose 4-byte author prefix the core handler
has already stripped, leaving bare text -- ran as an admin CLI command
whenever it happened to start with a command prefix, and was stored as a
post on a room server that firmware would have dropped.
CLI_COMMAND (3) joined the accepted set when firmware split "a CLI
command" out of CLI_DATA (MeshCore 2c0ace25); CLI_DATA stays in it
because that is what every released client still sends.
Reads the type from packet.decrypted, which the core text handler now
publishes. A core too old to report it leaves the gate open rather than
dropping every message.
This commit is contained in:
1 parent
aeb749218f
commit
369c6142b6
2 files changed
+139
-1
No files matched your search
@@ -20,9 +20,25 @@ from .room_server import RoomServer
|
||||
logger = logging.getLogger("TextHelper")
|
||||
|
||||
|
||||
# Text message type flags
|
||||
# Text message type flags (firmware TxtDataHelpers.h)
|
||||
TXT_TYPE_PLAIN = 0x00
|
||||
TXT_TYPE_CLI_DATA = 0x01
|
||||
TXT_TYPE_SIGNED_PLAIN = 0x02
|
||||
TXT_TYPE_CLI_COMMAND = 0x03
|
||||
|
||||
# The text types a server acts on. Both simple_repeater::onPeerDataRecv and
|
||||
# simple_room_server::onPeerDataRecv open their TXT_MSG branch with
|
||||
#
|
||||
# if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA
|
||||
# || flags == TXT_TYPE_CLI_COMMAND)) {
|
||||
# MESH_DEBUG_PRINTLN("unsupported text type received: flags=%02x", flags);
|
||||
# } else if (...)
|
||||
#
|
||||
# so anything else -- SIGNED_PLAIN included -- is logged and dropped: no
|
||||
# command run, no post stored, no reply. CLI_COMMAND joined the set when
|
||||
# firmware split "a CLI command" out of CLI_DATA (MeshCore 2c0ace25); CLI_DATA
|
||||
# stays in it because that is what every released client still sends.
|
||||
SERVER_TXT_TYPES = frozenset((TXT_TYPE_PLAIN, TXT_TYPE_CLI_DATA, TXT_TYPE_CLI_COMMAND))
|
||||
|
||||
|
||||
class TextHelper:
|
||||
@@ -286,6 +302,22 @@ class TextHelper:
|
||||
# Extract decrypted message if available
|
||||
if hasattr(packet, "decrypted") and packet.decrypted:
|
||||
message_text = packet.decrypted.get("text", "<unknown>")
|
||||
|
||||
# Firmware gates the whole TXT_MSG branch on the text type before it
|
||||
# looks at the text at all. Without this, a SIGNED_PLAIN — a room
|
||||
# post, whose 4-byte author prefix the core handler has already
|
||||
# split off, leaving bare text — reaches the repeater's CLI and runs
|
||||
# as a command whenever it happens to start with a command prefix.
|
||||
# A core older than the one that publishes ``txt_type`` reports
|
||||
# None; behave as before rather than dropping every message.
|
||||
txt_type = packet.decrypted.get("txt_type")
|
||||
if txt_type is not None and txt_type not in SERVER_TXT_TYPES:
|
||||
logger.debug(
|
||||
f"[{identity_type}:{identity_name}] unsupported text type "
|
||||
f"{txt_type} from 0x{src_hash:02X}; dropping"
|
||||
)
|
||||
return
|
||||
|
||||
sender_client = self._resolve_sender_client(dest_hash, src_hash, packet)
|
||||
|
||||
# Clean message text - remove null bytes and trailing whitespace
|
||||
|
||||
@@ -458,6 +458,112 @@ def test_text_helper_cli_prefix_and_admin_permission_checks():
|
||||
assert helper._check_admin_permission_for_identity(0x23, 0x41) is False
|
||||
|
||||
|
||||
class _TxtPacket:
|
||||
"""A packet as the core TextMessageHandler leaves it: text + its type."""
|
||||
|
||||
def __init__(self, text: str, txt_type=None):
|
||||
self.decrypted = {"text": text}
|
||||
if txt_type is not None:
|
||||
self.decrypted["txt_type"] = txt_type
|
||||
self.payload = bytearray([0x41, 0x21])
|
||||
self.mark_do_not_retransmit = MagicMock()
|
||||
|
||||
|
||||
def _repeater_cli_helper():
|
||||
"""A TextHelper wired as a repeater identity whose admin CLI is a stub."""
|
||||
helper = TextHelper(identity_manager=MagicMock(), acl_dict={})
|
||||
helper.repeater_hash = 0x41
|
||||
helper.cli = MagicMock()
|
||||
helper.cli.handle_command = MagicMock(return_value="ok")
|
||||
helper.handlers = {0x41: {"name": "rep", "type": "repeater", "identity": MagicMock()}}
|
||||
helper._resolve_sender_client = MagicMock(
|
||||
return_value=_FakeClient(
|
||||
pubkey=bytes([0x21]) + b"x" * 31,
|
||||
shared_secret=b"k" * 32,
|
||||
permissions=PERM_ACL_ADMIN,
|
||||
)
|
||||
)
|
||||
helper._check_admin_permission_for_identity = MagicMock(return_value=True)
|
||||
helper._send_cli_reply = AsyncMock()
|
||||
return helper
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"txt_type,runs",
|
||||
[
|
||||
(0, True), # PLAIN -- legacy CLI, firmware still accepts it
|
||||
(1, True), # CLI_DATA -- what every released client sends
|
||||
(3, True), # CLI_COMMAND -- the type firmware split out (2c0ace25)
|
||||
(2, False), # SIGNED_PLAIN -- a room post, never a command
|
||||
(None, True), # no type reported (older core): behave as before
|
||||
],
|
||||
)
|
||||
async def test_text_helper_runs_cli_only_for_server_text_types(txt_type, runs):
|
||||
"""[fails pre-fix for SIGNED_PLAIN] The text type gates the CLI, not the text.
|
||||
|
||||
simple_repeater::onPeerDataRecv opens its TXT_MSG branch by rejecting any
|
||||
flags outside {PLAIN, CLI_DATA, CLI_COMMAND}. openHop dispatched purely on
|
||||
whether the text looked like a command, so a SIGNED_PLAIN -- a room post,
|
||||
whose 4-byte author prefix the core handler has already stripped, leaving
|
||||
bare text -- ran as an admin CLI command whenever it happened to start with
|
||||
a command prefix.
|
||||
|
||||
``txt_type is None`` is a core too old to report it; that must keep working
|
||||
rather than silently dropping every message.
|
||||
"""
|
||||
helper = _repeater_cli_helper()
|
||||
packet = _TxtPacket("get status", txt_type)
|
||||
|
||||
await helper._on_message_received(
|
||||
identity_name="rep",
|
||||
identity_type="repeater",
|
||||
packet=packet,
|
||||
dest_hash=0x41,
|
||||
src_hash=0x21,
|
||||
)
|
||||
|
||||
assert helper.cli.handle_command.called is runs
|
||||
assert helper._send_cli_reply.await_count == (1 if runs else 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_helper_signed_plain_is_not_stored_as_a_room_post():
|
||||
"""A SIGNED_PLAIN never reaches the room server either.
|
||||
|
||||
simple_room_server::onPeerDataRecv carries the same filter as the repeater;
|
||||
only PLAIN becomes a post there. openHop stored whatever arrived.
|
||||
"""
|
||||
helper = TextHelper(identity_manager=MagicMock(), acl_dict={})
|
||||
room = MagicMock()
|
||||
room.add_post = AsyncMock(return_value=True)
|
||||
room.cli = None
|
||||
helper.room_servers = {0x41: room}
|
||||
helper.handlers = {0x41: {"name": "room", "type": "room_server", "identity": MagicMock()}}
|
||||
helper._resolve_sender_client = MagicMock(
|
||||
return_value=_FakeClient(pubkey=bytes([0x21]) + b"x" * 31, shared_secret=b"k" * 32)
|
||||
)
|
||||
|
||||
await helper._on_message_received(
|
||||
identity_name="room",
|
||||
identity_type="room_server",
|
||||
packet=_TxtPacket("hello room", 2),
|
||||
dest_hash=0x41,
|
||||
src_hash=0x21,
|
||||
)
|
||||
room.add_post.assert_not_awaited()
|
||||
|
||||
# ...while a plain post still lands.
|
||||
await helper._on_message_received(
|
||||
identity_name="room",
|
||||
identity_type="room_server",
|
||||
packet=_TxtPacket("hello room", 0),
|
||||
dest_hash=0x41,
|
||||
src_hash=0x21,
|
||||
)
|
||||
room.add_post.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_helper_process_text_packet_routes_or_forwards():
|
||||
helper = TextHelper(identity_manager=MagicMock(), acl_dict={})
|
||||
|
||||
Reference in new issue
Block a user