# S1 掃描結果：jedi-iam 認證與外部身分綁定

> **掃描日期**：2026-09-05
> **掃描版本**：jedi-python-package `feature/FR-075` @ `67cb075`
> **工具**：Claude Code `claude-security` plugin v0.10.2.3（`claude-security:scan` workflow）
> **狀態**：✅ **已完整跑完並經驗證面板流程**（`verification.status: verified`）
> **對應卡片**：CM-1547（母卡 CM-1546）

## 掃了什麼

- 範圍：`jedi-iam/jedi_iam/infra/adapter/`（password / ldap / google 三個認證 adapter）+
  `jedi-iam/jedi_iam/app/service/login_service.py` + `jedi-iam/jedi_iam/app/service/user_auth_provider_service.py`
  （外部身分綁定）+ `jedi-iam/jedi_iam/domain/service/login_domain_service.py`
- 檔數：11（tracked files 實測，與卡片指定一致）
- effort：`low`（single-researcher shape，非 medium 的元件矩陣）
- focus：`attack-surface`
- 研究員：這一輪不順——第一次派出的研究員 agent 直接失敗（inference gateway 500 錯誤，
  非本次範圍或程式碼問題），workflow 自動重試，第二次也未見成功結果就再度重試，
  第三次嘗試才拿到 8 條候選發現。最終 coverage 記錄 `researchersDispatched: 2`／`researchersReturned: 2`
  （第一次失敗的嘗試不計入完成數）。另有一支必跑的 secrets 掃描 agent（因 `focus` 有設定），空手而回。
- 面板：8 條候選、24 票（3 位獨立投票者 × 8 條），**全部 3-0 一致判定為 TRUE_POSITIVE**

## 結果

**8 條發現存活：5 條 HIGH、3 條 MEDIUM。**

### HIGH（5 條）

#### F1 LDAP filter injection（filter 未跳脫）

- **檔案**：`jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/ldap/ldap_adapter.py:144` `get_open_ldap_user_info()`（AD 分支同構在 `:185`）
- **CWE**：CWE-90
- **問題**：`/login` 端點傳入的 username 未經任何跳脫直接內插進 LDAP search filter（`f'(cn={username})'`）。
- **影響**：攻擊者可用 filter metacharacter（如 `*`）改寫查詢範圍，配上下方 F2 的匿名 bind，
  可匹配到目錄內任意條目、取得其 `entryUUID`，若該 entryUUID 有綁定紀錄即可無密碼登入該帳號。
- **觸發前提**：LDAP provider 已設定（非預設）；要達到最大殺傷力需 OpenLDAP 模式 + 目錄允許匿名 bind；
  目標帳號已有 LDAP provider 綁定。
- **建議修法**：用 `ldap3.utils.conv.escape_filter_chars` 處理所有目錄輸入；另在輸入驗證層限制帳號可用字元集。
- **面板核對**：3/3 TRUE_POSITIVE，驗證者逐一追過 route → orchestrator → adapter 的完整呼叫鏈，
  確認未經 `@auth_required` 保護、無其他跳脫層。

#### F2 OpenLDAP 登入路徑從未驗證密碼（匿名 bind）

- **檔案**：同上 `:67` `connect()`
- **CWE**：CWE-287
- **問題**：`is_ad=False`（OpenLDAP 模式）時，`Connection()` 的 `user`／`password` 皆被強制設為 `None`，
  等於做匿名 bind。使用者輸入的密碼從未真正送到目錄伺服器比對。
- **影響**：任何 OpenLDAP 綁定帳號，只要目錄允許匿名 bind，攻擊者帶任意密碼值皆可登入成功。
- **觸發前提**：LDAP provider 為 OpenLDAP 模式；目錄伺服器允許匿名 bind；目標帳號已有 jedi-iam LDAP 綁定。
- **建議修法**：OpenLDAP 模式下先用服務帳號（或匿名）bind 解出使用者 DN，
  再用該 DN 與使用者輸入的密碼做第二次 bind，bind 成功才算通過。
