From 33a5090bf6196e287a51a925200b79e0a3ff121a Mon Sep 17 00:00:00 2001 From: claudlos Date: Sun, 28 Jun 2026 15:48:02 -0500 Subject: [PATCH] security(gateway): fail closed on persisted /resume for identity-less callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses egilewski (Codex/CodeRabbit) follow-up on PR #52355: the no-identity branch of _resume_target_allowed() returned True after only checking that the row's source didn't mismatch the caller platform. The sessions table has no chat_id, so same-platform alone is not ownership proof — a Telegram group caller in chat-a with user_id=None could resume (and /sessions could list) a persisted row owned by another chat/user (e.g. victim_chat_b_uid, source=telegram, user_id=victim). Fail closed: an identity-less caller can no longer bind to or enumerate a persisted session by id/title. A legitimate same-chat resume of an ACTIVE session still works via the live-origin branch (which compares chat_id), and an operator can use the admin --all override. The listing path inherits the fix because _resume_row_visible() routes non-Matrix rows through the same helper. Adds an end-to-end no-identity probe (resume blocked) and a unit-level persisted-fallback assertion. Co-Authored-By: Claude Opus 4.8 --- gateway/slash_commands.py | 12 ++++++--- tests/gateway/test_resume_command.py | 37 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) 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