diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index f7837a1f7d69..37d71becbd7e 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -485,11 +485,55 @@ def __init__(self, config: PlatformConfig): } self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {} self._approval_prompt_by_session: Dict[str, str] = {} + self.gateway_runner = None allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "") self._allowed_user_ids: Set[str] = { u.strip() for u in allowed_users_raw.split(",") if u.strip() } + @staticmethod + def _env_flag_enabled(name: str) -> bool: + return os.getenv(name, "").strip().lower() in {"true", "1", "yes"} + + @staticmethod + def _env_allowlist(name: str) -> Set[str]: + raw = os.getenv(name, "").strip() + return {item.strip() for item in raw.split(",") if item.strip()} + + def _is_reaction_approval_authorized(self, sender: str, room_id: str) -> bool: + """Return True when a Matrix reaction sender may resolve approvals.""" + if not sender: + return False + + auth_fn = getattr(getattr(self, "gateway_runner", None), "_is_user_authorized", None) + if callable(auth_fn): + try: + source = self.build_source( + chat_id=room_id, + chat_type="group", + user_id=sender, + ) + return bool(auth_fn(source)) + except Exception as exc: + logger.warning( + "Matrix: approval reaction auth fell back after runner check failed: %s", + exc, + ) + + if self._env_flag_enabled("MATRIX_ALLOW_ALL_USERS"): + return True + + matrix_allowed = set(self._allowed_user_ids) or self._env_allowlist( + "MATRIX_ALLOWED_USERS" + ) + global_allowed = self._env_allowlist("GATEWAY_ALLOWED_USERS") + + if not matrix_allowed and not global_allowed: + return self._env_flag_enabled("GATEWAY_ALLOW_ALL_USERS") + + allowed = matrix_allowed | global_allowed + return "*" in allowed or sender in allowed + def _is_duplicate_event(self, event_id) -> bool: """Return True if this event was already processed. Tracks the ID otherwise.""" if not event_id: @@ -2236,7 +2280,7 @@ async def _on_reaction(self, event: Any) -> None: if prompt and not prompt.resolved: if room_id != prompt.chat_id: return - if self._allowed_user_ids and sender not in self._allowed_user_ids: + if not self._is_reaction_approval_authorized(sender, room_id): logger.info( "Matrix: ignoring approval reaction from unauthorized user %s on %s", sender, reacts_to, diff --git a/gateway/run.py b/gateway/run.py index 3e23cf2352af..f0a71ecd2120 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6338,7 +6338,9 @@ def _create_adapter( if not check_matrix_requirements(): logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'") return None - return MatrixAdapter(config) + adapter = MatrixAdapter(config) + adapter.gateway_runner = self + return adapter elif platform == Platform.API_SERVER: from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements diff --git a/tests/gateway/test_matrix_exec_approval.py b/tests/gateway/test_matrix_exec_approval.py index a7afe912cba5..6e7f481127bc 100644 --- a/tests/gateway/test_matrix_exec_approval.py +++ b/tests/gateway/test_matrix_exec_approval.py @@ -6,6 +6,45 @@ from gateway.config import PlatformConfig +def _clear_matrix_auth_env(monkeypatch): + for name in ( + "MATRIX_ALLOWED_USERS", + "GATEWAY_ALLOWED_USERS", + "MATRIX_ALLOW_ALL_USERS", + "GATEWAY_ALLOW_ALL_USERS", + ): + monkeypatch.delenv(name, raising=False) + + +def _make_adapter_with_prompt(monkeypatch): + from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt + + adapter = MatrixAdapter( + PlatformConfig( + enabled=True, + token="tok", + extra={"homeserver": "https://matrix.example.org"}, + ) + ) + # Resolve user_id so _is_self_sender doesn't defensively drop all traffic (#15763). + adapter._user_id = "@bot:example.org" + adapter._approval_prompts_by_event["$target"] = _MatrixApprovalPrompt( + session_key="sess-1", chat_id="!room:example.org", message_id="$target" + ) + adapter._approval_prompt_by_session["sess-1"] = "$target" + return adapter + + +def _reaction_event(sender): + content = {"m.relates_to": {"event_id": "$target", "key": "✅"}} + return types.SimpleNamespace( + sender=sender, + event_id=f"$react-{sender}", + room_id="!room:example.org", + content=content, + ) + + class TestMatrixExecApprovalReactions: @pytest.mark.asyncio async def test_send_exec_approval_registers_prompt_and_seeds_reactions(self, monkeypatch): @@ -58,3 +97,41 @@ async def test_reaction_resolves_pending_approval(self, monkeypatch): mock_resolve.assert_called_once_with("sess-1", "once") assert "$target" not in adapter._approval_prompts_by_event assert "sess-1" not in adapter._approval_prompt_by_session + + @pytest.mark.asyncio + async def test_reaction_denied_when_matrix_allowlist_empty_and_global_mismatch(self, monkeypatch): + _clear_matrix_auth_env(monkeypatch) + monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "@owner:example.org") + adapter = _make_adapter_with_prompt(monkeypatch) + + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._on_reaction(_reaction_event("@attacker:example.org")) + + mock_resolve.assert_not_called() + assert "$target" in adapter._approval_prompts_by_event + assert adapter._approval_prompt_by_session["sess-1"] == "$target" + + @pytest.mark.asyncio + async def test_reaction_allowed_by_global_allowlist_when_matrix_allowlist_empty(self, monkeypatch): + _clear_matrix_auth_env(monkeypatch) + monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "@owner:example.org") + adapter = _make_adapter_with_prompt(monkeypatch) + + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._on_reaction(_reaction_event("@owner:example.org")) + + mock_resolve.assert_called_once_with("sess-1", "once") + assert "$target" not in adapter._approval_prompts_by_event + assert "sess-1" not in adapter._approval_prompt_by_session + + @pytest.mark.asyncio + async def test_reaction_denied_when_no_allowlist_is_configured(self, monkeypatch): + _clear_matrix_auth_env(monkeypatch) + adapter = _make_adapter_with_prompt(monkeypatch) + + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._on_reaction(_reaction_event("@attacker:example.org")) + + mock_resolve.assert_not_called() + assert "$target" in adapter._approval_prompts_by_event + assert adapter._approval_prompt_by_session["sess-1"] == "$target"