Skip to content

Commit 68e0595

Browse files
fix: security hardening and launch docs for chat-sdk-python
Security fixes: - Teams: reject webhooks when app_id is empty instead of silently skipping JWT verification (was a complete auth bypass) - Teams: add issuer validation to JWT decode (require https://api.botframework.com) - Teams: validate service_url against SSRF allow-list in open_dm and _cache_user_context before storing/using untrusted URLs Performance: - Slack: cache AsyncWebClient instances by token instead of creating a new client on every request Docs: - README: add "Why chat-sdk?" and "Compared to Alternatives" sections, update version to 0.0.1a3 - Add CONTRIBUTING.md with dev setup, testing, and PR guidelines - Add CHANGELOG.md for initial alpha release Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 095113f commit 68e0595

5 files changed

Lines changed: 126 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Changelog
2+
3+
## 0.0.1a3 (2026-04-06)
4+
5+
Initial alpha release.
6+
7+
- Core SDK: Chat orchestrator, Thread, Channel, Message, Cards, Modals, Emoji
8+
- 8 adapters: Slack, Discord, Teams, Telegram, WhatsApp, Google Chat, GitHub, Linear
9+
- 3 state backends: Memory, Redis, PostgreSQL
10+
- 2,467 tests passing
11+
- Security hardened: JWT verification, SSRF protection, timing-safe comparisons

CONTRIBUTING.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Contributing to chat-sdk-python
2+
3+
Thanks for your interest in contributing! This guide covers the essentials.
4+
5+
## Dev Environment Setup
6+
7+
```bash
8+
# Clone and install (requires Python 3.11+ and uv)
9+
git clone https://github.com/Chinchill-AI/chat-sdk-python.git
10+
cd chat-sdk-python
11+
uv sync --group dev
12+
```
13+
14+
## Running Tests
15+
16+
```bash
17+
uv run pytest tests/ # all tests
18+
uv run pytest tests/ -x # stop on first failure
19+
uv run pytest tests/unit/ # unit tests only
20+
```
21+
22+
## Code Quality
23+
24+
```bash
25+
uv run ruff check src/ tests/ # lint
26+
uv run ruff format src/ tests/ # auto-format
27+
```
28+
29+
All PRs must pass `ruff check` with zero errors.
30+
31+
## Adding a New Adapter
32+
33+
1. Create `src/chat_sdk/adapters/<platform>/` with at minimum:
34+
- `adapter.py` -- the adapter class implementing the Adapter protocol
35+
- `__init__.py` -- public exports and a `create_<platform>_adapter()` factory
36+
2. Follow the patterns in existing adapters (Slack and Teams are good references).
37+
3. Add an optional-dependency group in `pyproject.toml`.
38+
4. Add tests under `tests/unit/adapters/<platform>/`.
39+
40+
## Pull Request Expectations
41+
42+
- **Tests required.** Every bugfix or feature needs at least one test.
43+
- **Ruff clean.** `uv run ruff check src/ tests/` must pass with no errors.
44+
- **Small, focused PRs** are easier to review than large ones.
45+
- **Descriptive commit messages.** Explain *why*, not just *what*.
46+
47+
## Issues and PRs
48+
49+
- Check existing issues before opening a new one.
50+
- Reference the issue number in your PR description (e.g., `Fixes #42`).
51+
- For large changes, open an issue first to discuss the approach.
52+
53+
## License
54+
55+
By contributing you agree that your contributions will be licensed under the MIT License.

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22

33
Multi-platform async chat SDK for Python. Port of [Vercel Chat](https://github.com/vercel/chat).
44

5-
> **Status: Alpha (0.0.1a1)** — API may change. Not yet tested in production.
5+
> **Status: Alpha (0.0.1a3)** — API may change. Not yet tested in production.
6+
7+
## Why chat-sdk?
8+
9+
- **Write once, deploy to 8 platforms.** One handler runs on Slack, Discord, Teams, Telegram, WhatsApp, Google Chat, GitHub, and Linear.
10+
- **Built-in concurrency primitives.** Deduplication, thread locking, and message queuing are handled for you.
11+
- **Cross-platform cards.** Author a `Card` once and it renders as Block Kit (Slack), Adaptive Cards (Teams), embeds (Discord), and more.
12+
- **Not a replacement for platform SDKs.** chat-sdk is built *on top of* them. You can always drop down to the native SDK when you need to.
613

714
## Install
815

@@ -54,6 +61,17 @@ async def handle_mention(thread, message):
5461
| Redis | `chat-sdk[redis]` |
5562
| PostgreSQL | `chat-sdk[postgres]` |
5663

64+
## Compared to Alternatives
65+
66+
| Feature | chat-sdk | Raw platform SDKs | BotFramework SDK |
67+
|---------|----------|--------------------|------------------|
68+
| Multi-platform from one codebase | 8 platforms | 1 per SDK | Teams + limited |
69+
| Async-native (Python 3.11+) | Yes | Varies | No |
70+
| Cross-platform cards | Card model | Platform-specific | Adaptive Cards only |
71+
| Thread locking / dedup | Built-in | DIY | DIY |
72+
| State abstraction (mem/redis/pg) | Built-in | DIY | DIY |
73+
| Drop down to native SDK | Yes | N/A | Partially |
74+
5775
## Development
5876

5977
```bash

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
197197
# Channel external/shared cache
198198
self._external_channels: set[str] = set()
199199

200+
# Cache of AsyncWebClient instances keyed by bot token
201+
self._client_cache: dict[str, Any] = {}
202+
200203
# Multi-workspace OAuth fields
201204
self._client_id: str | None = config.client_id or (os.environ.get("SLACK_CLIENT_ID") if zero_config else None)
202205
self._client_secret: str | None = config.client_secret or (
@@ -258,11 +261,20 @@ def _get_token(self) -> str:
258261
def _get_client(self, token: str | None = None) -> Any:
259262
"""Return an ``AsyncWebClient`` for the given (or current) token.
260263
261-
The import is deferred so that ``slack_sdk`` is only required at call-time.
264+
Clients are cached by token so we avoid creating a new instance on
265+
every request. The import is deferred so that ``slack_sdk`` is only
266+
required at call-time.
262267
"""
268+
resolved_token = token or self._get_token()
269+
cached = self._client_cache.get(resolved_token)
270+
if cached is not None:
271+
return cached
272+
263273
from slack_sdk.web.async_client import AsyncWebClient
264274

265-
return AsyncWebClient(token=token or self._get_token())
275+
client = AsyncWebClient(token=resolved_token)
276+
self._client_cache[resolved_token] = client
277+
return client
266278

267279
# ------------------------------------------------------------------
268280
# Initialization

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ def __init__(self, config: TeamsAdapterConfig | None = None) -> None:
164164
"Use app_password (client secret) or federated (workload identity) authentication instead.",
165165
)
166166

167+
if not self._app_id:
168+
self._logger.warn(
169+
"Teams app_id is empty — webhook verification will reject all incoming requests. "
170+
"Set TEAMS_APP_ID or pass app_id in config."
171+
)
172+
167173
self._bot_user_id: str | None = self._app_id or None
168174
self._access_token: str | None = None
169175
self._token_expiry: float = 0
@@ -207,10 +213,13 @@ async def handle_webhook(
207213
self._logger.debug("Teams webhook raw body", {"body": body[:500] if body else ""})
208214

209215
# ---- JWT verification (Bot Framework tokens) ----
210-
if self._app_id:
211-
auth_result = await self._verify_bot_framework_token(request)
212-
if auth_result is not None:
213-
return auth_result
216+
if not self._app_id:
217+
self._logger.warn("Rejecting Teams webhook: app_id is not configured, cannot verify JWT")
218+
return self._make_response("Unauthorized – Teams app_id not configured", 401)
219+
220+
auth_result = await self._verify_bot_framework_token(request)
221+
if auth_result is not None:
222+
return auth_result
214223

215224
try:
216225
activity: dict[str, Any] = json.loads(body)
@@ -259,10 +268,19 @@ async def _cache_user_context(self, activity: dict[str, Any]) -> None:
259268
ttl = CACHE_TTL_MS
260269
state = self._chat.get_state()
261270

262-
# Cache serviceUrl
271+
# Cache serviceUrl (validate against SSRF allow-list first)
263272
service_url = activity.get("serviceUrl")
264273
if service_url and state:
265-
await state.set(f"teams:serviceUrl:{user_id}", service_url, ttl)
274+
try:
275+
_validate_service_url(service_url)
276+
except ValidationError:
277+
self._logger.warn(
278+
"Refusing to cache disallowed serviceUrl",
279+
{"serviceUrl": service_url},
280+
)
281+
service_url = None
282+
if service_url:
283+
await state.set(f"teams:serviceUrl:{user_id}", service_url, ttl)
266284

267285
# Cache tenantId
268286
channel_data = activity.get("channelData", {})
@@ -1112,6 +1130,8 @@ async def open_dm(self, user_id: str) -> str:
11121130
if not service_url:
11131131
service_url = "https://smba.trafficmanager.net/teams/"
11141132

1133+
_validate_service_url(service_url)
1134+
11151135
import aiohttp
11161136

11171137
token = await self._get_access_token()
@@ -1693,6 +1713,7 @@ async def _verify_bot_framework_token(self, request: Any) -> Any | None:
16931713
signing_key.key,
16941714
algorithms=["RS256"],
16951715
audience=self._app_id,
1716+
issuer="https://api.botframework.com",
16961717
)
16971718
self._logger.debug(
16981719
"Teams JWT verified",

0 commit comments

Comments
 (0)