S4 掃描結果:jedi-iam 使用者本體與密碼變更

掃描日期:2026-09-06 掃描版本:jedi-python-package feature/FR-075 @ 67cb075941bd6fd57f1e05a76bc56d5fed984c70(乾淨,無未 commit 變更) 工具:Claude Code claude-security plugin v0.10.2.3(claude-security:scan workflow) 範圍(指定):使用者本體 route→service→domain→repository 全鏈路 + 批次匯入 + 改密碼/忘記密碼,共 43 個受版控檔案(啟動前以 git ls-files 驗證,數字吻合:43) effort:low,focus:attack-surface 狀態:✅ 流程完整跑完並經驗證面板(verification.status: verified)——17 個 agent 全數回報、0 錯誤、0 空回,5 條發現全部 3/3 全票通過,且全部落在指派範圍內(無越界) 對應卡片:CM-1553(母卡 CM-1546) 現況:5 條發現全數已修,1.21.0 出貨(F1+F2 → CM-1575、F3 → CM-1576、F4 → CM-1577、F5 → CM-1578;總表 M13-1/4/7/14)

§1

摘要:這一棒找到什麼

5 條發現,其中 2 條 CRITICAL 是同一個洞的兩種寫法。 最重要的一條是:

免登入的 /forget-password 端點,把密碼重設 token 直接放在 HTTP response 回傳給呼叫者。 知道對方 email 就能接管任何帳號,包含原廠 admin 超級管理員——不需要碰到對方信箱。

這條與 S1~S3、S6、S7 的所有發現都不重複,是本輪全新收穫,也是整個 FR-075 掃描系列到目前為止唯一一條 CRITICAL。

本棒與 S7 形成明顯對比:S7 的 9 條發現有 8 條越界重複 S1,實際產出 0;本棒 5 條全部在範圍內、全部是新發現。

§2

掃描執行狀況

項目 數值
派出 agent 17(研究員 2、面板 15)
回報 agent 17(100%,0 錯誤、0 空回、0 被砍)
研究員 2 名派出 / 2 名回報(主研究員 + 密鑰專項掃描)
候選發現 6 條 → 去重後 5 條
面板票數 15 票(5 條 × 3 票),全部投完
未經審查的候選 0
總 token 2,291,427
總 tool call 1,277
耗時 約 3 小時 32 分(12,717 秒)

模型設定:主 session 與所有 agent 皆為 Opus 5 (1M context)(agent frontmatter 為 model: inherit,繼承主 session)。這是卡片 2026-09-06 修正段指定的設定,首次一輪跑完、沒有任何 stalled 或重試——S4~S7 首輪用 Sonnet 5 全滅的問題(研究員讀到 20 幾萬 token 後思考變慢、撞上 180 秒 stalled 門檻被砍)在本輪未再出現。

面板結果異常一致:5 條全部 3/3 全票,沒有任何一條被否決或出現 2:1 分歧。這通常代表候選品質高(研究員沒有硬報湊數)。

§3

逐條發現(HIGH 以上均已自行開檔核對)

F1 + F2(CRITICAL, confidence HIGH)— 忘記密碼端點把重設 token 回傳給呼叫者

面板:各 3/3 全票 | CWE-640 / CWE-201 | api/routes/change_password_route.py:66

核對結果:✅ 屬實,且我認為工具給的 CRITICAL 沒有誇大。 這是我親自逐檔追完整條路徑後確認的,不是照抄工具輸出。

F1 與 F2 是同一個洞,兩名研究員各自獨立發現、從不同角度描述(F1 從「重設憑證外洩」CWE-640,F2 從「敏感資訊暴露」CWE-201)。建議合併為一張修正卡。

白話說明:使用者按「忘記密碼」時,系統會產生一枚一次性的重設憑證,正常情況下這枚憑證只該出現在寄給本人的信裡。但這支端點把它連同整包資料當成 API 回應吐回去了——誰打這支 API,誰就直接拿到憑證。

核對過程(我實際確認的四件事):

  1. 端點確實免登入 — ForgetPasswordRoute.post(:44-72)沒有 @auth_required,這是設計上正確的(忘記密碼本來就是未登入流程)。
  2. 回應 schema 確實含 uid — api/serializers/change_password.py 的 UserChangePasswordResponse 第一個欄位就是 uid = fields.String()。
  3. 那個 uid 就是重設憑證 — 這是關鍵證據:user_change_password_service.py:112 組信件連結時寫的是 f"{system_url}/reset-password?uid={change_pwd_reqs.uid}&lang=..."。 寄給使用者的信裡放的憑證,與 API 回傳的欄位,是同一個值。
  4. uid 在回傳時已經有值 — infra/models/user_change_password_request.py:19 是 mapped_column(String(36), default=generate_uuid, ...),add() flush 後即產生,mapper 會複製進 entity。

