diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index fbc91041d..a24bd9e86 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -1,9 +1,13 @@ """Feishu OAuth and Channel API routes.""" +import hashlib +import hmac +import json import uuid from fastapi import APIRouter, Depends, HTTPException, Request, status -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, Response +from lark_oapi.core.utils import AESCipher from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -31,6 +35,50 @@ ) +def _verify_and_decode_feishu_callback( + body_bytes: bytes, + headers: dict[str, str], + config: ChannelConfig, +) -> dict | None: + """Authenticate a Feishu callback before any event data is consumed.""" + try: + envelope = json.loads(body_bytes) + if not isinstance(envelope, dict): + return None + + encrypt_key = (config.encrypt_key or "").strip() + encrypted = envelope.get("encrypt") + if encrypted: + if not encrypt_key: + return None + payload = json.loads(AESCipher(encrypt_key).decrypt_str(encrypted)) + else: + payload = envelope + if not isinstance(payload, dict): + return None + + verification_token = (config.verification_token or "").strip() + actual_token = str((payload.get("header") or {}).get("token") or "") + if not verification_token or not hmac.compare_digest(actual_token, verification_token): + return None + + event_type = str((payload.get("header") or {}).get("event_type") or "") + if encrypt_key and event_type != "url_verification": + timestamp = headers.get("x-lark-request-timestamp", "") + nonce = headers.get("x-lark-request-nonce", "") + signature = headers.get("x-lark-signature", "") + if not timestamp or not nonce or not signature: + return None + expected = hashlib.sha256( + (timestamp + nonce + encrypt_key).encode() + body_bytes + ).hexdigest() + if not hmac.compare_digest(signature, expected): + return None + return payload + except (UnicodeDecodeError, ValueError, TypeError): + return None + + # ─── OAuth ────────────────────────────────────────────── @router.get("/auth/feishu/callback") @@ -391,7 +439,22 @@ async def feishu_event_webhook( request: Request, ): """Handle Feishu event callback for a specific agent's bot.""" - body = await request.json() + body_bytes = await request.body() + async with _async_session() as db: + result = await db.execute( + select(ChannelConfig).where( + ChannelConfig.agent_id == agent_id, + ChannelConfig.channel_type == "feishu", + ) + ) + config = result.scalar_one_or_none() + if not config: + return Response(status_code=status.HTTP_404_NOT_FOUND) + + body = _verify_and_decode_feishu_callback(body_bytes, dict(request.headers), config) + if body is None: + logger.warning("[Feishu] Rejected unauthenticated callback for {}", agent_id) + return Response(status_code=status.HTTP_401_UNAUTHORIZED) # Handle verification challenge if "challenge" in body: diff --git a/backend/app/api/teams.py b/backend/app/api/teams.py index 5c2690aef..ebc7a97c2 100644 --- a/backend/app/api/teams.py +++ b/backend/app/api/teams.py @@ -1,5 +1,6 @@ """Microsoft Teams Bot Channel API routes.""" +import hmac import json import os import time @@ -8,6 +9,7 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Request, Response +from jose import JWTError, jwk, jwt from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -37,6 +39,52 @@ # In-memory cache for OAuth tokens _teams_tokens: dict[str, dict] = {} # agent_id -> {access_token, expires_at} +_BOT_FRAMEWORK_OPENID_CONFIG = "https://login.botframework.com/v1/.well-known/openidconfiguration" +_BOT_FRAMEWORK_ISSUER = "https://api.botframework.com" + + +async def _validate_teams_callback( + authorization: str | None, + activity: dict, + config: ChannelConfig, +) -> bool: + """Verify a Bot Framework JWT and bind its serviceUrl to the activity.""" + if not authorization or not authorization.lower().startswith("bearer "): + return False + token = authorization[7:].strip() + app_id = (config.app_id or "").strip() + service_url = str(activity.get("serviceUrl") or "") + if not token or not app_id or not service_url: + return False + try: + header = jwt.get_unverified_header(token) + algorithm = header.get("alg") + key_id = header.get("kid") + if not algorithm or algorithm == "none" or not key_id: + return False + async with httpx.AsyncClient(timeout=10) as client: + metadata_response = await client.get(_BOT_FRAMEWORK_OPENID_CONFIG) + metadata_response.raise_for_status() + metadata = metadata_response.json() + jwks_response = await client.get(metadata["jwks_uri"]) + jwks_response.raise_for_status() + keys = jwks_response.json().get("keys", []) + key_data = next((item for item in keys if item.get("kid") == key_id), None) + if not key_data: + return False + claims = jwt.decode( + token, + jwk.construct(key_data, algorithm), + algorithms=[algorithm], + audience=app_id, + issuer=_BOT_FRAMEWORK_ISSUER, + options={"leeway": 300}, + ) + claimed_service_url = str(claims.get("serviceurl") or claims.get("serviceUrl") or "") + return bool(claimed_service_url) and hmac.compare_digest(claimed_service_url, service_url) + except (JWTError, KeyError, TypeError, ValueError, httpx.HTTPError): + return False + async def _get_teams_access_token(config: ChannelConfig) -> str | None: """Get or refresh Microsoft Teams access token. @@ -230,9 +278,12 @@ async def configure_teams_channel( tenant_id = data.get("tenant_id", "").strip() # Optional: for single-tenant apps use_managed_identity = data.get("use_managed_identity", False) # Optional: use Azure Managed Identity - # Validate: either managed identity OR app_id + app_secret required - if not use_managed_identity and (not app_id or not app_secret): - raise HTTPException(status_code=422, detail="Either use_managed_identity must be enabled, or app_id and app_secret are required") + # The App ID is required to verify the incoming Bot Framework JWT audience. + if not app_id or (not use_managed_identity and not app_secret): + raise HTTPException( + status_code=422, + detail="app_id is required; app_secret is required unless managed identity is enabled", + ) result = await db.execute( select(ChannelConfig).where( @@ -242,7 +293,7 @@ async def configure_teams_channel( ) existing = result.scalar_one_or_none() if existing: - existing.app_id = app_id if not use_managed_identity else existing.app_id + existing.app_id = app_id existing.app_secret = app_secret if not use_managed_identity else existing.app_secret existing.is_configured = True # Store tenant_id and use_managed_identity in extra_config @@ -266,7 +317,7 @@ async def configure_teams_channel( config = ChannelConfig( agent_id=agent_id, channel_type="microsoft_teams", - app_id=app_id if not use_managed_identity else None, + app_id=app_id, app_secret=app_secret if not use_managed_identity else None, is_configured=True, extra_config=extra_config, @@ -381,7 +432,13 @@ async def teams_event_webhook( logger.warning(f"Teams: Webhook received for unconfigured agent {agent_id}") return Response(status_code=404) - # Extract serviceUrl from the activity for sending replies + if not await _validate_teams_callback( + request.headers.get("authorization"), activity, config + ): + logger.warning("Teams: Rejected unauthenticated callback for agent {}", agent_id) + return Response(status_code=401) + + # This value is now authenticated by the JWT serviceUrl claim above. service_url = activity.get("serviceUrl") if service_url: if config.extra_config.get("service_url") != service_url: diff --git a/backend/tests/test_feishu_channel_runtime.py b/backend/tests/test_feishu_channel_runtime.py index 5c0fd492b..1503f6901 100644 --- a/backend/tests/test_feishu_channel_runtime.py +++ b/backend/tests/test_feishu_channel_runtime.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib from datetime import UTC, datetime from types import SimpleNamespace import uuid @@ -48,6 +49,40 @@ def __call__(self): return next(self.sessions) +def test_feishu_callback_rejects_missing_or_mismatched_verification_token() -> None: + config = SimpleNamespace(verification_token="expected", encrypt_key="") + + assert feishu._verify_and_decode_feishu_callback( + b'{"header":{"token":"unexpected"}}', {}, config # type: ignore[arg-type] + ) is None + + +def test_feishu_callback_accepts_a_matching_verification_token() -> None: + config = SimpleNamespace(verification_token="expected", encrypt_key="") + payload = b'{"header":{"token":"expected","event_type":"im.message.receive_v1"}}' + + assert feishu._verify_and_decode_feishu_callback(payload, {}, config) == { + "header": {"token": "expected", "event_type": "im.message.receive_v1"} + } + + +def test_feishu_callback_rejects_an_invalid_signed_request() -> None: + config = SimpleNamespace(verification_token="expected", encrypt_key="encrypt-key") + payload = b'{"header":{"token":"expected","event_type":"im.message.receive_v1"}}' + headers = { + "x-lark-request-timestamp": "1", + "x-lark-request-nonce": "2", + "x-lark-signature": "invalid", + } + + assert feishu._verify_and_decode_feishu_callback(payload, headers, config) is None + + headers["x-lark-signature"] = hashlib.sha256( + b"12encrypt-key" + payload + ).hexdigest() + assert feishu._verify_and_decode_feishu_callback(payload, headers, config) is not None + + def _runtime(tenant_id: uuid.UUID) -> ChatRuntimeIntake: run_id = uuid.uuid4() return ChatRuntimeIntake( diff --git a/backend/tests/test_http_channel_runtime.py b/backend/tests/test_http_channel_runtime.py index bc965082e..ecbae89a5 100644 --- a/backend/tests/test_http_channel_runtime.py +++ b/backend/tests/test_http_channel_runtime.py @@ -53,9 +53,9 @@ async def close(self) -> None: class _Request: - def __init__(self, body: dict) -> None: + def __init__(self, body: dict, headers: dict[str, str] | None = None) -> None: self._body = json.dumps(body).encode() - self.headers: dict[str, str] = {} + self.headers = headers or {} async def body(self) -> bytes: return self._body @@ -176,7 +176,10 @@ async def test_teams_webhook_uses_runtime_intake(monkeypatch) -> None: config = SimpleNamespace( app_id="bot-1", app_secret="", - extra_config={"use_managed_identity": False}, + extra_config={ + "use_managed_identity": False, + "service_url": "https://smba.trafficmanager.net/teams/", + }, is_connected=False, ) agent = SimpleNamespace(id=agent_id, tenant_id=tenant_id, creator_id=uuid.uuid4()) @@ -201,10 +204,14 @@ async def enqueue(_db, **kwargs): calls["intake"] = kwargs return intake + async def validate_callback(*_args, **_kwargs): + return True + monkeypatch.setattr(channel_user_service, "resolve_channel_user", resolve_user) monkeypatch.setattr(teams, "find_or_create_channel_session", find_session) monkeypatch.setattr(teams, "_load_agent_and_model", load_model) monkeypatch.setattr(teams, "enqueue_channel_chat_runtime", enqueue) + monkeypatch.setattr(teams, "_validate_teams_callback", validate_callback) result = await teams.teams_event_webhook( agent_id, @@ -219,6 +226,7 @@ async def enqueue(_db, **kwargs): "id": "teams-conversation-1", "conversationType": "personal", }, + "serviceUrl": "https://smba.trafficmanager.net/teams/", } ), # type: ignore[arg-type] db, # type: ignore[arg-type] @@ -243,6 +251,26 @@ async def enqueue(_db, **kwargs): ) +@pytest.mark.asyncio +async def test_teams_webhook_rejects_requests_without_a_valid_jwt() -> None: + agent_id = uuid.uuid4() + config = SimpleNamespace(app_id="bot-1", extra_config={}) + db = _Session(config) + + result = await teams.teams_event_webhook( + agent_id, + _Request( + { + "type": "message", + "serviceUrl": "https://smba.trafficmanager.net/teams/", + } + ), # type: ignore[arg-type] + db, # type: ignore[arg-type] + ) + + assert result.status_code == 401 + + @pytest.mark.asyncio async def test_whatsapp_webhook_uses_runtime_intake(monkeypatch) -> None: tenant_id = uuid.uuid4()