第一批掃描發現:jedi-common + jedi-iam

掃描日期:2026-09-05 掃描版本:jedi-python-package main @ 67cb075 工具:Claude Code claude-security plugin v0.10.2.3 狀態:⚠️ 未經工具的驗證面板審查(掃描在研究階段中止,見文末「這份清單的可信度」) 裁決狀態:⬜ 全部待裁決

§1

摘要

19 條獨立問題。7 條 HIGH(其中 5 條是可直接繞過登入的認證缺陷)、9 條 MEDIUM、3 條 LOW。

問題高度集中在 LDAP/AD 登入路徑(單一檔案 ldap_adapter.py 佔 7 條), 以及 RLS 租戶隔離的 fail-open 預設jedi_common/session/database/db.py)。

我已對全部 7 條 HIGH 與部分 MEDIUM 開檔核對過原始碼,核對結果記在每條的「實查」欄。


§2

HIGH 級(7 條)

H-1 OpenLDAP 登入從未驗證密碼

  • 檔案jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/ldap/ldap_adapter.py:67 connect()
  • 問題:非 AD 模式下,Connection()userpassword 都傳 None(那兩個參數有 if self.is_ad 條件), 所以走的是匿名 bind。之後只用 search 找到條目就當作登入成功,使用者輸入的密碼從頭到尾沒有跟目錄驗證過。
  • 實查:✅ 屬實。connect() 第 64-68 行 user=username if self.is_ad and username else None, 非 AD 分支確實不帶憑證;verify_user() 拿到 conn 後直接查使用者資訊就回傳。
  • 影響:只要目錄允許匿名 bind(常見預設),知道任一已綁定帳號名稱就能無密碼登入。
  • 前提:LDAP provider 設為 OpenLDAP 模式、目錄允許匿名 bind、該帳號有 user_auth_providers 綁定紀錄。
  • 建議修法:搜到 user DN 後,用該 DN 與使用者輸入的密碼做第二次 bind,bind 成功才算通過。

H-2 LDAP 搜尋條件直接串接使用者輸入(filter injection)

  • 檔案:同上 :144 get_open_ldap_user_info():185 get_ad_user_info()
  • 問題search_filter = f'(cn={username})',username 未做任何 escape。
  • 實查:✅ 屬實,兩處都是 f-string 直接內插。
  • 影響:與 H-1 疊加後最嚴重——OpenLDAP 分支既然匿名 bind,送 username=* 就讓 filter 變成 (cn=*) 匹配全目錄,取第一筆的 entryUUID 去對綁定表,等於任意帳號登入。
  • AD 分支的實際風險較低:NTLM bind 必須先成功,攻擊者得先擁有一個名字裡含 filter 特殊字元的網域帳號。
  • 建議修法:用 ldap3.utils.conv.escape_filter_chars 處理,並在序列化層限制帳號字元集。

H-3 LDAP/AD 的 TLS 完全不驗憑證

  • 檔案:同上 :49 _get_ldap_server()
  • 問題Server(...) 沒有傳 Tls 物件,ldap3 的預設是 validate=CERT_NONE, 等於設了 SSL/STARTTLS 卻不驗伺服器憑證與 hostname。
  • 實查:✅ 屬實。第 48 行 Server(server, port, use_ssl=..., get_info='ALL', connect_timeout=20),無 tls= 參數。
  • 影響:中間人可冒充目錄伺服器。OpenLDAP 模式下攻擊者能偽造搜尋結果直接做到登入繞過; AD 模式下可攔截服務帳號與使用者的 NTLM 材料做離線破解或轉發攻擊。
  • 建議修法:明確建構 Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=...) 並開啟 hostname 檢查,無法驗證時 fail closed。

