---
title: L4 掃描結果：License Center 網頁後台
---

# L4 掃描結果：License Center 網頁後台

> **掃描日期**：2026-09-06
> **掃描版本**：license_center `feature/FR-075` @ `6c1d9160380cf23b8bb70a522dca4c91506146f9`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin v0.10.2.3（`claude-security:scan` workflow）
> **範圍**：`src/license_center/web`，43 個受版控檔案（啟動前用 `git ls-files` 驗過，與卡片數字吻合；已排除 `static/vendor`）
> **effort**：`low`，**focus**：`attack-surface`
> **模型**：主 session Opus 5 (1M context)，研究員繼承
> **狀態**：✅ **流程完整跑完，驗證面板 `verification.status: verified`**（讀 `CLAUDE-SECURITY-REVISION-6c1d9160380c.json` 的 `verification` 欄確認，非自述）
> **對應卡片**：CM-1571（母卡 CM-1566）

## 一句話結論

**範圍內找到 4 條 MEDIUM，全部屬實，沒有 HIGH／CRITICAL。**
問題全部集中在**線上開通端點**（`/api/activation/activate`，唯一免認證的對外入口）與**登入導轉**兩處：
開通序號只有 32 bits 且防猜機制形同虛設（可暴力猜到別人的照）、防猜用的記憶體結構無上限（可打掛服務）、
登入後的 `next` 參數未驗證（可用來釣內部人員的管理密碼）。

值得注意的是：**同一個 repo 內就有正確做法**——後台登入的失敗鎖定存 DB、計 IP、
docstring 還明寫了「為什麼不能存記憶體」，但開通端點沒套用同一套知識。這不是不懂，是遺漏。

## 執行概況

| 項目 | 數值 |
|------|------|
| 掃描時長 | 約 110 分鐘（06:18 啟動 → 07:29 產出報告，含面板） |
| agent 派出／回報 | 23 派出，23 回報，**0 失敗、0 stalled** |
| 研究員 | 派出 2，回報 2 |
| 原始候選 | 9 條 → 去重後 7 條 |
| 面板投票 | **21 票（7 條 × 3 位），全部投完，無漏審、無被 cap 砍掉** |
| 通過（3-0） | **4 條**（本報告 F1～F4） |
| 遭否決 | 3 條（1 條 1-2、2 條 0-3，詳見末節） |
| 消耗 token | 約 240 萬（subagent 合計） |

**面板確實跑完**：`verification.status: verified`、`unreviewed_candidate_sites: 0`、
`incomplete_panel_candidates: 0`、`candidatesDroppedByCap: 0`。這幾個欄位是 workflow 自己
用程式算出來寫進 stamp 的，不是我或 agent 自述。

**「Opus 1M ＋ 小範圍」再次驗證成立**：本棒 43 檔（四棒中最大），110 分鐘一次跑完零 stalled。
對照 FR-075 用 Sonnet 跑 40 檔以上四棒全滅共燒 29.6 小時。

## 掃了什麼、沒掃什麼

**掃了**：`src/license_center/web` 整棵——app factory、session 登入、CSRF 接線、安全 header、
後台各 SSR 頁面（總覽／簽發／方案／照明細／客戶時間線／解鎖 token／API token 管理）、
兩支機器 API（`/api/internal/*`、`/api/activation/*`）、健康檢查、模板與靜態資產。

**沒掃**：`src/license_center/web` 以外的一切——包含 `core/`（組態、金鑰處理、logging）、
`modules/`（licensing／auth／plan 領域服務）、`cli/`、`scripts/`、`alembic/`、`tests/`。
這對讀本報告有實際影響：F2 的建議修法要動 `modules/licensing/service.py:42`、
F4 要動 `web/api_schemas.py` 與 `core/config.py`，**但只有範圍內的呼叫點被審過**。

**越界發現已排除**：研究員有 2 條指向 `src/license_center/core/config.py:124`
（`LICENSE_CENTER_ENV` 未設定時預設 `dev`，連帶套用公開已知的預設 `SECRET_KEY`）。
該檔不在本棒 scope 內，依卡片紀律標為**越界、不列為本棒發現**——
且該 2 條在面板也是 0-3 被否決。若首腦認為值得追，應另開範圍涵蓋 `core/` 的棒次。

