# 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 條發現，其中 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 分歧。這通常代表候選品質高（研究員沒有硬報湊數）。

## 逐條發現（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":"Attacker1234","confirm_password":"Attacker1234"}
   → 密碼被改掉，攻擊者登入
```

第二步我也核對過：`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))`。

## 密鑰專項掃描

`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 — 建議優先處理 |
| 2 | F3：自助 profile 端點改用欄位白名單 schema | 🟠 HIGH（修正前先實測確認可利用性）|
| 3 | F4：`update_user` / `add_user` 補密碼政策驗證 | 🟡 MEDIUM |
| 4 | F5：login_name 格式驗證提升到 schema 層 ＋ 路徑組法改用 id | 🟡 MEDIUM |

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

## 掃描產物

`jedi-python-package/CLAUDE-SECURITY-20260906-021236/`（`.gitignore` 已就位，不進版控）：
`CLAUDE-SECURITY-RESULTS.md` / `.jsonl` / `.sarif` ＋ 版本戳記。
