# S3 掃描發現：jedi-iam 第二因子與人機驗證（MFA + Turnstile）

> **掃描日期**：2026-09-05
> **掃描版本**：jedi-python-package `feature/FR-075` @ `67cb075`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin v0.10.2.3，`effort: low`
> **範圍**：`jedi-iam/jedi_iam/mfa`、`jedi-iam/jedi_iam/turnstile`，共 34 個受版控檔案
> **狀態**：✅ **已經過工具驗證面板完整審查**（單一研究員掃完整個範圍 → 3 人面板逐條投票）
> **裁決狀態**：⬜ 全部待裁決

## 掃描執行摘要

- **agent 派出/回報**：研究員 2（1 主掃描員覆蓋全部 34 檔＋panel 內部另需要一次補充查證，回報 2/2）＋面板 9 票（3 個候選 × 3 票）全數投完
- **面板結果**：候選 3 條 → **2 條通過**（F1 3/3、F2 2/3）、**1 條被面板一致否決**（非密碼學安全亂數，0/3）
- **驗證面板狀態**：`verified`（`render_report.py` 蓋出的官方戳記，非自行宣稱）
- **產出檔案**：`CLAUDE-SECURITY-20260905-064153-s3/`（`CLAUDE-SECURITY-RESULTS.md` / `.jsonl` / `.sarif` / revision stamp），該目錄有自己的 `.gitignore` 排除版控，本檔為手動摘要進 repo 供裁決追蹤

## 與第一輪試掃（batch1）的對照

第一輪試掃在同一塊範圍（OTP + Turnstile）找到過 H-6（OTP 端點無認證無次數限制）與 M-4（Turnstile fallback 到永遠通過的測試金鑰）。這一輪跑完整條含驗證面板後：

- **H-6 → 本輪 F1，確認屬實，面板 3/3 一致通過**。這次額外查清楚了：密碼路徑（`LoginDomainService.is_user_locked_within_limit`）有 3 次/15 分鐘的鎖定機制，但 MFA 驗證路徑完全沒有對應控制——兩條路徑走的是不同程式碼，不是同一套邏輯漏鎖一處。FE 有前端計數器（`OtpVerification.vue` errorCount≥5）但直接打 API 可繞過。
- **M-4 → 本輪 F2，確認程式碼行為屬實，面板 2/3 通過（confidence 降為 medium）**。這輪多查出一個重要限制：**目前出貨的安裝路徑（installer）預設把 `TURNSTILE_ENABLED` 設為 `false`**，且所有已 commit 的 env 檔在 Turnstile 開啟時都有明確設定金鑰（即使是測試金鑰），所以**這條路徑在目前實際部署設定下不會被觸發**。程式碼本身仍是不設防的，但反映的是「未來或非標準部署方式的風險」，不是「現在正在裸奔」。
- **舊清單的 L-1（OTP 用非密碼學安全亂數）→ 本輪重新被研究員提出，但面板 3 票一致否決**。三位驗證者都指出：舉報者自己承認攻擊機制是「理論上」需要從同一 process 觀察到大量 `random` 模組的其他輸出才能重建亂數狀態，而整個 codebase grep 後找不到任何這樣的洩漏管道（唯一另一處用到 `random` 的是從未被實例化的死碼）。這是本輪跑完整驗證面板才篩掉的假陽性，印證了母卡強調的「跑完整條含驗證面板」的價值。

## HIGH 級（1 條）

### F1 OTP／MFA 驗證端點無次數限制，可暴力猜出 6 位數驗證碼