**低 effort 的意義**：`low` 沒有 inventory、沒有 threat model、沒有 breadth sweep，
就是一輪研究員讀完再交面板。所以「某區沒發現」的正確讀法是
**「有讀過但沒報東西」，不是「已窮盡稽核」**。這與「根本沒讀到」（上述範圍外）是兩回事。

---

## 發現清單

| # | 嚴重度 | 位置 | 一句話 | 面板 | 復核 |
|---|--------|------|--------|------|------|
| F1 | MEDIUM | `web/auth.py:83-84` | 登入後 `next` 參數未驗證，可導到外站釣密碼 | 3-0 | 屬實 |
| F2 | MEDIUM | `web/views/activation.py:115` | 開通序號防猜鎖定「用被猜的序號當 key」，等於沒鎖 | 3-0 | 屬實（更嚴重）|
| F3 | MEDIUM | `web/views/activation.py:53` | 防猜計數表無上限，免認證即可撐爆記憶體 | 3-0 | 屬實（與 F4 同源）|
| F4 | MEDIUM | `web/views/activation.py:66` | 同上結構，且**查詢時也寫入**、key 長度無限制 | 3-0 | 屬實（與 F3 同源）|

---

### F1 — 登入導轉未驗證目標（Open redirect）

**位置**：`src/license_center/web/auth.py:83-84`，`register_auth.login`
**嚴重度**：MEDIUM　**信心**：高　**CWE-601**　**面板**：3-0

**問題在說什麼（白話）**
登入頁的網址可以帶一個 `?next=...` 參數，用意是「登入完把你送回原本要去的頁面」。
但程式碼拿到這個值之後**完全沒檢查**就直接導轉：

```python
next_url = request.args.get("next") or url_for("overview.index")
return redirect(next_url)
```

所以 `next` 填 `https://evil.tld/login` 也照導。Werkzeug 不會幫忙擋，
`//evil.tld` 這種協定相對網址一樣有效。

**實際影響**
攻擊者寄一個連結給內部人員：`http://<簽發站>:5062/login?next=https://假站/login`。
受害者看到的是**真站的登入頁**（網址列是對的、憑證是對的），輸入帳密、登入成功，
然後被導到攻擊者的仿冒頁，那頁顯示「連線逾時請重新登入」——密碼就被收走了。
而簽發站只有**一組共用管理帳密**，拿到就等於拿到簽照、簽解鎖 token、
以及全部客戶與訂單資料的權限。

**觸發前提**
- 攻擊者能把網址送到內部人員手上（信件、聊天、工單）
- 受害者**登入成功**（導轉只在成功分支才會走到）

