Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/UPSTREAM_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,8 @@ stay explicit instead of being rediscovered in code review.
| jsx-runtime `callbackUrl` props (vercel/chat#454 slice) | Not ported | `ButtonProps`/`ModalProps` gain `callbackUrl`; `resolveJSXElement` forwards it | Covered by the existing "JSX Card/Modal elements" row — Python has no JSX runtime; `Button()`/`Modal()` builders accept `callback_url` directly. |
| Transcripts API Python adaptations (vercel/chat#448) | `transcripts.delete()` returns a `DeleteResult` dataclass; misconfiguration raises `ValueError` (constructor/`AppendInput` guards, invalid duration) or `ChatError` (`chat.transcripts` accessor); guard messages name the Python kwarg (`options.user_key`); `DurationString` is a `str` alias validated at runtime by `_parse_duration` | Inline `{ deleted: number }`; generic `Error` for all of the above; template-literal `` `${number}${"s"\|"m"\|"h"\|"d"}` `` type | Port rules: typed dataclasses over raw dicts; repo error-type conventions (constructor misconfig → `ValueError`, runtime API misuse → `ChatError`) with upstream-matching message wording; Python has no template-literal types. Same shapes and values throughout. |
| Slack legacy mrkdwn renderer (response_url surface only, post-#440) | `_node_to_mrkdwn` renders headings as `*bold*` and images as `{alt} ({url})` / bare URL | TS `nodeToMrkdwn` has no heading/image branches — both fall through to `defaultNodeToText`, dropping heading emphasis and image URLs | Pre-existing Python improvement; after vercel/chat#440 it affects only `to_response_url_text` (ephemeral edits via response_url). Preserves visual hierarchy and image URLs Slack would otherwise lose. |
| Slack `api` primitives `send_slack_response_url` URL gate (vercel/chat#548) | `send_slack_response_url` (`slack/api/__init__.py`) calls `_assert_slack_response_url(url)` before POSTing — requires an `https://*.slack.com` URL (where Slack-issued `response_url`s always live) and raises `ValueError` for anything else | Upstream `api/client.ts` `sendResponseUrl` POSTs to whatever `response_url` it is handed, with no scheme/host validation | SSRF guard. The `response_url` reaching this primitive can originate from a parsed-but-unverified interaction payload; without a gate a crafted value could redirect the POST (which carries no bearer token but does echo SDK-controlled message content and trigger an arbitrary outbound request) to an attacker host. Enforces `CLAUDE.md`'s "Validate external URLs before requests (SSRF)" rule, mirroring the high-level adapter's `rehydrate_attachment` allowlist row above. Allowlist: scheme `https`, host `slack.com` or `*.slack.com`. |
| Slack `api` primitives `fetch_slack_file` host allowlist (vercel/chat#548) | `fetch_slack_file` (`slack/api/__init__.py`) gates `url` through `is_trusted_slack_file_url` before forwarding the bot token, raising `ValueError` for untrusted hosts | Upstream `api/client.ts` `fetchFile` GETs the supplied URL with `Authorization: Bearer <token>` unconditionally | Token-leak guard. `fetch_slack_file` attaches the workspace bot token; a crafted `url_private` from a parsed file object could otherwise exfiltrate that token to an arbitrary host. `is_trusted_slack_file_url` requires scheme `https` and host in `{files.slack.com, slack.com, *.slack.com, *.slack-edge.com}` — the same allowlist the high-level adapter's `rehydrate_attachment` row uses for Slack. Enforces `CLAUDE.md`'s SSRF/URL-validation rule. |

### Platform-specific gaps

Expand Down
24 changes: 22 additions & 2 deletions src/chat_sdk/adapters/slack/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
"""Slack adapter for chat-sdk."""
"""Slack adapter for chat-sdk.

from chat_sdk.adapters.slack.adapter import SlackAdapter, create_slack_adapter
The high-level adapter is loaded lazily (PEP 562) so that the low-level
primitive subpaths (``chat_sdk.adapters.slack.webhook``) can be imported
without pulling in the full adapter runtime — mirroring upstream's
``@chat-adapter/slack/webhook`` subpath export boundary (vercel/chat#538).
"""

from __future__ import annotations

import importlib
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from chat_sdk.adapters.slack.adapter import SlackAdapter as SlackAdapter
from chat_sdk.adapters.slack.adapter import create_slack_adapter as create_slack_adapter

__all__ = ["SlackAdapter", "create_slack_adapter"]


def __getattr__(name: str) -> object:
if name in __all__:
module = importlib.import_module("chat_sdk.adapters.slack.adapter")
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
126 changes: 26 additions & 100 deletions src/chat_sdk/adapters/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import base64
import contextlib
import contextvars
import hashlib
import hmac
import inspect
import json
Expand Down Expand Up @@ -58,6 +57,10 @@
SlackThreadId,
SlackWebhookVerifier,
)
from chat_sdk.adapters.slack.webhook import (
read_slack_request_body,
verify_slack_request,
)
from chat_sdk.emoji import emoji_to_slack, resolve_emoji_from_slack
from chat_sdk.logger import ConsoleLogger, Logger
from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement
Expand Down Expand Up @@ -1392,33 +1395,11 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No

Returns a dict with ``body`` and ``status`` keys.
"""
# Read the raw body. `hasattr` narrows `Any` → `object` (not
# awaitable), so we use `getattr(..., None)` to preserve the
# `Any` type across the duck-typed framework branches.
# Handle both callable (`async def text(self)`) and non-callable
# (`text: str` attribute) forms of `request.text`. Gating entry
# on callability would drop populated string attributes.
text_attr = getattr(request, "text", None)
body: str
if text_attr is not None:
if callable(text_attr):
result = text_attr()
text_attr = await result if inspect.isawaitable(result) else result
body = text_attr.decode("utf-8") if isinstance(text_attr, (bytes, bytearray)) else str(text_attr)
else:
raw = getattr(request, "body", None)
if raw is not None:
# Some frameworks expose `body` as an async method (e.g.
# `async def body(self)`) — call it, then await if the
# result is awaitable. Previously we only handled the
# coroutine-as-attribute case, not the async-method case.
if callable(raw):
raw = raw()
if asyncio.iscoroutine(raw) or asyncio.isfuture(raw) or inspect.isawaitable(raw):
raw = await raw
body = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw)
else:
body = str(request)
# Read the raw body via the shared webhook primitive (the Python
# stand-in for the Fetch API's ``await request.text()``) so the
# adapter and the low-level ``webhook`` subpath use one
# implementation for duck-typed framework requests.
body: str = await read_slack_request_body(request)

self._logger.debug("Slack webhook raw body", {"body": body[:500]})

Expand Down Expand Up @@ -1455,7 +1436,7 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
# Hazard #12 (replay): the shared bearer alone is not enough —
# without a freshness check, an old captured forwarded event
# could be replayed indefinitely. Mirror the 5-minute window
# ``_verify_signature`` enforces on signed webhook traffic.
# ``verify_slack_signature`` enforces on signed webhook traffic.
#
# Wire format: upstream's ``forwardSocketEvent`` always emits
# ``timestamp: Date.now()`` — milliseconds since the Unix epoch
Expand Down Expand Up @@ -1484,31 +1465,22 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
if self._mode == "socket":
return {"body": "Webhooks are disabled in socket mode", "status": 405}

# Verify the request — when a custom ``webhook_verifier`` is configured
# it takes precedence over ``signing_secret`` / ``SLACK_SIGNING_SECRET``
# (matches upstream vercel/chat#468). The verifier may also return a
# string that replaces the body for downstream parsing (e.g.
# canonicalization).
if self._webhook_verifier is not None:
try:
verifier_result = self._webhook_verifier(request, body)
if inspect.isawaitable(verifier_result):
verifier_result = await verifier_result
except Exception as exc:
self._logger.warn("Webhook verifier rejected request", {"error": exc})
return {"body": "Invalid signature", "status": 401}
if not verifier_result:
self._logger.warn("Webhook verifier rejected request")
return {"body": "Invalid signature", "status": 401}
if isinstance(verifier_result, str):
# Substitute the verifier-supplied canonical body before
# parsing. Other truthy returns are pure verification.
body = verifier_result
else:
timestamp = headers.get("x-slack-request-timestamp") or headers.get("X-Slack-Request-Timestamp")
signature = headers.get("x-slack-signature") or headers.get("X-Slack-Signature")
if not self._verify_signature(body, timestamp, signature):
return {"body": "Invalid signature", "status": 401}
# Verify the request via the shared webhook primitive (vercel/chat#538
# extracted this from the adapter) — when a custom ``webhook_verifier``
# is configured it takes precedence over ``signing_secret`` /
# ``SLACK_SIGNING_SECRET`` (matches upstream vercel/chat#468). The
# verifier may also return a string that replaces the body for
# downstream parsing (e.g. canonicalization).
try:
body = await verify_slack_request(
request,
body=body,
signing_secret=self._signing_secret,
webhook_verifier=self._webhook_verifier,
)
except Exception as exc:
self._logger.warn("Webhook verifier rejected request", {"error": exc})
return {"body": "Invalid signature", "status": 401}

# URL verification is special: Slack sends a JSON ``url_verification``
# ping at app-install / event-subscription time and only expects the
Expand Down Expand Up @@ -1658,52 +1630,6 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
self._process_event_payload(payload, options)
return {"body": "ok", "status": 200}

# ==================================================================
# Signature verification
# ==================================================================

def _verify_signature(self, body: str, timestamp: str | None, signature: str | None) -> bool:
# Defensive: ``_verify_signature`` should never be reached when only a
# ``webhook_verifier`` is configured (handle_webhook gates on that),
# but if a subclass calls this directly without a signing_secret,
# fail closed. This also covers the socket-mode case where
# ``signing_secret`` is legitimately ``None`` — without this guard a
# caller could call ``handle_webhook`` while in socket mode and
# silently pass verification with an empty secret.
if not self._signing_secret:
return False

if not (timestamp and signature):
return False

# Check timestamp is recent (within 5 minutes)
now = int(time.time())
try:
ts_int = int(timestamp)
except (ValueError, TypeError):
return False
if abs(now - ts_int) > 300:
return False

sig_basestring = f"v0:{timestamp}:{body}"
expected = (
"v0="
+ hmac.new(
self._signing_secret.encode("utf-8"),
sig_basestring.encode("utf-8"),
hashlib.sha256,
).hexdigest()
)

try:
# ``hmac.compare_digest`` is the canonical constant-time comparison.
# Custom verifiers passed via ``webhook_verifier`` MUST do the
# same — a regression to ``==`` would leak signature bytes via
# timing.
return hmac.compare_digest(signature, expected)
except Exception:
return False

# ==================================================================
# Event dispatch
# ==================================================================
Expand Down
Loading
Loading