diff --git a/apps/api/src/api/v1/telegram_webhook.py b/apps/api/src/api/v1/telegram_webhook.py new file mode 100644 index 00000000..6e98c666 --- /dev/null +++ b/apps/api/src/api/v1/telegram_webhook.py @@ -0,0 +1,76 @@ +""" +Telegram Webhook Handler — ADR-094 +=================================== +接收 Telegram Bot API 的 webhook push,支援 secret_token 驗證。 +WS4 Hermes NL 接入點框架(目前只記錄 update,不執行動作)。 + +2026-04-24 Claude Sonnet 4.6 (ADR-093 WS3 / ADR-094) +""" +from __future__ import annotations + +from typing import Optional + +import structlog +from fastapi import APIRouter, Depends, Header, HTTPException, Request + +from src.core.config import settings + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix="/telegram", tags=["Telegram Webhook"]) + + +def _verify_secret_token( + x_telegram_bot_api_secret_token: Optional[str] = Header(None), +) -> None: + """ + 驗證 Telegram Webhook Secret Token(ADR-094 P0-2 修) + + 若 TELEGRAM_WEBHOOK_SECRET 未配置(空字串)→ 跳過驗證(dev 環境相容)。 + 生產環境必須配置此 secret,與 setWebhook 呼叫時的 secret_token 相同。 + """ + expected = getattr(settings, "TELEGRAM_WEBHOOK_SECRET", "") + if not expected: + # dev 環境:未配置 secret → 跳過驗證 + return + if x_telegram_bot_api_secret_token != expected: + logger.warning( + "telegram_webhook_invalid_secret", + provided=bool(x_telegram_bot_api_secret_token), + ) + raise HTTPException(status_code=403, detail="Invalid webhook secret") + + +@router.post("/webhook", dependencies=[Depends(_verify_secret_token)]) +async def telegram_webhook(request: Request) -> dict: + """ + 接收 Telegram Bot API webhook update。 + + 目前:記錄 update 類型 + 回傳 200 OK。 + WS4 Hermes NL:在此呼叫 Hermes NL router 處理自然語言指令。 + + Telegram Bot API 要求此端點在 1 秒內回傳 200,耗時操作須非同步排程。 + """ + body = await request.json() + + if "message" in body: + update_type = "message" + elif "callback_query" in body: + update_type = "callback_query" + elif "edited_message" in body: + update_type = "edited_message" + elif "channel_post" in body: + update_type = "channel_post" + else: + update_type = "other" + + logger.info( + "telegram_webhook_received", + update_type=update_type, + update_id=body.get("update_id"), + ) + + # WS4: 在此呼叫 Hermes NL router + # hermes_router.dispatch(body) + + return {"ok": True} diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index db4c7705..ea6e7c31 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -455,6 +455,12 @@ class Settings(BaseSettings): default="", description="HMAC secret for webhook signature verification", ) + # 2026-04-24 Claude Sonnet 4.6 (ADR-094): Telegram Webhook Secret Token + # 與 setWebhook API 呼叫時的 secret_token 相同;空字串 → dev 環境跳過驗證 + TELEGRAM_WEBHOOK_SECRET: str = Field( + default="", + description="Telegram Webhook Secret Token(setWebhook 設定的同一值)", + ) WEBHOOK_NONCE_TTL: int = Field( default=300, description="Nonce TTL in seconds for replay attack prevention", diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 1ad6a07c..a5d1555d 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -68,6 +68,7 @@ from src.api.v1 import monitoring as monitoring_v1 # 2026-04-03: 監控工具 from src.api.v1 import notifications as notifications_v1 # 2026-04-10: 通知頻道狀態 from src.api.v1 import stats as stats_v1 # Phase 6.5: Statistics Analytics from src.api.v1 import telegram as telegram_v1 # Phase 5.4: Telegram Gateway +from src.api.v1 import telegram_webhook as telegram_webhook_v1 # ADR-094: Webhook入口 from src.api.v1 import terminal as terminal_v1 # Phase 19.1: Omni-Terminal SSE from src.api.v1 import timeline as timeline_v1 from src.api.v1 import webhooks as webhooks_v1 @@ -719,6 +720,9 @@ app.include_router( app.include_router( telegram_v1.router, prefix="/api/v1", tags=["Telegram Gateway"] ) # Phase 5.4 +app.include_router( + telegram_webhook_v1.router, prefix="/api/v1", tags=["Telegram Webhook"] +) # ADR-094: Webhook 入口(WS4 Hermes NL 預留) app.include_router( metrics_v1.router, prefix="/api/v1", tags=["Gold Metrics"] ) # Phase 7: 真實血脈 diff --git a/apps/api/src/services/security_interceptor.py b/apps/api/src/services/security_interceptor.py index 7d9cda7c..a5cd9ecd 100644 --- a/apps/api/src/services/security_interceptor.py +++ b/apps/api/src/services/security_interceptor.py @@ -317,6 +317,36 @@ class TelegramSecurityInterceptor: if not self._initialized: await self.initialize() + # ======================================================================= + # Step 0: ADR-093 Bound User Check(群組 CSRF 防護) + # 若 Redis 中存有 cb_bind:{nonce},則嚴格比對 user_id; + # 若 key 不存在(舊格式 nonce 或 Redis 暫時不可用)→ 跳過,繼續走 whitelist。 + # 2026-04-24 Claude Sonnet 4.6 (ADR-093 WS3) + # ======================================================================= + if nonce: + try: + redis = self._nonce_store._redis_client + if redis is not None and self._nonce_store._use_redis: + bind_key = f"cb_bind:{nonce}" + bound_raw = await redis.get(bind_key) + if bound_raw is not None: + bound_id = int(bound_raw) + if user_id != bound_id: + logger.warning( + "telegram_callback_rejected_wrong_user", + user_id=user_id, + bound_user_id=bound_id, + callback_id=callback_id, + ) + raise UserNotWhitelistedError( + f"User {user_id} not bound to this approval (bound={bound_id})" + ) + except UserNotWhitelistedError: + raise + except Exception as exc: + # Redis 暫時不可用 → 降級繼續走 whitelist + logger.warning("callback_bound_check_failed", error=str(exc)) + # ======================================================================= # Step 1: 白名單驗證 # ======================================================================= @@ -391,6 +421,33 @@ class TelegramSecurityInterceptor: nonce=nonce, ) + async def bind_callback_user(self, nonce: str, user_id: int) -> None: + """ + 非同步綁定 callback nonce 到指定 user_id(ADR-093 群組 CSRF 防護) + + 在 SRE 群組場景中,為指定人員綁定此 nonce, + handler 驗證時會確認 caller == bound_user_id。 + Redis 不可用時優雅降級(繼續走 whitelist check)。 + + Args: + nonce: generate_callback_nonce 產生的 4-part nonce + user_id: 被綁定的 Telegram user_id(僅此人可點按) + """ + # 2026-04-24 Claude Sonnet 4.6 (ADR-093 WS3): bound user binding + try: + redis = self._nonce_store._redis_client + if redis is not None and self._nonce_store._use_redis: + bind_key = f"cb_bind:{nonce}" + await redis.setex(bind_key, 172800, str(user_id)) # TTL=48h + logger.debug( + "callback_user_bound", + user_id=user_id, + nonce_prefix=nonce[:16], + ) + except Exception as exc: + # Redis unavailable → 降級無 binding,依然走 whitelist check + logger.warning("callback_user_bind_failed", error=str(exc)) + def generate_callback_nonce(self, approval_id: str, action: str) -> str: """ 產生 Callback Nonce (嵌入到 callback_data)