攻擊路徑(兩步,全程免登入):

① POST /api/1.0/forget-password   {"email":"admin@客戶.example"}
   → 回應 {"status":true,"data":{"uid":"<重設憑證>", ...}}

② POST /api/1.0/user/change-pwd-by-req
   {"req_uid":"<重設憑證>","password":"<攻擊者自訂的新密碼>","confirm_password":"<同上>"}
   → 密碼被改掉,攻擊者登入

第二步我也核對過:change_password_by_request(service:35-79)只驗密碼政策、token 有效期與是否已使用,從頭到尾不問「你是誰」——這在原設計下是合理的(憑證本身即身分證明),但前提是憑證不能外洩。前提被第一步破壞了。

觸發前提:① 知道目標 email;② 目標 5 分鐘內沒申請過重設(否則被 429 節流擋,等 5 分鐘再打即可);③ 打得到 API。不需要任何帳號或 session。

實際影響:打原廠 admin 帳號即取得平台管理權——所有租戶的合規資料、角色與權限配置全數失守。

額外發現(我核對時注意到的,工具的 F1 有提到):宿主啟用的 forget_password_masks_missing_email=True 防帳號列舉措施在此形同虛設——它只遮蔽「信箱不存在」,而成功路徑洩漏的是遠比存在性敏感的憑證本身。

建議修法:ForgetPasswordRoute.post 成功與失敗一律回固定空 body(return c.reply(True, {})),讓兩條路徑無法區分;uid 只留在 request_change_password 內部組信件用。加一條回歸測試斷言 token 不出現在序列化回應中。

⚠️ 同一支 serializer 還有第二個出口:UserChangePasswordRequestRoute.get(:76-84)同樣免登入、同樣 dump 含 uid 的 schema。該端點的憑證是路徑上的 uid 本身(呼叫者已經有了才打得到),風險較低,但修 F1 時應一併檢視。


F3(HIGH, confidence MEDIUM)— 自助改個人資料可夾帶 user_roles 自我提權

面板:3/3 全票 | CWE-269 | api/routes/user_route.py:212

核對結果:✅ 機制屬實,但工具自己標 confidence MEDIUM 是誠實的——我同意保留 MEDIUM。 程式碼路徑我完整核對過確實通,但沒有實際打過端點,無法排除 DB 層 constraint 或 RLS 在寫入時擋下的可能。修正前應先實測確認。

白話說明:PUT /user-profile/<uid> 是「改自己的個人資料」,只檢查「你改的是不是自己」,沒有權限檢查。但它接受的欄位沒有白名單,送什麼進去都會被套用——包含 user_roles(你的角色)。

核對過程:

  1. schema 確實放行未知欄位 — @use_kwargs(UserRequest(unknown=INCLUDE), ...)(:194)。
  2. route 只擋 uid 不是自己 — :206-208 檢查 uid != current_user.uid,沒有 @capability_required(對照組:真正的管理端 UserRoute.put 有掛)。
  3. 角色確實被重建 — user_service.py:322-330:先 delete_user_role_by_user_id(user.id) 刪光舊角色,再依 payload 的 role_uid / tenant_id 逐筆重建。
  4. 宿主守門管不到這個情境 — 這點最值得說明。assert_root_admin_update_allowed(app/auth/service/user_app_service.py:74-111)看起來像防線,但我讀完發現它只保護原廠 admin 不被降權:第 89 行 if not self._is_protected(uid): return ——一般使用者改自己,第一關就直接放行。它防的是「別人把 admin 拔權」,不是「一般人把自己升權」。

這條最值得注意的地方:route 的 docstring 自己寫著 is_super_admin / status / user_roles 送進來都會生效——當初是為了處理 root admin 自我降權才加的守門,但沒意識到反方向(一般人自我升權)沒有任何防護。這不是沒人看過這段程式碼,是防護方向想錯了。

建議修法:自助端點改用專屬 schema(unknown=EXCLUDE),只允許 nickname / job_title / tel / email / password;user_roles / tenants / org_units / status / is_super_admin 一律只能走有 @capability_required("user.update") 的管理端。


F4(MEDIUM, confidence HIGH)— update_user 改密碼繞過密碼強度政策

面板:3/3 全票 | CWE-521 | app/service/user_service.py:298

核對結果:✅ 屬實。 我對照三條改密碼路徑確認過。

白話說明:改密碼有多條路徑,只有「忘記密碼重設」那條會驗密碼強度,走 update_user 改密碼的不驗——可以設 1234。

核對過程:change_password_by_request(:35-45)第一件事就是 validate_password_policy(password, policy),違反直接 400;註解寫明是 CM-683「關掉繞過 FE 直打 API 設弱密碼的洞」。但 update_user 的 if user_entity.password: 區塊(:290-300)只做「能不能改」的權限檢查與歷史密碼比對,整段沒有 validate_password_policy,驗完就直接 bcrypt 雜湊寫入。

這是修一半的洞:CM-683 補的是重設路徑,另外兩條同樣能設密碼的路徑沒補上。屬 [[feedback_scope_fix_grep_all_writers_and_readers]] 的典型案例。

建議修法:update_user 的密碼分支與 add_user 的管理員設密碼分支,都補上同一個 validate_password_policy,policy 比照從宿主 hooks.password_policy 帶入。


F5(MEDIUM, confidence MEDIUM)— Excel 上傳目錄用未驗證的 login_name 組路徑

面板:3/3 全票 | CWE-22 | app/service/import_user_service.py:79

核對結果:✅ 機制屬實,但實際觸發門檻比工具描述的高——我把前提補在下面。 這條就是卡片點名的舊發現 M-10,本輪獨立重新找到,可與第一輪 batch1 對照。

白話說明:上傳使用者 Excel 時,暫存目錄用上傳者自己的 login_name 組成。login_name 若含 ../,檔案就會落到預期目錄之外。

核對過程與一個重要細節:

  • upload_dir = os.path.join(uploads_dir, username),username 來自 get_user_context().login_name(:70-71),組路徑前沒有任何驗證或 secure_filename。
  • 模組內確實有 login_name 格式正則 _LOGIN_NAME_FORMAT_RE = ^[A-Za-z0-9._]+$(:28)——但它用在 :162,驗的是「Excel 裡被匯入的那些人」,不是「正在上傳的這個人」。組路徑用的 :71 完全沒經過它。這個落差是這條發現的核心。
  • 檔名本身安全(時間戳重組),唯一可控的是目錄名。

觸發前提(比工具描述的嚴格,需誠實標明):攻擊者的帳號 login_name 本身必須含 ../。而 user_route 建立帳號走 UserRequest,login_name 只宣告 fields.String(required=True),沒掛格式驗證——所以理論上管理員建帳號時可以建出這種名字,但需要一個有 user.create 權限的人先建立惡意 login_name 帳號,不是任意使用者自助可達。故 MEDIUM 而非 HIGH 是恰當的。

建議修法:① 把 _LOGIN_NAME_FORMAT_RE 提升為 UserRequest.login_name 的 marshmallow validator,讓所有建立路徑都覆蓋;② 目錄改用 user.id / uid 這類非文字識別碼;③ file.save() 前斷言 os.path.realpath(save_filepath).startswith(os.path.realpath(uploads_dir))。

§4

密鑰專項掃描

sweep:secrets 已執行完成,回報 0 條——指派範圍內未發現硬編碼憑證或金鑰洩漏。

§5

與舊發現的對照

舊發現(第一輪不完整掃描) 本輪結果
M-10 Excel 匯入 login_name 路徑穿越(import_user_service.py:79) ✅ 獨立重新找到 = 本輪 F5,行號完全吻合,並補上「模組內有正則但用錯地方」與實際觸發前提

卡片要求說明「舊發現有沒有被找到」:M-10 有被找到,非面板刪除、非漏掃。

本輪新增、舊清單沒有的:F1/F2(CRITICAL 重設憑證外洩)、F3(自助提權)、F4(密碼政策繞過)——4 條全新發現,其中含整個 FR-075 系列至今唯一的 CRITICAL。

§6

建議開卡(已全數開卡並修完)

建議卡 內容 嚴重度 現況
1 F1+F2 合併:忘記密碼端點停止回傳重設憑證(含一併檢視 UserChangePasswordRequestRoute.get) 🔴 CRITICAL — 建議優先處理 ✅ 已修(CM-1575)
2 F3:自助 profile 端點改用欄位白名單 schema 🟠 HIGH(修正前先實測確認可利用性) ✅ 已修(CM-1576)
3 F4:update_user / add_user 補密碼政策驗證 🟡 MEDIUM ✅ 已修(CM-1577)
4 F5:login_name 格式驗證提升到 schema 層 + 路徑組法改用 id 🟡 MEDIUM ✅ 已修(CM-1578)

F1/F2 與 F3 同屬 jedi-iam 套件,異動前依 CLAUDE.md「外部套件異動規範」需先向決策者說明影響範圍與其他 consumer 風險。

§7

掃描產物

jedi-python-package/CLAUDE-SECURITY-20260906-021236/(.gitignore 已就位,不進版控): CLAUDE-SECURITY-RESULTS.md / .jsonl / .sarif + 版本戳記。