---
title: L2 掃描結果：License 授權狀態與 API
---

# L2 掃描結果：License 授權狀態與 API

> **掃描日期**：2026-09-06
> **掃描版本**：jedi-python-package `feature/FR-075` @ `67cb075941bd6fd57f1e05a76bc56d5fed984c70`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin v0.10.2.3（`claude-security:scan` workflow）
> **範圍**：`jedi-license-runtime/jedi_license_runtime/` 的 `api` / `app` / `domain` / `infra` 四個目錄 ＋ `plugin.py`，**33 個受版控檔案**（啟動前用 `git ls-files` 驗過，與卡片數字吻合）
> **effort**：`low`，**focus**：`attack-surface`
> **模型**：主 session Opus 5 (1M context)，研究員繼承
> **狀態**：✅ **流程完整跑完，驗證面板 `verification.status: verified`**（讀 `CLAUDE-SECURITY-REVISION-67cb075941bd.json` 的 `verification` 欄確認，非自述）
> **對應卡片**：CM-1568（母卡 CM-1566）

## 一句話結論

**掃描工具在本棒範圍內找到 1 條（TOCTOU 跨租戶重複用照，LOW，屬實但修法要改）；另 1 條是 L1 已報過的同一個洞（越界，不計本棒）。此外，我在人工核對過程中另外發現 1 條掃描沒報的問題：停權可以被租戶自己上傳一張新照解除**——這條的嚴重度高於掃描報出來的任何一條。

## 執行概況

| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2（無 stalled，無重試）|
| 候選發現 | 5 條（去重後 4）|
| 面板票數 | 12 票（4 條 × 3 票，無缺票）|
| 面板存活 | 2 條（F3 以 1:2、F4 以 0:3 被否決）|
| 總耗時 | 約 90 分鐘（5,394 秒）|
| agent 總數 | 14（全數完成，0 失敗、0 空回報）|
| token 消耗 | 約 167 萬 |
| 掃描覆蓋 | `scopeFiles: 33`、`emptyScope: false`、`collapsed: null`、`droppedComponents: []`、`unverifiedByCap: 0` |

**「Opus 1M ＋ 小範圍」組合再次驗證成立**：33 檔（比 L1 的 10 檔多兩倍）一次跑完、零 stalled。對照 FR-075 的 S4～S7 用 Sonnet 5（200K）跑 40 檔以上四棒全滅，本設定可繼續用於 L3／L4。

## ⚠️ 研究員又越界，且這次撞上 L1 已報過的同一條

掃描存活的 2 條裡，只有 1 條是本棒的新發現：

| ID | 標題 | 檔案 | 判定 |
|---|---|---|---|
| F1 | 驗章前無界解壓縮（解壓炸彈） | `.../common/engine.py:114` | ❌ **越界＋重複**——`common/` 是 L1 的範圍，且 L1 報告已完整記錄過同一條（見 `scan-L1-verification-core.md` F1）|
| F2 | 跨租戶重複用照的 TOCTOU | `.../app/service/license_verification_service.py:142` | ✅ **在範圍內，本棒唯一收穫** |

F1 不重複論述，修法照 L1 報告。這是研究員第三次越界（FR-075 S7、FR-076 L1、本棒），已可視為 plugin 的固有行為而非偶發。

---

## 🔴 額外發現（掃描沒報，人工核對時發現）：租戶可以自己上傳一張新照解除停權

**嚴重度**：HIGH ｜ **信心**：HIGH（已逐檔開檔核對完整鏈路）｜ **CWE-863（授權判定不正確）**

**位置**：`app/service/license_verification_service.py:188-213`（`_store_verified_license` 建新照 entity）＋ `domain/service/tenant_license_domain_service.py:37`（`replace_current`）

### 白話說明

平台管理員可以「停權」某個租戶——例如客戶欠款、或發現濫用。停權的做法是在**那張照的資料列上**蓋一個 `suspended_at` 時間戳，被停權的租戶整站變唯讀（`common/authz/license.py:275` 對 `status` 與 `suspended_at` 取聯集）。設計上刻意不動 `status`，避免每日排程把停權洗掉（CM-1173 的教訓）。

問題出在停權旗標**跟著照走，而不是跟著租戶走**。而換照的機制（D6 replace 制）是：舊照那一列標成歷史、**新建一列**當現行照。新建的那一列，`suspended_at` 是預設的 `None`。

所以被停權的租戶只要**自己上傳一張新照**，唯讀立刻解除——不需要管理員、不需要任何特權。

### 為什麼繞得過守門

三道看似會擋的機制，全部擋不住：

