security: harden Slack approval handling

This commit is contained in:
Rohit P
2026-07-24 22:33:13 -07:00
parent 56b3c62864
commit 7656952692
21 changed files with 931 additions and 33 deletions
+2
View File
@@ -243,7 +243,9 @@ class SlackAdapter(BasePlatformAdapter):
chat_id=str(channel),
message_id=ts,
value=str(value),
user_id=user.get("id"),
user_name=user.get("username") or user.get("name"),
response_url=body.get("response_url"),
)
)
+8 -2
View File
@@ -119,14 +119,20 @@ MessageHandler = Callable[[MessageEvent], Awaitable[None]]
@dataclass
class InteractionEvent:
"""A button click on an interactive prompt. `value` is the opaque button value (see
`interactions.decode`); `user_name` is who clicked, for the message update."""
"""A button click on an interactive prompt.
Stable actor/workspace ids are security inputs; display names are presentation only.
`response_url` is Slack's short-lived reply capability for a private rejection notice.
"""
platform: str
chat_id: str
message_id: Optional[str] # the clicked message's id/ts (to update it)
value: str
user_id: Optional[str] = None
user_name: Optional[str] = None
team_id: Optional[str] = None
response_url: Optional[str] = None
InteractionHandler = Callable[[InteractionEvent], Awaitable[None]]
+47 -1
View File
@@ -9,15 +9,19 @@ super-agent runner, wired in the next increment). Outbound replies go through th
from __future__ import annotations
import logging
from asyncio import to_thread
from collections import OrderedDict
from typing import Callable, Optional
from urllib.parse import urlparse
from ..secrets import SecretStore
from .base import (
BasePlatformAdapter,
InteractionEvent,
MessageEvent,
MessageHandler,
SendResult,
SessionSource,
parse_target,
)
from .config import ConnectorSettings, is_authorized, load_settings
@@ -69,10 +73,52 @@ class Gateway:
adapter.set_interaction_handler(self._on_interaction)
self._adapters[adapter.platform] = adapter
async def _on_interaction(self, event) -> None:
async def _on_interaction(self, event: InteractionEvent) -> None:
source = SessionSource(
platform=event.platform,
chat_id=event.chat_id,
user_id=event.user_id,
user_name=event.user_name,
chat_type="channel",
team_id=event.team_id,
)
settings = self.settings.get(event.platform)
if settings is None or not is_authorized(settings, source):
logger.info("rejecting unauthorized interaction from %s", source.label())
await self.reject_interaction(event)
return
if self._interaction_handler is not None:
await self._interaction_handler(event)
async def reject_interaction(
self,
event: InteractionEvent,
text: str = "Only a designated approval owner can respond to this request.",
) -> None:
"""Best-effort private feedback for a rejected Slack button click."""
response_url = str(event.response_url or "")
parsed = urlparse(response_url)
if (
event.platform != "slack"
or parsed.scheme != "https"
or parsed.hostname not in {"hooks.slack.com", "hooks.slack-gov.com"}
):
return
def _post() -> None:
import httpx
try:
httpx.post(
response_url,
json={"response_type": "ephemeral", "text": text},
timeout=10,
)
except Exception:
logger.debug("Slack ephemeral interaction response failed", exc_info=True)
await to_thread(_post)
async def _on_inbound(self, event: MessageEvent) -> None:
self._record_recent(event) # capture identity even from unauthorized senders
settings = self.settings.get(event.source.platform)
+3
View File
@@ -358,7 +358,10 @@ class SlackRelayAdapter(BasePlatformAdapter):
chat_id=qualify(team_id, channel),
message_id=ts,
value=str(value),
user_id=user.get("id"),
user_name=user.get("username") or user.get("name"),
team_id=team_id,
response_url=interaction.get("response_url"),
)
)
+16
View File
@@ -93,6 +93,9 @@ def connector_list(secrets: SecretStore) -> list[dict[str, Any]]:
"enabled": bool(profile.get("enabled", True)) and connected,
# The actual allow-list (the GUI manages it inline); was a bare count.
"allowed_users": list(profile.get("allowed_users") or []),
# Manual Socket Mode only: explicitly selected humans who may resolve
# consequential Inbox prompts. Relay uses its OAuth installer instead.
"approval_owner_ids": list(profile.get("approval_owner_ids") or []),
"tools": tool_dicts(secrets, d.name),
"experimental": d.experimental,
"risk_notice": d.risk_notice,
@@ -107,6 +110,10 @@ def connector_list(secrets: SecretStore) -> list[dict[str, Any]]:
# Managed relay is multi-workspace: each `slack:team:*` profile is one
# connected workspace with its OWN allow-list (ids are workspace-scoped).
entry["workspaces"] = _slack_workspaces(secrets)
if profile.get("mode") == "relay":
# Dormant Manual-mode owners may remain beside preserved Socket
# Mode credentials; they never authorize a bare Relay target.
entry["approval_owner_ids"] = []
if d.name == "gmail":
# Multi-account: each `gmail:account:*` profile is one mailbox; the
# :default profile is just the default pointer + privacy filters.
@@ -185,6 +192,11 @@ def _slack_workspaces(secrets: SecretStore) -> list[dict[str, Any]]:
"domain": profile.get("domain") or "",
"allowed_users": list(profile.get("allowed_users") or []),
"allow_all": bool(profile.get("allow_all")),
# Relay approvals are installer-only. Keep the list-shaped API aligned
# with Manual mode without creating a second editable relay role.
"approval_owner_ids": (
[profile["slack_user_id"]] if profile.get("slack_user_id") else []
),
# Who installed (authed_user) — the GUI marks their chip "you" and
# keys the post-connect card's "your mentions get through" line.
"installer_user_id": profile.get("slack_user_id") or "",
@@ -353,6 +365,10 @@ def connect_connector(
profile: dict[str, Any] = {"type": profile_type, "enabled": True, **token_creds}
if any(f.key == "allowed_users" for f in d.fields):
profile["allowed_users"] = allowed
if name == "slack" and existing.get("approval_owner_ids"):
# Re-pasting manual Socket Mode tokens must not erase the locally selected
# approval owners.
profile["approval_owner_ids"] = list(existing["approval_owner_ids"])
if identity:
profile["account"] = identity
if d.account_field: