掃描日期:2026-09-06 掃描版本:jedi-python-package
feature/FR-075@67cb075941bd6fd57f1e05a76bc56d5fed984c70(乾淨,無未 commit 變更) 工具:Claude Codeclaude-securityplugin v0.10.2.3(claude-security:scanworkflow) 範圍(指定):使用者本體 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)
5 條發現,其中 2 條 CRITICAL 是同一個洞的兩種寫法。 最重要的一條是:
免登入的
/forget-password端點,把密碼重設 token 直接放在 HTTP response 回傳給呼叫者。 知道對方 email 就能接管任何帳號,包含原廠admin超級管理員——不需要碰到對方信箱。
這條與 S1~S3、S6、S7 的所有發現都不重複,是本輪全新收穫,也是整個 FR-075 掃描系列到目前為止唯一一條 CRITICAL。
本棒與 S7 形成明顯對比:S7 的 9 條發現有 8 條越界重複 S1,實際產出 0;本棒 5 條全部在範圍內、全部是新發現。
| 項目 | 數值 |
|---|---|
| 派出 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/3 全票 | CWE-640 / CWE-201 | api/routes/change_password_route.py:66
核對結果:✅ 屬實,且我認為工具給的 CRITICAL 沒有誇大。 這是我親自逐檔追完整條路徑後確認的,不是照抄工具輸出。
F1 與 F2 是同一個洞,兩名研究員各自獨立發現、從不同角度描述(F1 從「重設憑證外洩」CWE-640,F2 從「敏感資訊暴露」CWE-201)。建議合併為一張修正卡。
白話說明:使用者按「忘記密碼」時,系統會產生一枚一次性的重設憑證,正常情況下這枚憑證只該出現在寄給本人的信裡。但這支端點把它連同整包資料當成 API 回應吐回去了——誰打這支 API,誰就直接拿到憑證。
核對過程(我實際確認的四件事):
ForgetPasswordRoute.post(:44-72)沒有 @auth_required,這是設計上正確的(忘記密碼本來就是未登入流程)。api/serializers/change_password.py 的 UserChangePasswordResponse 第一個欄位就是 uid = fields.String()。user_change_password_service.py:112 組信件連結時寫的是 f"{system_url}/reset-password?uid={change_pwd_reqs.uid}&lang=..."。 寄給使用者的信裡放的憑證,與 API 回傳的欄位,是同一個值。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 時應一併檢視。
面板:3/3 全票 | CWE-269 | api/routes/user_route.py:212
核對結果:✅ 機制屬實,但工具自己標 confidence MEDIUM 是誠實的——我同意保留 MEDIUM。 程式碼路徑我完整核對過確實通,但沒有實際打過端點,無法排除 DB 層 constraint 或 RLS 在寫入時擋下的可能。修正前應先實測確認。
白話說明:PUT /user-profile/<uid> 是「改自己的個人資料」,只檢查「你改的是不是自己」,沒有權限檢查。但它接受的欄位沒有白名單,送什麼進去都會被套用——包含 user_roles(你的角色)。
核對過程:
@use_kwargs(UserRequest(unknown=INCLUDE), ...)(:194)。uid != current_user.uid,沒有 @capability_required(對照組:真正的管理端 UserRoute.put 有掛)。user_service.py:322-330:先 delete_user_role_by_user_id(user.id) 刪光舊角色,再依 payload 的 role_uid / tenant_id 逐筆重建。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") 的管理端。
面板: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 帶入。
面板: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_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))。
sweep:secrets 已執行完成,回報 0 條——指派範圍內未發現硬編碼憑證或金鑰洩漏。
| 舊發現(第一輪不完整掃描) | 本輪結果 |
|---|---|
M-10 Excel 匯入 login_name 路徑穿越(import_user_service.py:79) |
✅ 獨立重新找到 = 本輪 F5,行號完全吻合,並補上「模組內有正則但用錯地方」與實際觸發前提 |
卡片要求說明「舊發現有沒有被找到」:M-10 有被找到,非面板刪除、非漏掃。
本輪新增、舊清單沒有的:F1/F2(CRITICAL 重設憑證外洩)、F3(自助提權)、F4(密碼政策繞過)——4 條全新發現,其中含整個 FR-075 系列至今唯一的 CRITICAL。
| 建議卡 | 內容 | 嚴重度 | 現況 |
|---|---|---|---|
| 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 風險。
jedi-python-package/CLAUDE-SECURITY-20260906-021236/(.gitignore 已就位,不進版控): CLAUDE-SECURITY-RESULTS.md / .jsonl / .sarif + 版本戳記。