1. **唯讀 gate 不擋**：`/license/` 前綴是刻意豁免的（防死鎖設計，`api/license/__init__.py` 檔頭紅字明示）。被鎖的租戶本來就必須能上傳新照自救。
2. **端點權限不擋**：`/license/activation/upload` 只掛 `login_required`（`api/routes.py:48`），任何登入使用者都能打。這同樣是刻意的——開通當下租戶可能還沒有管理員。
3. **落地流程不檢查停權**：`_store_verified_license` 從頭到尾沒有讀過現行照的 `suspended_at`（我 grep 過整支 `license_verification_service.py`，`suspend` 零命中）。

四條落地路徑（root 指派／上傳／離線開通／線上開通）都走同一支 `_store_verified_license`，所以四條全中；其中三條是租戶自己就能打的。

### 觸發前提

- 租戶被平台管理員停權（`suspended_at` 非空）
- 租戶手上有**任何一張驗章通過、且不是當前現行照**的照
  - 最容易的來源：自己的舊照。歷史照仍屬本租戶，跨租戶檢查（`<> tid`）排除自己，不會擋
  - host 版還要過機器指紋核對，但舊照本來就綁同一台機器
- 送一個 POST 到 `/license/activation/upload`

### 實際影響

停權這道管理手段對「手上有舊照的租戶」形同虛設。SaaS 版尤其嚴重：`force_lock_tenant` 的 docstring 明說這是「SaaS 獨有手動立即停權」，是欠款／濫用時的主要處置工具，而 SaaS 版連機器指紋核對都沒有（`deployment_mode != "host"` 直接跳過）。管理員停權後看到租戶恢復正常，時間線上只會出現一筆正常的 `upload` 事件，不會有任何告警。

### 建議修法

不要只在落地路徑補一個 if——那只擋得住這一條路，日後新增落地路徑會再漏一次。**停權語意本來就屬於租戶而不屬於某一張照**，兩個方向擇一：

- **（建議）把停權移到租戶層**：新增 `config.tenant_license_suspensions`（或在租戶側記一個旗標），執法讀租戶而非讀照。換照就自然不會清掉它。
- **（次選，改動小）落地時繼承**：`_store_verified_license` 建新 entity 前先讀現行照，若 `suspended_at` 非空就帶到新列，並記一筆事件。但要留意這會讓「停權中的租戶換照」永遠帶著停權，解除只能靠管理員 `unlock`——這其實正是想要的語意，只是要明確寫進設計。

無論選哪個，落地路徑都應該補一筆 `blocked_while_suspended` 或 `landed_while_suspended` 事件，讓時間線看得出來。

**這一項建議直接開卡修，不要等下一輪掃描**——它不會被下一輪掃出來（下一輪的範圍不含這裡）。

---

## F2（掃描報出、範圍內唯一收穫）— 跨租戶重複用照的競態視窗

**嚴重度**：掃描判 LOW ｜ **信心**：MEDIUM ｜ **CWE-367（TOCTOU）**｜ **人工核對：屬實，但修法要改**

**位置**：`app/service/license_verification_service.py:142`（`_store_verified_license` 的跨租戶檢查）＋ `infra/license_tenant_resolver.py:_query_scalar`

### 白話說明

同一張照不該被兩個租戶同時用——不然 SaaS 版 root 手滑把 A 客戶的照指派給 B，B 就免費用 A 的授權（程式碼註解 CM-1157 講得很清楚）。目前擋這件事的方法是：落地前查一下「這個 `license_id` 有沒有被別的租戶用過」，有就拒絕。

問題是這個「查」跟接下來的「寫」**不在同一個交易裡**。查詢走 `LicenseTenantResolver._query_scalar`，它開一個**全新的 session**、查完就 `rollback` 關掉（這是為了繞 RLS 讀跨租戶事實，設計上有理由）；而寫入是在呼叫端那個還沒 commit 的交易裡。

於是兩個租戶**同時**上傳同一張照時，兩邊都在對方 commit 之前查，兩邊都看到「沒人用過」，兩邊都寫進去。而資料庫層沒有任何 unique 約束能事後補救——我開了 `migrations/001-license-tables.sql` 核對，`license_id` 欄位（第 32 行）只是普通 `VARCHAR(64)`，兩支 index（`idx_tenant_licenses_tenant_current`、`idx_tenant_licenses_suspended`）都不涉及它。

### 觸發前提

- 攻擊者在同一套系統的兩個租戶各有登入帳號（或跟另一個租戶的人串通）
- 兩邊拿同一張驗章通過、且還沒在任何租戶落地過的照
- 兩個請求要卡在「查完」到「commit」之間的窄視窗內

前提偏嚴苛，掃描判 LOW 合理。

### 掃描給的修法有問題，不要照抄