- **面板核對**：3/3 TRUE_POSITIVE。

#### F3 Google 登入 adapter 完全不驗密碼／token 即發身分

- **檔案**：`jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/google_auth/google_auth_adapter.py:54` `authenticate()`
- **CWE**：CWE-287
- **問題**：`authenticate(username, password)` 方法本體從未使用 `password` 參數，只憑
  `provider_user_id=username` 查 `user_auth_providers` 有無綁定紀錄，查到即回傳該使用者、視為登入成功。
  此路徑確實可達：`auth_factory.py` 無條件把 `AuthenticateType.GOOGLE` 映射到此 adapter，
  `LoginService.process_login()` 會依 `provider='google'` 派送過來。
- **影響**：任何知道（或能自行綁定，見 F4／F5）某帳號 Google `provider_user_id` 的人，
  帶任意密碼值即可登入該帳號。
- **觸發前提**：GOOGLE provider 在 factory 中預設啟用（目前無條件開啟）；
  攻擊者知道或能自行綁定一個 provider_user_id 到目標帳號。
- **建議修法**：OAuth2 未真正實作前應從 factory 移除 GOOGLE；實作時 provider_user_id
  只能從已驗證的 Google ID token 的 `sub` claim 取得，不可信任任何 client 端提供的字串。
- **面板核對**：3/3 TRUE_POSITIVE。

#### F4 外部身分綁定完全不驗證 caller 是否為本人

- **檔案**：`jedi-iam/jedi_iam/app/service/user_auth_provider_service.py:99` `register_ad_user()`
  （`register_ldap_user`、`register_google_user` 同構）
- **CWE**：CWE-862
- **問題**：`register_ad_user`／`register_ldap_user`／`register_google_user` 皆收攻擊者可自訂的
  `user_uid` 參數，只檢查「目標使用者存在」與「該外部身分尚未被綁走」兩項，**從未比對 caller 是否即為
  `user_uid` 本人、也未檢查是否具備管理他人身分的 capability**。`get_user_context().login_name`
  雖有取用，但僅用來填 `created_user`／`updated_user` 稽核欄位。
- **影響**：任何已認證使用者皆可把自己的 AD／LDAP／Google 身分（或攻擊者任選的字串，見 F5）
  綁到**任意其他人**的帳號上，之後用該身分登入即完全取得受害者身分（含高權限帳號）。
- **觸發前提**：攻擊者已持有任一有效登入 session；已知或可枚舉受害者的 `user_uid`。
- **建議修法**：強制 `user_uid` 必須等於從 JWT 解出的 caller 本人，除非 caller 明確持有
  「管理他人身分綁定」的 capability；此檢查要落在 app service 層,不能只靠 UI 不顯示入口。
- **面板核對**：3/3 TRUE_POSITIVE。驗證者確認對應 route（`api/user_auth_provider/routes/user_auth_provider_route.py`）
  只掛 `@jwt_required()`，無 self-check（對照有做 self-check 的 `/user-profile/<uid>` 端點）。

#### F5 Google 綁定把攻擊者任意選擇的值當成可信身分識別碼

- **檔案**：同上 `:142` `register_google_user()`
- **CWE**：CWE-287
- **問題**：`register_google_user` 把 request payload 裡的 `third_party_password` 原封不動存成
  `provider_user_id`，沒有呼叫任何 Google API 做驗證。這個值之後就是 F3 登入時唯一比對的憑證。
- **影響**：與 F4（缺 ownership 檢查）、F3（Google adapter 不驗密碼）疊加後，
  形成一條完整的「只需一個低權限已登入 session」→「自選密語」→「登入成為任意受害者」的
  帳號完全接管鏈。
