Skip to content

Commit 5b99696

Browse files
Merge pull request #1 from Chinchill-AI/security-fixes
security: Fix all critical and high findings from security audit
2 parents 968582b + ddb4042 commit 5b99696

14 files changed

Lines changed: 205 additions & 24 deletions

File tree

.github/workflows/publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ jobs:
1111
permissions:
1212
id-token: write # trusted publishing
1313
steps:
14-
- uses: actions/checkout@v4
15-
- uses: astral-sh/setup-uv@v4
14+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
15+
- uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v4
1616
- name: Build
1717
run: uv build
1818
- name: Publish to PyPI
19-
uses: pypa/gh-action-pypi-publish@release/v1
19+
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9e8454a21b30b1df74fa848 # release/v1

.github/workflows/test-publish.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Publish to TestPyPI
2+
3+
on:
4+
workflow_dispatch: # manual trigger only
5+
6+
jobs:
7+
test-publish:
8+
runs-on: ubuntu-latest
9+
environment: testpypi
10+
permissions:
11+
id-token: write # trusted publishing
12+
steps:
13+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
14+
- uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v4
15+
- name: Build
16+
run: uv build
17+
- name: Publish to TestPyPI
18+
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9e8454a21b30b1df74fa848 # release/v1
19+
with:
20+
repository-url: https://test.pypi.org/legacy/

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ jobs:
1313
matrix:
1414
python-version: ["3.11", "3.12", "3.13"]
1515
steps:
16-
- uses: actions/checkout@v4
17-
- uses: astral-sh/setup-uv@v4
16+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
17+
- uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v4
1818
- name: Set up Python ${{ matrix.python-version }}
1919
run: uv python install ${{ matrix.python-version }}
2020
- name: Install dependencies

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ pip install chat-sdk[all] # all adapters + state backends
1515
## Quick Start
1616