- **檔案**：`jedi-iam/jedi_iam/mfa/infra/email/adapter/email_adapter.py:14` `EmailAdapter.verify`（TOTP 路徑 `jedi-iam/jedi_iam/mfa/infra/totp/adapter/totp_adapter.py:11-18` 有相同缺口）
- **問題**：驗證碼比對只是一行 `if stored_code and stored_code == code:`，沒有任何次數限制、退避、鎖定機制。呼叫它的 `MfaVerifyRoute.post`（`jedi-iam/jedi_iam/api/routes/otp_route.py:86-114`）刻意不掛認證（因為這是登入第二關，此時還沒 access token），驗證通過就直接發出完整的 access/refresh JWT。
- **實查**：✅ 屬實，面板 3/3 一致通過。已對照確認密碼路徑確實有獨立的 3 次/15 分鐘鎖定機制（`login_domain_service.py:61-76`），但這套機制完全沒有被 MFA 驗證路徑呼叫——是兩條分開的程式碼路徑，不是共用邏輯漏設定。唯一存在的節流是 `/otp-resend` 的 60 秒冷卻，管的是「重發驗證碼」不是「驗證嘗試次數」，對暴力猜碼沒有任何阻擋作用。BE 與 FE 兩邊都查過沒有 nginx/WAF/Flask-Limiter 之類的外部速率限制。
- **影響**：攻擊者只要已經拿到受害者的密碼（外洩資料庫、釣魚、密碼重用），完全不需要受害者的 email 或驗證器 app，就能在驗證碼約 300 秒的 Redis TTL 內暴力猜完六位數（100 萬組合），拿到完整 JWT 直接取得帳號控制權，等於 MFA 這道第二關防線形同虛設。
- **前提**：① 攻擊者已持有目標帳號的有效密碼；② 沒有外部速率限制擋在 API 前面（目前確認沒有）；③ 該帳號啟用了 MFA。
- **建議修法**：比照密碼路徑的鎖定機制，在 `EmailAdapter.verify` / `ToptAdapter.verify`（或上一層的 `EmailMfaService`/`TotpMfaService`）加上 per-uid 失敗次數計數＋鎖定；每組驗證碼設定總嘗試上限（例如 5 次），超過即讓該碼失效。

## MEDIUM 級（1 條）

### F2 Turnstile 人機驗證在忘記設定密鑰時會靜默永遠通過（fail-open）

- **檔案**：`jedi-iam/jedi_iam/turnstile/verifier.py:38` `_resolve_secret_key`
- **問題**：當設定讀不到 `TURNSTILE_SECRET_KEY` 時，`_resolve_secret_key()` 會 fallback 到 Cloudflare 官方文件公開的「永遠通過」測試金鑰。這個值送去真正的 Cloudflare `siteverify` 端點，不管前端送了什麼 token（甚至空值）都會回傳成功。呼叫端 `TurnstileCaptchaVerifier.verify`（`compliance-manager-be/app/auth/service/login_adapters.py:35-41`）只有在明確設 `TURNSTILE_ENABLED=false` 時才跳過，該旗標預設是 `true`（`config/config.py:286`）。fallback 發生時完全沒有 log 警告。
- **實查**：✅ 程式碼行為屬實，面板 2/3 通過（confidence 降為 medium，非 3/3 才有的 high）。**但這輪額外查清楚了一個重要限制，第一輪沒查到**：目前出貨的安裝路徑（`scripts/installer/install.sh:1408`）**預設把 `TURNSTILE_ENABLED` 寫死為 `false`**，而且目前所有已 commit 的 env 檔（`.env` / `.env.sample` / `.env.test`）在 Turnstile 開啟的情況下都有明確設定金鑰（即使是文件記載的測試金鑰）。也就是說**目前實際的部署設定不會踩到這條路徑**，一位驗證者因此投了否決票——但另外兩位仍認為這是一段真實、沒有任何防護的程式碼行為，未來任何偏離目前 installer / env 檔慣例的部署方式（例如雲端 SaaS 安裝流程）都可能踩到，且踩到時完全沒有告警訊號，所以維持 MEDIUM 而非直接下架。
- **影響**：若真的發生（開了 Turnstile 但沒設金鑰），登入頁的人機驗證形同虛設，任何自動化程式都能無阻礙地打登入 API（帳號鎖定機制仍是獨立的第二道防線，不受此影響）。
- **前提**：`TURNSTILE_ENABLED=true`（預設值）且 `TURNSTILE_SECRET_KEY` 完全未設定——依目前 installer 與 env 檔慣例，這不是預設會發生的狀態。
- **建議修法**：改成 fail closed——只在明確的 dev/debug 旗標同時為真時才允許用測試金鑰頂替，並在每次走到這個 fallback 分支時印出顯眼、會重複出現的警告，讓誤設定不再是靜默的。

## 這份清單的可信度

本輪掃描**完整跑完含驗證面板**，`render_report.py` 蓋出的 `verification.status` 為 `verified`。範圍限定在 `mfa/` 與 `turnstile/` 兩個目錄共 34 個受版控檔案，範圍外（jedi-iam 其他模組、其他 jedi-* 套件）本輪未檢查，不在此份清單的宣稱範圍內。
