mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
security: harden Slack approval handling
This commit is contained in:
@@ -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"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user