1717
```python
18-
from chat_sdk import Chat, Card, Button, Actions
18+
from chat_sdk import Chat, Card, Button, Actions, MemoryStateAdapter
19+
from chat_sdk.adapters.slack import create_slack_adapter
1920

2021
chat = Chat(
2122
adapters={"slack": create_slack_adapter()},

src/chat_sdk/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@
6363
resolve_emoji_from_slack,
6464
)
6565
from chat_sdk.errors import ChatError, ChatNotImplementedError, LockError, RateLimitError
66+
from chat_sdk.shared.errors import (
67+
AdapterRateLimitError,
68+
AuthenticationError,
69+
NetworkError,
70+
PermissionError as AdapterPermissionError,
71+
ResourceNotFoundError,
72+
ValidationError,
73+
)
6674
from chat_sdk.from_full_stream import from_full_stream
6775
from chat_sdk.logger import ConsoleLogger, Logger, LogLevel
6876
from chat_sdk.message_history import MessageHistoryCache, MessageHistoryConfig
@@ -226,6 +234,13 @@
226234
"ChatNotImplementedError",
227235
"LockError",
228236
"RateLimitError",
237+
# Adapter errors
238+
"AdapterRateLimitError",
239+
"AuthenticationError",
240+
"ValidationError",
241+
"NetworkError",
242+
"ResourceNotFoundError",
243+
"AdapterPermissionError",
229244
# Format converter
230245
"BaseFormatConverter",
231246
# Logger

src/chat_sdk/adapters/discord/adapter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import hmac
1112
import json
1213
import os
1314
import re
@@ -178,7 +179,7 @@ async def handle_webhook(
178179
# Check if this is a forwarded Gateway event (uses bot token for auth)
179180
gateway_token = self._get_header(request, "x-discord-gateway-token")
180181
if gateway_token:
181-
if gateway_token != self._bot_token:
182+
if not hmac.compare_digest(gateway_token, self._bot_token):
182183
self._logger.warn("Invalid gateway token")
183184
return self._make_response("Invalid gateway token", 401)
184185
self._logger.info("Discord forwarded Gateway event received")

src/chat_sdk/adapters/google_chat/adapter.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ def __init__(self, config: GoogleChatAdapterConfig | None = None) -> None:
145145
self._warned_no_webhook_verification = False
146146
self._warned_no_pubsub_verification = False
147147

148+
# Cached JWKS client for JWT verification (lazy init on first use)
149+
self._jwks_client: Any | None = None
150+
148151
# Auth setup
149152
self._credentials: ServiceAccountCredentials | None = None
150153
self._use_adc = False
@@ -651,11 +654,10 @@ async def _verify_bearer_token(
651654
import jwt as pyjwt
652655
from jwt import PyJWKClient
653656

654-
# TODO: For direct Chat webhook JWTs signed by chat@system.gserviceaccount.com,
655-
# use https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com
656-
# Currently only supports OIDC-based verification (Pub/Sub push tokens and HTTP endpoint auth)
657-
jwks_client = PyJWKClient("https://www.googleapis.com/oauth2/v3/certs")
658-
signing_key = jwks_client.get_signing_key_from_jwt(token)
657+
# Lazily create and cache the JWKS client (avoid per-request instantiation)
658+
if self._jwks_client is None:
659+
self._jwks_client = PyJWKClient("https://www.googleapis.com/oauth2/v3/certs")
660+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
659661
payload = pyjwt.decode(
660662
token,
661663
signing_key.key,

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2691,6 +2691,13 @@ async def _send_to_response_url(
26912691
thread_ts: str | None = None,
26922692
) -> dict[str, Any]:
26932693
"""Send a request to Slack's response_url to modify an ephemeral message."""
2694+
# Validate response_url points to Slack (prevent SSRF)
2695+
from urllib.parse import urlparse
2696+
2697+
parsed = urlparse(response_url)
2698+
if not (parsed.scheme == "https" and parsed.hostname and parsed.hostname.endswith(".slack.com")):
2699+
raise ValidationError("slack", f"Invalid response_url: must be https://*.slack.com, got {response_url}")
2700+
26942701
import httpx
26952702

26962703
payload: dict[str, Any]

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
from __future__ import annotations
1010

1111
import base64
12+
import hmac
1213
import json
1314
import os
1415
import re
1516
from datetime import datetime, timezone
1617
from typing import Any
18+
from urllib.parse import urlparse
1719

1820
from chat_sdk.adapters.teams.cards import card_to_adaptive_card
1921
from chat_sdk.adapters.teams.format_converter import TeamsFormatConverter
@@ -57,6 +59,37 @@
5759
MESSAGEID_STRIP_PATTERN = re.compile(r";messageid=\d+")
5860
CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000 # 30 days
5961

62+
# Allowed Microsoft Bot Framework service URL patterns (SSRF protection).
63+
# Covers commercial, GCC, GCCH, DoD, and sovereign cloud endpoints.
64+
ALLOWED_SERVICE_URL_PATTERNS = [
65+
re.compile(r"^https://smba\.trafficmanager\.net/"),
66+
re.compile(r"^https://[a-z0-9.-]+\.botframework\.com/"),
67+
re.compile(r"^https://[a-z0-9.-]+\.botframework\.us/"),
68+
re.compile(r"^https://[a-z0-9.-]+\.teams\.microsoft\.com/"),
69+
re.compile(r"^https://[a-z0-9.-]+\.teams\.microsoft\.us/"),
70+
re.compile(r"^https://smba\.infra\.(gcc|gov)\.teams\.microsoft\.(com|us)/"),
71+
]
72+
73+
# Bot Framework OpenID configuration URL for JWT verification
74+
BOT_FRAMEWORK_OPENID_CONFIG_URL = (
75+
"https://login.botframework.com/v1/.well-known/openid-configuration"
76+
)
77+
78+
79+
def _validate_service_url(url: str) -> None:
80+
"""Validate that a service URL matches known Microsoft Bot Framework endpoints.
81+
82+
Raises :class:`~chat_sdk.shared.errors.ValidationError` if the URL is not
83+
in the allow-list, preventing SSRF attacks via crafted ``serviceUrl`` values.
84+
"""
85+
for pattern in ALLOWED_SERVICE_URL_PATTERNS:
86+
if pattern.match(url):
87+
return
88+
raise ValidationError(
89+
"teams",
90+
f"Service URL is not an allowed Bot Framework endpoint: {url}",
91+
)
92+
6093

6194
def _handle_teams_error(error: Any, operation: str) -> None:
6295
"""Convert Teams SDK errors to adapter errors and raise.
@@ -132,6 +165,7 @@ def __init__(self, config: TeamsAdapterConfig | None = None) -> None:
132165
self._bot_user_id: str | None = self._app_id or None
133166
self._access_token: str | None = None
134167
self._token_expiry: float = 0
168+
self._jwks_client: Any | None = None # Cached PyJWKClient for JWT verification
135169

136170
@property
137171
def name(self) -> str:
@@ -170,6 +204,12 @@ async def handle_webhook(
170204
body = await self._get_request_body(request)
171205
self._logger.debug("Teams webhook raw body", {"body": body[:500] if body else ""})
172206

207+
# ---- JWT verification (Bot Framework tokens) ----
208+
if self._app_id:
209+
auth_result = await self._verify_bot_framework_token(request)
210+
if auth_result is not None:
211+
return auth_result
212+
173213
try:
174214
activity: dict[str, Any] = json.loads(body)
175215
except (json.JSONDecodeError, ValueError):
@@ -1529,6 +1569,7 @@ async def _teams_send(
15291569
"""Send an activity to a Teams conversation via Bot Framework REST API."""
15301570
import aiohttp # lazy import
15311571

1572+
_validate_service_url(decoded.service_url)
15321573
token = await self._get_access_token()
15331574
url = f"{decoded.service_url}v3/conversations/{decoded.conversation_id}/activities"
15341575

@@ -1560,6 +1601,7 @@ async def _teams_update(
15601601
"""Update an activity in a Teams conversation via Bot Framework REST API."""
15611602
import aiohttp # lazy import
15621603

1604+
_validate_service_url(decoded.service_url)
15631605
token = await self._get_access_token()
15641606
url = f"{decoded.service_url}v3/conversations/{decoded.conversation_id}/activities/{message_id}"
15651607

@@ -1589,6 +1631,7 @@ async def _teams_delete(
15891631
"""Delete an activity from a Teams conversation via Bot Framework REST API."""
15901632
import aiohttp # lazy import
15911633

1634+
_validate_service_url(decoded.service_url)
15921635
token = await self._get_access_token()
15931636
url = f"{decoded.service_url}v3/conversations/{decoded.conversation_id}/activities/{message_id}"
15941637

@@ -1606,6 +1649,61 @@ async def _teams_delete(
16061649
f"Teams API error: {response.status} {error_text}",
16071650
)
16081651

1652+
# =========================================================================
1653+
# JWT verification (Bot Framework)
1654+
# =========================================================================
1655+
1656+
async def _verify_bot_framework_token(self, request: Any) -> Any | None:
1657+
"""Verify the JWT Bearer token from the Bot Framework.
1658+
1659+
Returns a 401 response dict if authentication fails, or ``None`` if
1660+
the token is valid.
1661+
"""
1662+
auth_header: str | None = self._get_header(request, "authorization")
1663+
if not auth_header or not auth_header.startswith("Bearer "):
1664+
self._logger.warn("Missing or invalid Authorization header on Teams webhook")
1665+
return self._make_response("Unauthorized", 401)
1666+
1667+
token = auth_header[7:]
1668+
try:
1669+
import jwt as pyjwt
1670+
from jwt import PyJWKClient
1671+
1672+
# Lazily create and cache the JWKS client
1673+
if self._jwks_client is None:
1674+
import aiohttp
1675+
1676+
async with aiohttp.ClientSession() as session:
1677+
async with session.get(BOT_FRAMEWORK_OPENID_CONFIG_URL) as resp:
1678+
if resp.status != 200:
1679+
self._logger.error("Failed to fetch Bot Framework OpenID config", {"status": resp.status})
1680+
return self._make_response("Unauthorized", 401)
1681+
openid_config = await resp.json()
1682+
jwks_uri = openid_config.get("jwks_uri")
1683+
if not jwks_uri:
1684+
self._logger.error("No jwks_uri in Bot Framework OpenID config")
1685+
return self._make_response("Unauthorized", 401)
1686+
self._jwks_client = PyJWKClient(jwks_uri)
1687+
1688+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
1689+
payload = pyjwt.decode(
1690+
token,
1691+
signing_key.key,
1692+
algorithms=["RS256"],
1693+
audience=self._app_id,
1694+
)
1695+
self._logger.debug(
1696+
"Teams JWT verified",
1697+
{
1698+
"iss": payload.get("iss"),
1699+
"aud": payload.get("aud"),
1700+
},
1701+
)
1702+
return None # success
1703+
except Exception as exc:
1704+
self._logger.warn(f"Teams JWT verification failed: {exc}")
1705+
return self._make_response("Unauthorized", 401)
1706+
16091707
# =========================================================================
16101708
# Request/Response helpers (framework-agnostic)
16111709
# =========================================================================

src/chat_sdk/adapters/whatsapp/adapter.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -637,10 +637,32 @@ async def download_media(self, media_id: str) -> bytes:
637637

638638
media_info = await meta_response.json()
639639

640-
# Step 2: Download the actual file
640+
# Validate the download URL to prevent SSRF
641+
download_url = media_info["url"]
642+
parsed = urlparse(download_url)
643+
if parsed.scheme != "https":
644+
raise ValidationError(
645+
"whatsapp",
646+
f"Media download URL must use HTTPS, got: {parsed.scheme}",
647+
)
648+
host = (parsed.hostname or "").lower()
649+
allowed_suffixes = (
650+
".facebook.com", ".fbcdn.net", ".fbsbx.com",
651+
".whatsapp.net", ".whatsapp.com",
652+
)
653+
allowed_exact = {"facebook.com", "fbcdn.net", "fbsbx.com", "whatsapp.net", "whatsapp.com"}
654+
if not (
655+
any(host.endswith(s) for s in allowed_suffixes)
656+
or host in allowed_exact
657+
):
658+
raise ValidationError(
659+
"whatsapp",
660+
f"Media download URL host is not an allowed Meta domain: {host}",
661+
)
662+
663+
# Step 2: Download the actual file (no Bearer token -- CDN URLs are pre-signed)
641664
async with session.get(
642-
media_info["url"],
643-
headers={"Authorization": f"Bearer {self._access_token}"},
665+
download_url,
644666
) as data_response:
645667
if data_response.status != 200:
646668
self._logger.error(

0 commit comments

Comments
 (0)