diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 39c0023c120..ca3bd518de9 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -804,9 +804,15 @@ class GatewaySlashCommandsMixin: and bool(row_src) and bool(caller_src) and str(row_src) == str(caller_src) ) - # No caller identity (single-user / no-identity context): there is no - # cross-user boundary to enforce beyond the same-platform check above. - return True + # No caller identity: the persisted row carries only source + user_id + # (the sessions table has no chat_id), so a same-platform row can belong + # to a DIFFERENT chat or user. Same-platform alone is therefore NOT + # ownership proof — an identity-less caller must not bind to, or + # enumerate, a persisted session by id/title. Fail closed. A legitimate + # same-chat resume of an ACTIVE session still works through the + # live-origin branch above (which compares chat_id), and an operator can + # use the admin --all override. (CWE-639: IDOR on session routing.) + return False async def _resume_row_visible( self, source: SessionSource, row: dict, allow_all: bool diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index 68b0c40fa2a..2f425ba4ed0 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -500,6 +500,43 @@ class TestHandleSessionsCommand: assert "Resumed" not in result, name db.close() + @pytest.mark.asyncio + async def test_resume_blocks_no_identity_caller_on_persisted_row(self, tmp_path): + """A caller with no user_id must not resume a persisted row on + same-platform alone: the row has no chat_id to prove ownership, so a + Telegram group caller in chat-a (user_id=None) cannot bind to a row + owned by another chat/user (IDOR regression for the no-identity branch).""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("victim_chat_b_uid", "telegram", user_id="victim") + db.set_session_title("victim_chat_b_uid", "Victim Chat B") + db.create_session("current_session_001", "telegram") + + for name in ("Victim Chat B", "victim_chat_b_uid"): + event = _make_event(text=f"/resume {name}", user_id=None, + chat_id="chat-a") + event.source.chat_type = "group" + runner = _make_runner(session_db=db, current_session_id="current_session_001", + event=event) + result = await runner._handle_resume_command(event) + runner.session_store.switch_session.assert_not_called() + assert "Resumed" not in result, name + db.close() + + def test_resume_target_allowed_blocks_no_identity_persisted(self, tmp_path): + """Unit-level: the persisted-row fallback fails closed for an + identity-less caller (no live origin resolvable).""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("victim_chat_b_uid", "telegram", user_id="victim") + runner = _make_runner(session_db=db) + runner._gateway_session_origin_for_id = lambda sid: None # inactive/persisted-only + caller = SessionSource(platform=Platform.TELEGRAM, chat_id="chat-a", + chat_type="group", user_id=None) + assert runner._resume_target_allowed(caller, "victim_chat_b_uid", + allow_override=False) is False + db.close() + @pytest.mark.asyncio async def test_gateway_dispatches_sessions_command(self, tmp_path): from hermes_state import SessionDB