# ADR-116: AwoooP Security Hardening **狀態**:Accepted **日期**:2026-05-03(台北) **決策者**:統帥 **範圍**:callback nonce 修補、webhook replay 防護、approval_token 規格、requires_approval 修補 **關聯**:ADR-112(contract governance)、ADR-114(approval flow) --- ## 背景 vuln-verifier 對 codebase 進行 PoC 驗證,確認三個安全漏洞真實存在: ### 漏洞 1:Callback Nonce 偽造(P0-05) - 位置:`apps/api/src/services/security_interceptor.py:451-490` - 問題:server nonce 的驗證邏輯不依賴 `server_secret`,攻擊者可在不知道 secret 的情況下構造通過驗證的 nonce - PoC:已確認可通過驗證 ### 漏洞 2:Webhook HMAC Replay 無防護(P0-06) - 位置:`apps/api/src/api/v1/webhooks.py:679-728` - 問題:webhook HMAC 驗證只驗 body hash,沒有 timestamp 或 nonce - PoC:截取合法 webhook request,重放到任意時間,仍通過驗證 ### 漏洞 3:requires_approval 由 LLM Output 決定(P0-04) - 位置:`apps/api/src/services/decision_manager.py`(approval 鏈) - 問題:`requires_approval` 欄位的值來自 LLM 的 response,攻擊者可透過 prompt injection 讓 LLM 輸出 `requires_approval=false`,繞過 approval 要求 - PoC:已確認可繞過 --- ## 決策 ### D1 — Callback Nonce 重設計 **修補前(不安全)**: ```python # server 端生成 nonce:僅用時間戳或隨機值,不含 secret server_nonce = hash(timestamp + random) # 驗證時:只比對 client 提供的 nonce == server_nonce(不驗 secret 參與) ``` **修補後(安全)**: ```python import hmac, hashlib, secrets def generate_callback_nonce(run_id: str, server_secret: str) -> str: """生成含 secret 的 nonce,防止偽造""" salt = secrets.token_hex(16) message = f"{run_id}:{salt}:{int(time.time())}" signature = hmac.new( server_secret.encode(), message.encode(), hashlib.sha256 ).hexdigest() return f"{salt}:{signature}" def verify_callback_nonce(nonce: str, run_id: str, server_secret: str, max_age_seconds: int = 300) -> bool: """驗證 nonce,含 server_secret 參與,拒絕超過 5 分鐘的 nonce""" try: salt, provided_sig = nonce.split(":", 1) # 重建 message(注意:時間驗證需要 timestamp 在 nonce 中) # 完整實作需在 nonce 中包含 timestamp ... except ValueError: return False # server_secret 從 K8s Secret 注入(settings.CALLBACK_NONCE_SECRET) # 禁止 hardcode ``` ### D2 — Webhook Timestamp + Nonce **修補方案**: 每個 webhook request 必須帶: 1. `X-Timestamp` header:Unix timestamp(秒) 2. `X-Nonce` header:16-byte random hex(防 replay) 驗證邏輯: ```python async def verify_webhook_security(request: Request, body: bytes) -> None: timestamp = request.headers.get("X-Timestamp") nonce = request.headers.get("X-Nonce") if not timestamp or not nonce: raise WebhookSecurityError("Missing security headers") # 1. Timestamp 驗證(±5 分鐘 window) ts = int(timestamp) if abs(time.time() - ts) > 300: raise WebhookSecurityError("Timestamp out of window") # 2. Nonce 去重(Redis NX,TTL 10 分鐘) nonce_key = f"platform:webhook:nonce:{nonce}" if not await redis.set(nonce_key, "1", nx=True, ex=600): raise WebhookSecurityError("Nonce already used (replay attack)") # 3. HMAC 驗證(原有邏輯,保留) # hmac_header = request.headers.get("X-Hub-Signature-256") # verify_hmac(body, hmac_header, settings.WEBHOOK_SECRET) ``` **對 Gitea / Alertmanager 的影響**: - Gitea webhook:支援 secret 但未必加 timestamp,需要在 Gitea 設定端更新 secret policy - Alertmanager:不支援自訂 header,例外處理:Alertmanager 請求允許 timestamp 寬鬆 window(改為 ±10 分鐘,但仍要 nonce) - 內部呼叫(K8s Job / internal services):使用 mutual TLS(mTLS)代替,不需要 timestamp/nonce ### D3 — requires_approval 改為 Policy-Derived **修補前(不安全)**: ```python # LLM response 中有 requires_approval 欄位,被直接採用 decision = llm_response.get("requires_approval", False) ``` **修補後(安全)**: ```python async def should_require_approval( project_id: str, agent_id: str, action_type: str, risk_level: str, effective_policy: EffectivePolicy ) -> bool: """ approval 要求從 policy contract 決定,禁止由 LLM output 決定。 LLM 的 'requires_approval' 欄位被忽略。 """ # 從 effective_policy 的 approval_gates 讀取 for gate in effective_policy.approval_gates: if gate.matches(action_type=action_type, risk_level=risk_level): return True return False ``` **規則**: - `action_type=auto_repair` + `risk_level >= HIGH` → 必須 approval - `action_type=knowledge_write` → 不需 approval(低風險) - `action_type=destructive_mcp_tool` → 必須 approval(不論 risk_level) - 以上規則寫在 policy contract,不在 LLM prompt 中 ### D4 — approval_token 規格(Phase 8) ``` 算法:HS256 簽章 Key:K8s Secret `awooop-approval-signing-key` Payload: iss: "awooop-platform" sub: "{project_id}:{run_id}" aud: "awooop-approval" jti: UUID(唯一 ID,存 Redis NX,TTL 15min) exp: now + 900(15 分鐘) iat: now approval_type: "human" | "system" decision_scope: [{tool_id}, ...] # 這個 token 授權哪些 tool 的執行 驗證(Phase 8 resume API): 1. HS256 簽章驗證 2. exp 未過期 3. Redis NX:SET awooop:approval:jti:{jti} "used" NX EX 900 → 若 SET 失敗 → E-APPROVAL-003(已使用) 4. sub 的 run_id 與 API path 的 run_id 一致 5. decision_scope 包含 run 要求執行的 tool ``` ### D5 — Secret 管理 所有 security-critical secret 必須從 K8s Secret 注入,禁止硬碼: | Secret 名稱 | K8s Secret Key | 用途 | |------------|---------------|------| | `CALLBACK_NONCE_SECRET` | `awooop-security/callback-nonce-secret` | callback nonce HMAC | | `WEBHOOK_SECRET` | 現有 `awoooi-secrets/webhook-secret` | webhook HMAC(已有)| | `APPROVAL_SIGNING_KEY` | `awooop-security/approval-signing-key` | approval_token HS256 | | `CONTRACT_SIGNING_KEY` | `awooop-security/contract-signing-key` | contract publish HMAC(ADR-112)| --- ## 實作優先序(Phase 2) 1. **PR-07a(立即)**:security_interceptor.py nonce 重設計 2. **PR-07b(立即)**:webhooks.py timestamp + nonce 加入 3. **PR-07c(立即)**:decision_manager.py requires_approval 改為 policy-derived 4. **Phase 4(approval_token)**:HS256 token 生成 + Phase 8 verify --- ## 後果 ### Benefits - 三個 PoC 確認的漏洞全部修補 - approval 決策權回到 policy(不再由 LLM 決定),防止 prompt injection 繞過 ### Costs - webhook caller 需要加 `X-Timestamp` + `X-Nonce` header(需要 Gitea/Alertmanager 設定調整) - callback nonce 需要額外 Redis 查詢(nonce 去重) ### Risks - K8s Secret rotation 時,正在驗證中的 nonce/token 可能失效 - 緩解:rotation 時短暫雙 key 驗證(新 key 生成後,舊 key 再維持 TTL 時間) --- ## 驗收標準 - [ ] vuln-verifier PoC 重跑:P0-05 nonce 偽造失敗 - [ ] vuln-verifier PoC 重跑:P0-06 webhook replay 失敗 - [ ] vuln-verifier PoC 重跑:P0-04 LLM 無法決定 requires_approval - [ ] Phase 8:approval_token 無 token resume 被拒絕(E-APPROVAL-001) ## 關聯 - ADR-114(approval flow,WAITING_APPROVAL state) - ADR-112(contract activation,approval workflow) - ADR-122(OWASP Agentic AI Top 10 — OAI-03 Excessive Agency、OAI-09 Access Control Bypass)