H-4 Google 登入不驗任何憑證就發 session

  • 檔案jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/google_auth/google_auth_adapter.py:52
  • 問題authenticate() 只拿 username 去查 user_auth_providers 有沒有對應綁定,查到就回 UserDTO,密碼參數完全沒用到。
  • 實查:✅ 屬實,且可達——login_service.py:51 依 provider 派送 adapter, auth_factory.py:14AuthenticateType.GOOGLE: GoogleAuthAdapter 註冊,所以這條路徑是活的。
  • 影響:任何知道某人 google provider_user_id 的人,帶任意密碼即可登入該帳號。
  • 建議修法:OAuth2 未實作前先從 factory 拿掉 google;實作時要驗 Google 簽發的 ID token, provider_user_id 只能從驗證過的 token subject 取得。

H-5 任何已登入者可把外部身分綁到別人的帳號

  • 檔案jedi-iam/jedi_iam/app/service/user_auth_provider_service.py:99 register_ad_user()
  • 問題:方法收 user_uid 參數後直接 verify_user_exist(user_uid) 就建立綁定, 沒有檢查 caller 是不是本人、也沒有檢查 caller 有沒有管理他人身分的權限
  • 實查:✅ 屬實。第 85-100 行只做「使用者存在」與「該外部身分尚未被綁走」兩項檢查。 register_ldap_user(:108)、register_google_user(:141)同樣沒有。
  • 影響:攻擊者用自己的 AD 帳號綁到受害者的 user_id,之後用自己的目錄憑證登入就變成受害者。
  • 建議修法:service 層強制 user_uid == 從 JWT 解出的 caller,或要求 caller 具備管理他人身分的 capability。

H-6 OTP 驗證端點無認證、無次數限制,可換出完整 JWT

  • 檔案jedi-iam/jedi_iam/api/routes/otp_route.py:102 MfaVerifyRoute.post
  • 問題:端點刻意不掛 @auth_required(註解說明是登入第二關),但只要 uid + 六位數 code 正確就發 access/refresh token。 沒有任何嘗試次數限制。
  • 實查:✅ 屬實。route 內無 throttle 邏輯,驗證成功直接 generate_jwt_token(user.uid)
  • 影響:知道受害者 uid(登入回應與使用者列表都會露出)就能猜碼。攻擊者還能自行呼叫同樣不需認證的 /otp-resend 維持一個有效碼,然後無限次暴力猜。
  • 建議修法:每個 uid 做失敗計數(Redis),3 到 5 次就作廢該碼並鎖第二因子; 並讓 /otp-verify 綁定一個「密碼驗證通過後才發出的短期挑戰 token」。

H-7 LDAP filter injection(AD 分支)

  • 與 H-2 同一根因,工具分開報。AD 分支因 NTLM bind 前置條件而實際風險較低,建議與 H-2 一起修。

§3

MEDIUM 級(9 條)

M-1 無使用者情境時預設為 super admin(RLS fail-open)

  • 檔案jedi-common/jedi_common/session/database/db.py:117
  • 問題session_scope()elif is_pg: 分支在沒有 user context 時直接設 app.is_super_admin = 't',等於關閉 RLS。
  • 實查:✅ 屬實,見 db.py 第 115-119 行。
  • 影響:任何在沒有身分情境下跑的 @transaction 都以 super admin 執行。 主專案在 before_request 會把 context 重設為 None,所以任何漏掛 JWT 檢查的路由、公開端點、 或沒還原 context 的背景執行緒,都會踩到。
  • 建議修法:fail closed——沒有 user context 就不要給 super admin, 改為要求明確且可稽核的「系統情境」標記給登入、migration 這類少數可信呼叫者。

M-2 tenant_id 為 falsy 值即取得全域 super admin

  • 檔案:同上 :102
  • 問題is_super = (not user_ctx.tenant_id) or ...——tenant_idNone0 都算 super admin。
  • 實查:✅ 屬實。該檔自己的註解就記載了實際被利用的案例: tenant-102 的使用者帶 X-Tenant-ID: 0 就看到整張 roles 表。
  • 建議修法:不要把「tenant_id 是 falsy」等同於「平台管理者」。 改成 DTO 上一個經正向驗證才設定的 is_platform 布林值。

