Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion gateway/platforms/matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions tests/gateway/test_matrix_exec_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Loading