**建議修法**
在導轉那一行檢查，不要在填 `next` 的呼叫端檢查：用 `urlsplit()` 解析，
只要 `scheme` 或 `netloc` 有值就丟掉、退回 `url_for("overview.index")`；
另外要求開頭是單一個 `/`（擋掉 `//` 與 `/\`）。

**復核（我開檔核對過）：屬實。**
`auth.py:83-84` 逐字如報告所述。一個補充：**導轉發生在登入成功之後**，
所以攻擊者無法靠它繞過登入或直接進後台；危害路徑純粹是釣魚
（讓受害者在真站登入後落到假站再輸一次）。MEDIUM 的定級合理，不需上調。

---

### F2 — 開通序號防猜鎖定失效（暴力猜測不受限）

**位置**：`src/license_center/web/views/activation.py:115`，`activate`
**嚴重度**：MEDIUM　**信心**：高　**CWE-307**　**面板**：3-0

**問題在說什麼（白話）**
`/api/activation/activate` 是**故意不做 token 認證**的端點——呼叫端是客戶的後端，
端點自己的註解寫得很清楚：「本端點無 token 認證——憑證就是開通序號本身」。
既然序號就是憑證，那防止有人亂猜序號就是唯一的防線。

現在的防線是：同一個序號 15 分鐘內失敗 5 次就鎖定。
**但鎖定的 key 就是「被猜的那個序號」**——攻擊者每次猜不同的值，
每個 key 的失敗次數永遠是 1，門檻永遠不會到。**沒有按來源 IP 計數，也沒有全域上限。**

而序號本身是 `uuid.uuid4().hex[:8].upper()`——**8 個十六進位字元，32 bits**。

**實際影響**
猜中一個「已簽發但客戶還沒開通」的序號（這是簽發到交付之間的正常狀態），
攻擊者就能用**自己指定的機器指紋**去換一張**真的、有簽章的授權照**——
等於免費拿到整套產品的使用權。同時那個序號會被燒掉，
真正的客戶之後開通會拿到 409 失敗。

**觸發前提**
- 打得到 `/api/activation/activate`
- 當下至少有一個已簽發、未開通、未過期的序號存在

**建議修法**
① 改成對「攻擊者無法變動的東西」計數——按 `request.remote_addr` 計，再加一個全域失敗預算；
② 計數存 DB 不存記憶體（見下方復核）；
③ `new_activation_code()`（`modules/licensing/service.py:42`）的熵要拉高，
32 bits 對一個**單獨當憑證用**的值太少，改 `secrets.token_urlsafe`。

**復核（我開檔核對過）：屬實，而且比工具寫的更值得注意。** 三點逐一核對：

1. `activate()` 確實只呼叫 `_is_locked_out(activation_code)`，全檔沒有任何按 IP 的計數
   ——`request.remote_addr` 只進 log，不進判斷。
2. `new_activation_code()` 確為 `uuid.uuid4().hex[:8].upper()`，32 bits 無誤。
3. **對照組就在同一個 repo 裡**：`web/auth.py` 的後台登入鎖定是存 DB 的，
   而且它的 docstring 明寫：「計數存 DB 不存記憶體——gunicorn 多 worker 記憶體不共享，
   存 process 內等於把門檻乘上 worker 數，且重啟即清空。」

   **同一份知識已經寫在隔壁檔案，開通端點卻沒有套用。**
   這不是「不知道要這樣做」，是遺漏。開修正卡時可以直接指這段當範本。

---

### F3／F4 — 開通端點的失敗計數表可被無限撐大（兩個寫入點，同一個結構）

**位置**：`src/license_center/web/views/activation.py:53`（`_record_failure`）
與 `:66`（`_is_locked_out`）
**嚴重度**：MEDIUM　**信心**：高　**CWE-770**　**面板**：各 3-0

> 工具把這兩條分開報，但**它們是同一個 dict 的兩個寫入點，修法共用一套**。
> 這裡合併說明，開修正卡時應**開成一張卡**，不要拆兩張。

**問題在說什麼（白話）**
防猜用的計數表 `_failure_log` 是一個模組層級的 dict，
**key 就是呼叫端送來的 activation_code**——也就是攻擊者說了算的字串。
這個 dict **從來不清理**：

- 第 65 行的清理只會修剪「當下正在查的那一個 key 裡面的時間戳」，**不會刪掉 key**
- 沒有任何定期掃除、沒有數量上限

兩個寫入點的成本差很多：

| 行 | 何時寫入 | 攻擊成本 |
|----|---------|---------|
| **:66**（`_is_locked_out`）| **每次請求都寫**，而且在查 DB **之前** | 便宜——隨便打就長 |
| **:53**（`_record_failure`）| 只有走到失敗分支（404/409/400）才寫 | 較貴 |

雪上加霜的是**key 的長度沒有上限**：
`ActivateRequestSchema.activation_code` 是純 `fields.Str`，沒有 `Length` 驗證；
`create_app()` 也**沒有設 `MAX_CONTENT_LENGTH`**（全 repo grep 確認過，一處都沒有）。

**實際影響**
攻擊者送 `{"activation_code": "<10 MB 的 A>", "machine_fingerprint": "x"}`，
`_is_locked_out` 會在查資料庫之前先把這 10 MB 的字串當 key 存進去，而且永遠不刪。
**幾十個這種請求就能吃掉數百 MB。** worker 被 OOM 殺掉之後，
簽發、線上開通、竄改回報、後台操作**全部停擺**。整個過程**不需要任何憑證**。

**觸發前提**
- 打得到 `/api/activation/activate`（依設計免認證、且豁免 CSRF）
- 前面沒有反向代理擋 request body 大小

**建議修法（四項一起做）**
1. **查詢時不要寫入**：`_is_locked_out` 改用 `_failure_log.get(key, [])`，只在 `_record_failure` 寫
2. **結構加上限**：用 `OrderedDict` 當 LRU 設硬上限，寫入時順手掃掉過期的 key（不是只掃當前 key）
3. **key 長度設限**：`ActivateRequestSchema.activation_code` 加 `ma.validate.Length(max=64)`
4. **設 `MAX_CONTENT_LENGTH`**：在 `create_app()` 設定，超大 body 在進 handler 之前就被擋掉

> 若採用 F2 建議的「計數改存 DB」，本條的 1、2 兩項自然一起解決。
> **兩個寫入點只補一邊等於沒補**——第 66 行是主要的便宜攻擊路徑。

**復核（我開檔核對過）：兩條都屬實。**
`_is_locked_out` 第 66 行確為無條件寫入且執行在 DB 查詢之前；
`_required_str` 只有 `required=True` 無長度上限；
全 repo grep `MAX_CONTENT_LENGTH` 零結果。

---

## 遭面板否決的候選（3 條）

| 位置 | 宣稱 | 票數 | 處理 |
|------|------|------|------|
| `web/views/internal_api.py:87` | 單一 API token 同時授權簽照與簽 manifest，無 scope 區分 | 1-2 | 否決（範圍內，但面板多數認為不成立）|
| `core/config.py:124` ×2 | `LICENSE_CENTER_ENV` 預設 `dev` → 套用公開已知的預設 `SECRET_KEY` | 0-3 | **越界**（`core/` 不在本棒 scope）＋面板否決 |

`internal_api.py:87` 那條在範圍內，我看過該處程式碼：token 檢查是掛在
`@bp.before_request` 上的全 blueprint 認證，且註解說明了「認證先於參數驗證」的理由。
面板 1-2 否決，我不推翻——但「單一 token 無 scope 區分」屬**設計取捨**而非漏洞，
若首腦認為權限應分離，那是需求層的討論，不該用資安卡承載。

---

## 給首腦的修正卡建議

| 建議卡 | 收哪幾條 | 理由 |
|--------|---------|------|
| **卡 A：開通端點防猜與資源上限** | F2 + F3 + F4 | 三條都在同一支檔案的同一段邏輯，且 F2 建議的「計數改存 DB」順手解掉 F3/F4 的一半。拆開修會互相踩到。 |
| **卡 B：登入導轉驗證** | F1 | 獨立、單點、幾行就好，與卡 A 無耦合。 |

**卡 A 的範本就在同 repo**：`web/auth.py` + `modules/auth/`（DB 計數、按帳號鎖定、
docstring 寫明為什麼不能存記憶體）。修的人照著搬即可，不需重新設計。

**優先序判準**（依 [[project_first_release_is_host_saas_reserved]]）：
兩張卡影響的都是**簽發站**——它是公司內部系統，不隨落地版出貨給客戶。
但 `/api/activation/activate` 是**客戶端 BE 會打的對外端點**，
落地版客戶的網路打得到它，所以卡 A 的曝險面比卡 B 大。

---

## 附件

掃描原始產物在 license_center repo（該目錄自帶 `.gitignore`，不入版控）：

```
license_center/CLAUDE-SECURITY-20260906-051823/
├── CLAUDE-SECURITY-RESULTS.md              # 工具產出的英文報告
├── CLAUDE-SECURITY-RESULTS.jsonl           # CI gate 用
├── CLAUDE-SECURITY-RESULTS.sarif           # code scanning dashboard 用
└── CLAUDE-SECURITY-REVISION-6c1d9160380c.json  # 版本與驗證戳記
```

本文件是該報告的中文整理＋逐條開檔復核，**不是翻譯**——
每條的「復核」段是我自己讀原始碼後的判斷，與工具輸出獨立。