M-3 signed-token 最小情境以 super admin 執行

  • 檔案jedi-iam/jedi_iam/authz/signed_token.py:89
  • 問題:最小情境把 tenant_id 設為 None,於是踩到 M-2 變成 super admin。
  • 影響:signed-token 守門的端點沒有租戶隔離,token 裡帶的 user_id 並不會限制查出來的資料列。
  • 建議修法:解析出該 user 真正的 tenant scope 給最小情境,而非 None。

M-4 Turnstile 未設 secret 時 fallback 到永遠通過的測試金鑰

  • 檔案jedi-iam/jedi_iam/turnstile/verifier.py:38 _resolve_secret_key()
  • 問題if not raw: return _ALWAYS_PASS_TEST_KEY——secret 沒設就用 Cloudflare 的公開測試金鑰, 該金鑰的行為是「永遠驗證通過」。
  • 實查:✅ 屬實。第 35-37 行。
  • 影響:登入端點的機器人防護靜默失效,可做帳密填充; 還能配合三次鎖定機制對任意帳號做阻斷服務。
  • 建議修法:production 拿掉 fallback,解不到 secret 就啟動失敗或至少拋 401 並記 ERROR。

M-5 LDAP 連線測試把已存的服務帳號密碼送到呼叫端指定的主機

  • 檔案ldap_adapter.py:87 test_connect()
  • 問題:連線測試直接用設定檔裡存的 LDAP_USER / LDAP_SECRET 去 bind 呼叫端指定的伺服器。
  • 實查:✅ 屬實。test_connect() 就是 self.connect(self.config.LDAP_USER, self.config.LDAP_SECRET)
  • 影響:有 LDAP 設定權但不該知道服務帳號密碼的操作者,把伺服器欄位指向自己的主機就能收到該憑證。 配合 H-3(不驗憑證)風險更高。
  • 建議修法:目標主機與已存的不同時,要求重新輸入密碼;或限制連線測試目標為白名單。

M-6 非 SSL/STARTTLS 時 LDAP bind 走明文

  • 檔案ldap_adapter.py:73
  • 問題:加密設定不是 SSL 或 STARTTLS 時,bind 直接明文送出。
  • 建議修法:任何帶認證材料的 bind 一律要求 TLS,設定為 none 時拒絕或至少警告。

M-7 Redis TLS 關閉憑證驗證

  • 檔案jedi-iam/jedi_iam/common/utils/redis_client_util.py:39
  • 問題redis.Redis(..., ssl=self.__REDIS_SSL, ssl_cert_reqs=False, ...)—— 即使開了 SSL 也不驗憑證。
  • 實查:✅ 屬實,ssl_cert_reqs=False 是寫死的。
  • 影響:這條連線承載 OTP 驗證碼與 Redis 帳密,中間人可竊取憑證並繞過 email 第二因子。
  • 建議修法ssl=True 時傳 ssl_cert_reqs="required"ssl_check_hostname=True

M-8 稽核事件記錄可被注入

  • 檔案jedi-common/jedi_common/utils/audit_log.py:34
  • 問題:呼叫端傳入的欄位值未經處理就串進 system_logs 的訊息字串。
  • 實查:研究員指出實際已有可疑路徑——jedi-remote-agent 的 agent 註冊流程會把 hardware_info.hostname 一路帶進 holder_name此條我尚未開檔核對 jedi-remote-agent 側,待驗。
  • 影響:限於稽核紀錄完整性,可偽造 [AUDIT:...] 事件行、汙染下游 SIEM 解析。無程式碼執行風險。
  • 建議修法:組訊息前過濾 CR/LF 與控制字元,或直接改用結構化 JSON 而非 k=v 字串。

M-9 密碼登入的錯誤訊息可枚舉帳號

  • 檔案jedi-iam/jedi_iam/infra/adapter/authenticate_adapter/password/password_adapter.py:27
  • 問題:帳號不存在與密碼錯誤回不同的 error code。
  • 影響:未認證攻擊者可枚舉有效帳號,用於針對性密碼噴灑與釣魚。
  • 建議修法:兩種情況回完全相同的回應;帳號不存在時也跑一次假的 bcrypt 比對來拉平時間差。