- **觸發前提**：攻擊者已持有任一有效 session；已知或可枚舉受害者 `user_uid`；
  該 `user_uid` 尚無衝突的既有 Google 綁定。
- **建議修法**：不可用 client 提供的識別碼實作 Google 綁定；必須要求已驗證的 Google OAuth2 ID token，
  provider_user_id 只能從其驗證過的 `sub` claim 取得。
- **面板核對**：3/3 TRUE_POSITIVE，驗證者串起 F3／F4／F5 三條的完整攻擊鏈並確認每一步都可達、無攔阻。

### MEDIUM（3 條）

#### F6 LDAPS／STARTTLS 連線從不驗證伺服器憑證

- **檔案**：`jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/ldap/ldap_adapter.py:49` `_get_ldap_server()`
- **CWE**：CWE-295
- **問題**：`ldap3.Server()` 建構時未傳入 `tls=` 參數，ldap3 預設會建一個 `validate=ssl.CERT_NONE`
  的 `Tls()` 物件，即使管理者設定了 SSL 或 STARTTLS 加密，憑證與 hostname 仍完全不驗。
- **影響**：具中間人網路位置的攻擊者可冒充目錄伺服器，竊取／中繼 bind 過程中的 NTLM 或 simple-bind
  憑證材料（含 F8 提到的服務帳號密碼），或偽造搜尋結果強迫登入為任意帳號。
- **觸發前提**：`LDAP_ENCRYPTION` 設為 SSL 或 STARTTLS（管理者試圖強化連線）；
  攻擊者具備應用與目錄伺服器之間的 MITM 網路位置。
- **建議修法**：明確建構 `Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=...)` 並傳給 `Server(..., tls=tls)`；
  無法驗證憑證時應 fail closed。
- **面板核對**：3/3 TRUE_POSITIVE，驗證者讀過 vendored ldap3 原始碼確認預設行為。

#### F7 帳號不存在／密碼錯誤回不同錯誤碼，可枚舉帳號

- **檔案**：`jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/password/password_adapter.py:27` `authenticate()`
- **CWE**：CWE-203
- **問題**：`verify_user_exist_by_login_name` 對不存在的 login name 拋 `NotFound(AUTH_USER_NOT_FOUND)`
  （HTTP 404），而密碼錯誤時 `is_password_correct` 拋 `UnauthorizedError(LOGIN_AUTHENTICATION_FAILED)`
  （HTTP 401）。兩者在密碼比對發生前就已可由 HTTP 狀態碼與 error_code 區分。
- **影響**：未認證攻擊者可對候選帳號清單逐一嘗試，藉回應差異枚舉出系統內真實存在的帳號，
  用於後續針對性密碼噴灑或釣魚。
- **觸發前提**：僅需能連上 `/login` 端點，無其他前提。
- **建議修法**：帳號不存在與密碼錯誤一律回相同的狀態碼與 error_code，且時間差要拉平
  （帳號不存在時仍執行一次假的 bcrypt 比對）。
- **面板核對**：3/3 TRUE_POSITIVE，驗證者並指出主專案已對「忘記密碼」端點做過同類遮罩
  （`change_password_route.py` 的 `forget_password_masks_missing_email`），
  但登入端點未套用等價保護，確認並非刻意設計而是遺漏。

#### F8 LDAP 連線測試把已存的服務帳號密碼送往呼叫端指定的主機

- **檔案**：同上 `:87` `test_connect()`
- **CWE**：CWE-918
- **問題**：`test_connect()` 用 `self.config.LDAP_USER`／`self.config.LDAP_SECRET`（已存在系統設定裡的
  服務帳號密碼）去 bind `self.server`；而 `self.server` 的來源（`LDAP_SERVER`）可由呼叫端在
  connect-test 請求中指定——若請求省略 `secret` 但帶了 `user`，`LdapService.init_config`
  會沿用已存的 secret，等於把production 服務帳號密碼送到呼叫端自訂的任意主機。
