# 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）
