99from __future__ import annotations
1010
1111import base64
12+ import hmac
1213import json
1314import os
1415import re
1516from datetime import datetime , timezone
1617from typing import Any
18+ from urllib .parse import urlparse
1719
1820from chat_sdk .adapters .teams .cards import card_to_adaptive_card
1921from chat_sdk .adapters .teams .format_converter import TeamsFormatConverter
5759MESSAGEID_STRIP_PATTERN = re .compile (r";messageid=\d+" )
5860CACHE_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
6194def _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 # =========================================================================
0 commit comments