掃描建議「加一個 `UNIQUE(license_id)`」。**這會擋掉正當流程**：D6 是 replace 制，同一個租戶換回舊照時會為同一個 `license_id` 再插一列（新列 `is_current=true`，舊列留著當歷史）。全表唯一索引會直接讓這條路炸掉，而「換回舊照」是程式碼註解明文承認的正當語意（`_store_verified_license` 第 118-119 行）。

**建議改成**：在 `license_id` 上加一個**部分唯一索引**，只約束現行照——

```sql
CREATE UNIQUE INDEX CONCURRENTLY idx_tenant_licenses_license_id_current
    ON config.tenant_licenses (license_id) WHERE is_current = true;
```

這樣「一張照同時只能有一個租戶正在用」由資料庫保證，而歷史列不受影響。落地時接住 `IntegrityError` 轉成既有的 `LICENSE_ALREADY_USED_BY_OTHER_TENANT`。

⚠️ **建索引前要先查現有資料**：如果 DEV／STG／POC 現在就有同一 `license_id` 兩列現行照，索引會建不起來——那本身就是這個洞已經被踩到的證據，要先清。

---

## 面板否決的兩條（記錄備查，不列為發現）

| ID | 面板票數 | 說明 |
|---|---|---|
| F3 | 1:2 否決 | 研究員回報但兩位驗證者不認同；未進最終清單 |
| F4 | 0:3 否決 | 三票全否 |

工作檔（含被否決條目的完整內容）已隨 plugin 的 renderer 清理流程刪除，此處僅保留票數紀錄。

---

## 卡片點名的七個重點，各自結果如何

卡片列了七個「首腦讀過程式碼後認為最容易出事的地方」。誠實區分「讀過但沒找到」與「根本沒讀到」：

| 卡片重點 | 結果 |
|---|---|
| **端點守門是否有漏掛** | ✅ **我人工核對過，沒有漏掛**。`api/routes.py` 的 14 條路由每條都標了認證等級，`plugin.py:336-338` 的迴圈對每條無條件套 decorator，`_guard()` 對五個 HTTP method 全套。主專案 `api/license/__init__.py` 注入的 `admin_required` 是 `jwt_required() + require_platform_admin_route`。**五支 `login` 級端點是刻意的防死鎖設計**，不是漏掛——但正是它讓上面那條停權繞過成立 |
| **跨租戶（能否把照塞進別人租戶）** | ✅ 有擋，但擋法有競態（＝F2）。寫入租戶取自 JWT（`ctx.tenant_id`），呼叫端指定不了 |
| **同一張照多租戶／多機器重放** | ⚠️ 多租戶＝F2；多機器由機器指紋擋，但**僅 host 版**，SaaS 版設計上不綁機器 |
| **狀態機的卡住／跳過路徑** | ⚠️ **找到一條：停權可被換照清掉**（＝上面的額外發現）。`suspended_at` 的清空條件我追過了——正規路徑只有 `unlock_tenant`（admin 級），但換照建新列等於變相清空 |
| **兩支 migration 與 RLS** | 讀過，policy 形狀正確（super-admin bypass ＋ allowed_tenant_paths 前綴比對）。與 jedi-common fail-open（CM-1559）的疊加效果**未做針對性推演**——那屬 jedi-common 的問題，不在本棒範圍 |
| **向 LC 請照的 SSRF** | ✅ **不成立**。三處 outbound 呼叫（`tenant_license_admin_service.py:222/261`、`license_verification_service.py:322`）的 base URL 全部來自 `LicenseConfig.activation_server_url`（部署設定），path 是程式碼寫死的字串常數，呼叫端指定不了。API token 同樣來自設定 |
| **`plugin.py` 的預設值 fail-open 還是 fail-closed** | 研究員沒產生候選。我順手看了幾個關鍵的：`is_platform_admin` / `licensed_job_types` 未注入時降級為 `False` / 空清單（fail-closed，正確）；但 `deployment_mode` 預設 `"saas"` 是 fail-open 方向——落地版若沒設對，機器綁定**靜默失效**（`api/license/__init__.py` 已用紅字警告，屬已知並接受的設計） |

**未涵蓋的部分**：`low` effort 只派研究員通掃，不保證對每個假設做過針對性推演。上表中標「讀過但沒找到」的項目，檔案確實在研究員讀取範圍內（33 檔全在 scope），但沒回報不等於已排除。

---

## 給決策者的三件事

1. **停權繞過（額外發現）建議直接開卡修**，優先於掃描報出的兩條。它是本棒最有實際影響的發現，而且掃描沒抓到——下一輪也不會抓到。
2. **F2 若要修，不要用掃描建議的全表唯一索引**，改用上面的部分唯一索引；建索引前先查三環境有無既存重複列。
3. **F1 不必重複處理**，L1 已完整記錄。