M-10 Excel 匯入的檔案路徑可被 login_name 穿越

  • 檔案jedi-iam/jedi_iam/app/service/import_user_service.py:79
  • 問題upload_dir = os.path.join(uploads_dir, username),username 取自 login_name, 未經 secure_filenamebasename 處理。
  • 實查:✅ 屬實。第 71-73 行直接 join。
  • 前提:需要有一個 login_name 含路徑分隔符或 .. 的帳號存在(帳號建立端目前無格式驗證), 且該帳號具備 user.create 權限。前提偏窄。
  • 建議修法:帳號建立時用白名單正則驗 login_name,此處另外獨立做 os.path.basename

§4

LOW 級(3 條)

L-1 Email MFA 驗證碼用 random.randint 而非密碼學安全亂數

  • 檔案jedi-iam/jedi_iam/mfa/app/service/email_service.py:63
  • 實查:✅ 屬實,code = str(randint(100000, 999999))
  • 建議修法:改用 secrets.randbelow(900000) + 100000,驗證時用 hmac.compare_digest 比對。

L-2 分頁 page_size 無上限,可觸發 500

  • 檔案jedi-common/jedi_common/interfaces/entities/page_meta_entity.py:19
  • 問題PagerSchema.page_size 的上限驗證被刻意移除,page_size=0 會讓 (total + page_size - 1) // page_size 除以零。
  • 影響:僅可用性,且要先通過 schema。DB 查詢做完才炸,會汙染錯誤日誌。 另外無上限也允許一次撈全表。
  • 建議修法:恢復 validate.Range(min=1, max=<合理上限>),並在 __post_init__ 獨立防呆。

L-3 忘記密碼的帳號枚舉遮罩預設為關閉

  • 檔案jedi-iam/jedi_iam/plugin.py:191
  • 注意:主專案 core/iam_wiring.py 已明確設為 True,Guidant AI 目前不受影響。 問題在共用元件的預設值不安全,其他 consumer 忘了接線就會裸奔。
  • 建議修法:預設改為遮罩,要揭露存在性的產品明確 opt-out。

§5

建議的修正批次

這是我的建議,實際優先序由決策者裁決。

第一批(登入可被繞過,建議優先):H-1、H-2/H-7、H-4、H-5、H-6 第一批的共同性質是「不需要有效憑證就能取得他人身分」,其中 H-1 與 H-2 疊加後是完整的無密碼登入。

第二批(租戶隔離 fail-open):M-1、M-2、M-3 三條同根因,建議一起改 session_scope() 的 super admin 判定,並補守衛測試。 M-2 的註解顯示曾實際發生,改動前要先盤點有多少呼叫端依賴目前的 fail-open 行為。

第三批(傳輸與設定安全):H-3、M-4、M-5、M-7 第四批(其餘):M-6、M-8、M-9、M-10、L-1、L-2、L-3


§6

這份清單的可信度

必讀:這些是研究員 agent 的原始宣稱,沒有經過工具內建的三人驗證面板。 掃描在研究階段就因 session 額度用盡而中止,面板從未執行。

我已補做的核對:

  • 7 條 HIGH 全部開檔核對過原始碼,結論記在各條的「實查」欄,全部屬實
  • H-4 額外追了 factory 與 login_service 的派送路徑,確認該 adapter 是活的、可達
  • M-1、M-2、M-4、M-7、M-10、L-1、L-2 也開檔核對過

尚未核對的:

  • M-8 的實際觸發路徑(要開 jedi-remote-agent 的 agent 註冊流程確認)
  • 各條的「實際可利用性」未做端到端驗證,只做了靜態路徑核對

原始掃描資料(32 筆去重前的完整 JSON,含 evidence 與 snippet)在 ~/.claude/projects/.../subagents/workflows/wf_442f835c-ddf/journal.jsonl