- **影響**：持有 `ldap-config.create` capability（比完整系統管理員窄的一項權限）的操作者，
  可把連線測試目標指向自己控制的主機，攔截到本應只給合法目錄伺服器的服務帳號憑證，
  用於橫向移動或憑證重用。
- **觸發前提**：呼叫者已持有 `ldap-config.create` capability；系統設定內已有存好的 LDAP 服務帳號憑證。
- **建議修法**：目標主機／port 與目前已存值不同時，強制要求重新輸入 secret；
  或直接限制連線測試目標只能是目前已設定的主機。
- **面板核對**：3/3 TRUE_POSITIVE，信心度標為 MEDIUM（研究員與驗證者皆標註前提需要一項真實但較窄的
  capability，非完全未認證即可觸發）。

## 這一輪的收穫

對照第一輪不完整掃描（母卡 CM-1546 提到、`scan-findings-batch1.md` 記錄）在這一塊找到過的：
H-1（OpenLDAP 匿名 bind）、H-2／H-7（LDAP filter injection）、H-3（TLS 不驗憑證）、
H-4（Google 不驗憑證）、H-5（任何人可綁身分到他人帳號）、M-5（連線測試外洩服務帳號密碼）、
M-6（明文 bind）、M-9（帳號枚舉）。

**本輪重新找到並經面板驗證的**：H-1→F2、H-2/H-7→F1、H-3→F6、H-4→F3、H-5→F4（並額外拆出 F5 這條
更精確的子問題）、M-5→F8、M-9→F7。**全部第一輪的舊發現在本輪都獲得重新確認，且這次多了驗證面板的
3-0 一致背書**（第一輪僅靠人工開檔核對，未經工具面板）。

**本輪沒有重新找到的**：M-6（非 SSL／STARTTLS 時明文 bind）。核對範圍：`connect()` 方法（F2 所在函式）
本身確實只在 `is_ad` 且有 `LDAP_ENCRYPTION` 設定時才走加密路徑,若管理者把 `LDAP_ENCRYPTION` 設為
`none`,理論上仍會明文傳輸。這條在本次範圍內（`ldap_adapter.py` 也在掃描範圍），研究員與面板均未把它
單獨列為候選——**推測是因為它的觸發前提（管理者主動關閉加密）與 F2（匿名 bind 完全不驗密碼）相比，
攻擊面被認為次要而未被獨立提出**，而非掃描遺漏了這個檔案。此條列為待後續人工複核項目，不影響本輪
「已完整跑完並經驗證」的結論。

## 這份報告的可信度

- ✅ effort=low 但**單一研究員讀整個 11 檔範圍**（全讀非抽樣），最終拿到結果的那次嘗試涵蓋完整範圍
- ✅ 8 條候選全數經三人面板投票，**全部 3-0 一致確認**，無分歧、無需仲裁
- ✅ 每位驗證者的 reasoning 都追過真實呼叫鏈（route → orchestrator/service → adapter），
  確認端點未受認證保護或守門不足，而非僅憑靜態程式碼片段推論
- ✅ workflow 最終正常收尾，render_report.py stamp 標記 `verification.status: verified`
- ⚠️ 研究階段前兩次嘗試失敗（inference gateway 500 錯誤與重試），非本次範圍程式碼造成，
  但意味著實際只有「一位研究員完整讀過一次」而非兩位交叉核對（S2 那份 8 檔 0 發現報告
  是兩位研究員都完整讀過），**信心度略低於「雙研究員交叉」的形狀，但每條發現仍有三人驗證面板背書**
- ⚠️ low effort 沒有 inventory / threat-model / breadth-sweep 這些 medium 才有的階段，
  是比 medium 更輕的單次讀取式審查
- ⚠️ 這是讀程式碼做呼叫鏈推理的靜態審查，不是對正在跑的系統做真實滲透測試，
  沒有任何 exploit 被實際執行驗證
