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) 現況:8 條發現全數已修,1.21.0 出貨——F1/F2/F6 → CM-1560(TLS 項退回重做一次)、F3/F5 → CM-1561(改裁關閉入口、程式碼保留)、F4 → CM-1562、F7 → CM-1563、F8 → CM-1564。早期卡 09-21 作廢,總表狀態見 docs/security-report/M13-iam.md 第 2~5、8、9 條

§1

掃了什麼

  • 範圍: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
§2

結果

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,非完全未認證即可觸發)。
§3

這一輪的收穫

對照第一輪不完整掃描(母卡 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 完全不驗密碼)相比, 攻擊面被認為次要而未被獨立提出,而非掃描遺漏了這個檔案。此條列為待後續人工複核項目,不影響本輪 「已完整跑完並經驗證」的結論。

§4

這份報告的可信度

  • ✅ 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 被實際執行驗證