fix(awooop): Phase 2 第二批 P0 安全強化 + Redis key 命名空間修正
## P0-05 Callback Nonce 防偽造(ADR-116)
- security_interceptor.py:generate_callback_nonce() 新增 HMAC-SHA256[:16] 附加
- 新 5-part 格式:{action}:{short_id}:{ts}:{rand}:{hmac16}
- CALLBACK_HMAC_SECRET 未設定時降級 warning(向後相容)
- security_interceptor.py:parse_callback_data() 新增 5-part 分支 + HMAC 驗證
- config.py:新增 CALLBACK_HMAC_SECRET: str = Field(default="")
## P0-06 Webhook HMAC Replay 防護(ADR-116)
- security_interceptor.py:新增 check_webhook_nonce()(Service 層,get_redis 在此層合法)
- webhooks.py:verify_webhook_signature() 新增兩個可選 Header
- X-Webhook-Timestamp:±300s 窗口驗證(若提供)
- X-Webhook-Nonce:呼叫 check_webhook_nonce()(Redis NX dedup,fail open)
- 移除直接 get_redis import(leWOOOgo 積木化修正)
## P0-11 ollama:current_primary Redis key 遷移 Phase A(ADR-110)
- ollama_auto_recovery.py:_REDIS_PRIMARY_KEY = "platform:ollama:current_primary"
- 雙寫舊 key "ollama:current_primary"(Phase A 30 天)
- 讀取以新 key 為主,fallback 舊 key
## P0-12 consensus Redis key 加 project namespace Phase A
- consensus_engine.py:新增 _consensus_key() / _consensus_legacy_key() helper
- 新 key:{project_id}:consensus:{consensus_id}
- project_id=None 時 fallback __platform__:consensus:{consensus_id}
- Phase A 雙寫 + fallback 讀取,現有呼叫方零修改
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ Features:
|
||||
- 過期的 Nonce 自動清除
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, runtime_checkable
|
||||
@@ -134,6 +136,34 @@ class NonceStore:
|
||||
logger.debug("nonce_cleanup", removed_count=len(expired))
|
||||
|
||||
|
||||
async def check_webhook_nonce(nonce: str, ttl: int = 600) -> bool:
|
||||
"""
|
||||
Webhook replay 防護:用 Redis NX 記錄 nonce(TTL=600s),重複使用回傳 False。
|
||||
|
||||
Service 層 helper,供 Router 層(webhooks.py)呼叫;禁止 Router 直接用 get_redis。
|
||||
Redis 不可用時 fail open(回傳 True + 記錄 warning)。
|
||||
|
||||
P0-06 修正(ADR-116,2026-05-04 ogt + Claude Sonnet 4.6)
|
||||
"""
|
||||
from src.core.redis_client import get_redis
|
||||
nonce_key = f"webhook:nonce:{nonce}"
|
||||
try:
|
||||
redis = get_redis()
|
||||
stored = await redis.set(nonce_key, "1", nx=True, ex=ttl)
|
||||
if not stored:
|
||||
logger.warning("webhook_nonce_replay_detected", nonce_prefix=nonce[:16] + "...")
|
||||
return False
|
||||
logger.debug("webhook_nonce_registered", nonce_key=nonce_key)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"webhook_nonce_redis_unavailable",
|
||||
error=str(exc),
|
||||
note="fail open: request allowed despite Redis unavailability",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Telegram Security Interceptor
|
||||
# =============================================================================
|
||||
@@ -478,13 +508,30 @@ class TelegramSecurityInterceptor:
|
||||
# Not a valid UUID (e.g. legacy format) — use as-is, may exceed limit but won't crash
|
||||
short_id = approval_id
|
||||
|
||||
nonce = f"{action}:{short_id}:{timestamp}:{random_part}"
|
||||
nonce_body = f"{action}:{short_id}:{timestamp}:{random_part}"
|
||||
|
||||
# ADR-116 P0-05: 附加 HMAC-SHA256[:16] 防偽造
|
||||
# 2026-05-04 Claude Sonnet 4.6 (ADR-116): 若 CALLBACK_HMAC_SECRET 未設定則 warning + 降級
|
||||
if settings.CALLBACK_HMAC_SECRET:
|
||||
hmac_hex = hmac.new(
|
||||
settings.CALLBACK_HMAC_SECRET.encode(),
|
||||
nonce_body.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
nonce = f"{nonce_body}:{hmac_hex[:16]}"
|
||||
else:
|
||||
logger.warning(
|
||||
"callback_hmac_secret_missing",
|
||||
note="CALLBACK_HMAC_SECRET not configured; nonce generated without HMAC (transition mode)",
|
||||
)
|
||||
nonce = nonce_body
|
||||
|
||||
logger.debug(
|
||||
"callback_nonce_generated",
|
||||
approval_id=approval_id,
|
||||
action=action,
|
||||
nonce_len=len(nonce.encode()),
|
||||
hmac_appended=bool(settings.CALLBACK_HMAC_SECRET),
|
||||
)
|
||||
|
||||
return nonce
|
||||
@@ -517,7 +564,32 @@ class TelegramSecurityInterceptor:
|
||||
"is_info_action": True,
|
||||
}
|
||||
|
||||
if len(parts) != 4:
|
||||
# ADR-116 P0-05: 支援 5-part 格式(含 HMAC)
|
||||
# 2026-05-04 Claude Sonnet 4.6 (ADR-116): {action}:{short_id}:{ts}:{rand}:{hmac16}
|
||||
if len(parts) == 5:
|
||||
# 5-part:驗證 HMAC,然後還原成 4-part 格式繼續解析
|
||||
embedded_hmac = parts[4]
|
||||
nonce_body = ":".join(parts[:4])
|
||||
if settings.CALLBACK_HMAC_SECRET:
|
||||
expected_hmac = hmac.new(
|
||||
settings.CALLBACK_HMAC_SECRET.encode(),
|
||||
nonce_body.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()[:16]
|
||||
if not hmac.compare_digest(embedded_hmac, expected_hmac):
|
||||
logger.warning(
|
||||
"callback_nonce_hmac_mismatch",
|
||||
nonce_prefix=callback_data[:20] + "...",
|
||||
)
|
||||
raise ValueError(f"Callback nonce HMAC verification failed")
|
||||
else:
|
||||
logger.warning(
|
||||
"callback_hmac_secret_missing",
|
||||
note="CALLBACK_HMAC_SECRET not configured; skipping nonce HMAC verification (transition mode)",
|
||||
)
|
||||
# 以 4-part nonce_body 繼續解析(以下邏輯共用)
|
||||
parts = parts[:4]
|
||||
elif len(parts) != 4:
|
||||
raise ValueError(f"Invalid callback_data format: {callback_data}")
|
||||
|
||||
import base64, uuid as _uuid
|
||||
|
||||
Reference in New Issue
Block a user