---
title: O3a 檢查結果：Excel 上傳入口與確認流程
---

# O3a 檢查結果：Excel 上傳入口與確認流程

> 檢查日期 2026-09-21｜掃描耗時 1 小時 50 分｜對應卡片 CM-2003

---

## 🔴 一句話結論

**派工時指定要追的那條線，追到了——但真正的洞不在原本預判的位置。**

Excel 匯入有三條去路，其中**兩條有權限檢查、第三條完全沒有**。走那條沒檢查的路，**任何登入者（連只有唯讀權限的稽核員都算）可以把別家公司、甚至原廠公版的合規範本整份清空，換成自己上傳的檔案**。而系統裡改同一份資料的正規入口，是有要求權限的。

另外一條中風險：Excel 上傳只擋「壓縮後 10MB」，但**讀檔時是整份塞進記憶體**——一個精心壓縮的小檔可以讓落地版的容器被系統殺掉，整個產品停擺。

---

## 這一棒在檢查什麼

使用者把 Excel 檔傳上來 → 系統存起來並開一張「解析工作單」→ 使用者看預覽 → 按下確認 → 資料才真的寫進系統安全計畫。這一棒掃的就是**「誰能傳、誰能看預覽、誰能按確認」**這條路，共 5 支檔、1,460 行。

**不含**把 Excel 檔案內容拆開來讀的那七支程式（那批是 O3b）。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 問題 1（覆寫既有資源庫無權限檢查）＝M11 第 2 條，✅ 已修（CM-2178，commit `abf1cf8a4`；前半 CM-2040 `60015232f`，1.21.0 出貨）。
> - 問題 2（Excel 壓縮炸彈）＝M11 第 23 條，✅ 已修（CM-2181，commit `48638cbdb`／套件 `2cb9d2cd`，1.21.0 出貨）。

## 找到什麼：2 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🔴 **高** | **覆寫既有資源庫這條路，從頭到尾沒有權限檢查**。上傳時只問「這個編號指到的範本存在嗎」，存在就放行；按確認時直接清空那份範本再寫入 | 任何登入者可以把**別家公司或原廠公版**的合規範本整份清空重寫——控制項清單、每條控制項的實作說明、相關人員與角色全部連鎖刪除，攻擊者上傳的檔案成為新的合規基準 | ① 任何一個登入帳號（**不需要任何範本相關權限**）<br>② 一個範本編號（從列表就拿得到，列表也沒有權限檢查）<br>③ 一份格式正確的 Excel（範本檔產品裡可下載） | `app/oscal/service/ssp_excel_import_app_service.py:478`（確認端）<br>`:816-817`（上傳端） |
| 2 | 🟡 中 | **上傳只擋壓縮後的大小，讀檔時卻整份塞進記憶體** | 一個 10MB 以內、但解壓後膨脹到幾 GB 的 Excel，可以把容器的記憶體吃光。落地版是單一容器，**被系統殺掉就是整個產品停擺** | 任何一個登入帳號 ＋ 一個刻意壓縮過的 Excel 檔 | `app/oscal/service/excel_parser/parser.py:42`<br>（入口在 `ssp_excel_import_app_service.py:172`，⚠️ 這支檔屬 O3b 範圍） |

---

## 詳細說明

### 問題 1：覆寫既有資源庫這條路沒有權限檢查（高）

**白話**：Excel 匯入完，資料可以寫到三個地方：

| 去路 | 寫到哪 | 有檢查嗎 |
|---|---|---|
| 寫進某個專案的計畫（`ssp`） | 專案的系統安全計畫 | ✅ 有，要求是該專案的負責人 |
| 新建一個資源庫（`framework_version`） | 新的範本 | ✅ 有，驗框架版本存在 |
| **覆寫既有資源庫（`module_frame`）** | **既有範本，先清空再寫** | ❌ **完全沒有** |

第三條路是這樣寫的：

```python
# 上傳端 app/oscal/service/ssp_excel_import_app_service.py:803-817
def _verify_source_exists(self, source_type, source_uid):
    if source_type == "framework_version":
        ...驗框架版本存在...
    elif source_type == "ssp":
        self._require_project_manager(source_uid)      # ← 這條有守門
        ...
    else:  # module_frame
        self._resource_library.get_resource_library(source_uid)   # ← 只驗「存在」
```

`get_resource_library` 只在**那筆資料不存在**時才會擋，存在就回傳資料、放行。它問的是「有沒有這個東西」，不是「你能不能動這個東西」。

確認端更直接：

```python
# app/oscal/service/ssp_excel_import_app_service.py:475-481
rl = self._resource_library.get_resource_library(job.source_uid)
template_ssp_id = (rl or {}).get("ssp_template_id")
...
self._oscal_io.clear_ssp_body(template_ssp_id)      # ← 第 478 行，直接清空
counts, warnings = self._fill_template_ssp(...)     # ← 換成攻擊者的檔案
```

**中間沒有任何一行在問「你有權限動這份範本嗎」。**

#### 「清空」清掉的是什麼

`clear_ssp_body` 不是清一張表，是連鎖刪除（實作在 `jedi-oscal-v2` 套件的 `oscal_io_service.py:299-318`）：

- 控制項實作區塊 → 連鎖刪掉**每條控制項的實作記錄、每段說明文字、每個元件對應**
- 系統實作區塊 → 連鎖刪掉**元件、被授權系統、資產清單**
- 系統特性區塊
- 該計畫底下**所有的人員與角色**

也就是「這家公司的每條控制項怎麼做到」的完整記錄，一次消失。

#### 對照：改同一份資料的正規入口是有守門的

```python
# api/module_frame/routes/module_frame_route.py:71-72, 83-84
@require_capability("module-frame.update")    # 改範本要這個權限
def put(self, uid, ...):

@require_capability("module-frame.delete")    # 刪範本要這個權限
def delete(self, uid, ...):
```

**同一份資料，走正門要權限，走 Excel 匯入不用。**

#### 攻擊怎麼進行

1. 攻擊者用任何一個登入帳號——**唯讀稽核員就夠**，不需要任何範本相關權限。
2. 打 `POST /api/1.0/oscal/resource-libraries/list` 撈範本編號。這個端點也只驗登入（`api/oscal/routes/resource_library_route.py:26`）。
3. 從產品裡下載 Excel 範本檔，填一列垃圾資料。
4. `POST /api/1.0/ssp-excel-imports/parse`，帶 `source_type=module_frame` + `source_uid=<別人的範本編號>`。上傳端只驗存在 → 通過，回傳一個解析編號。
5. `POST /api/1.0/ssp-excel-import/<解析編號>/confirm`，內容送 `{}`。
6. 第 478 行清空那份範本，換成攻擊者的檔案。

#### ⚠️ 這裡有一道「看起來有守、實際守不到」的檢查

確認端第 320 行有一道公司歸屬檢查：

```python
if job.tenant_id != user_context.tenant_id and not user_context.is_admin:
    raise NotFound(...)
```

看起來像在擋跨公司操作，**但它比的是「這張解析單是不是我建的」**——解析單是攻擊者自己建的，公司當然是自己的，永遠通過。**真正要比的是「那份範本是不是我公司的」，而那個從來沒比過。**

這是和 O1 棒同一類的病：**呼叫了檢查、但檢查的對象不是要動的那個東西。**

#### 跨公司的部分：資料庫層也沒有第二道關卡

範本這張表（`compliance.module_frames`）的資料庫層讀取規則，**刻意讓原廠公版對每家公司都可見、母公司分享的範本對子公司可見**：

```sql
-- scripts/init/02-schema.sql:24323
CREATE POLICY module_frames_select ... USING (
  scope = 'SYSTEM'                              -- 原廠公版：所有公司都看得到
  OR ...app_tenant_allowed_for_session(tenant_id)
  OR (scope = 'SHARED' AND ...is_ancestor_of_session(tenant_id))  -- 母公司分享的
);
```

而寫入規則（`module_frames_update` / `module_frames_delete`）明確擋掉了改原廠公版——**但這次的破壞根本沒動到這張表**，動的是 `oscal.*` 那批放計畫內容的表。實際數過：

```bash
$ grep "ALTER TABLE oscal\..* ENABLE ROW LEVEL SECURITY" scripts/init/02-schema.sql
ALTER TABLE oscal.ap_docx_parse_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE oscal.ar_xlsx_parse_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE oscal.framework_parse_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE oscal.ssp_docx_parse_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE oscal.ssp_excel_parse_jobs ENABLE ROW LEVEL SECURITY;
```

**只有五張「解析工作單」表有資料庫層防護，被清掉的那批計畫內容表一張都沒有。** 程式這一層是唯一的關卡，而它沒關。

（這與 O2 問題 1 的結構相同：警衛程式存在、資料庫沒有第二道、而這條路徑沒叫警衛。）

#### 怎麼修

隔壁的 Word 匯入**已經解過同一題**，照抄就好：

```python
# app/oscal/service/ssp_docx_import_app_service.py:161-162
if not viewer_has_capability("module-frame.create"):
    raise ForbiddenError(FlowControlErrorCode.GRC_DOCX_NO_PERMISSION_FOR_SOURCE)
```

那支檔的註解還寫明了為什麼用 `viewer_has_capability` 而不是網址入口的裝飾器：**這個判斷夾在分支中間，要先知道是哪條去路才知道要不要判，入口那層表達不了。**

具體兩件事：

1. **要權限**：`_verify_source_exists` 的 `module_frame` 分支與 `_confirm_update_module_frame` 兩處，都要求 `module-frame.update`。
2. **要驗歸屬**：把撈出來的那份範本的公司，跟呼叫者的公司比。不同就拒絕；是原廠公版（`SYSTEM`）直接拒絕。判法照抄 `common/authz/sharing.py` 既有的。

**兩處都要補，確認端是關鍵那一道**——只補上傳端的話，攻擊者用另一個 `source_type` 建的解析單一樣能繞過（`confirm_import` 會照 job 裡存的 `source_type` 重新分派）。

---

### 問題 2：Excel 壓縮炸彈（中）

**白話**：Excel 檔（.xlsx）其實是一個壓縮檔。系統只檢查**壓縮後**的大小不超過 10MB，然後用「整份讀進記憶體」的方式打開它。

一個壓縮後 10MB 的檔，解壓後可以膨脹到幾 GB——只要裡面宣告「我有幾億個格子」就行。

```python
# app/oscal/service/ssp_excel_import_app_service.py:124-126（上傳端唯一的大小檢查）
file_size = self._get_file_size(file)
if file_size > _EXCEL_MAX_SIZE:          # _EXCEL_MAX_SIZE = 10MB，壓縮後的大小
    raise BadRequestError(...)

# app/oscal/service/excel_parser/parser.py:42（讀檔端）
wb = load_workbook(file_path, data_only=True, read_only=False)
#                                            ↑ 非串流模式：整份建成記憶體物件
```

**為什麼落地版特別嚴重**：`config/config.py` 對另一個設定的註解自己就寫了——「落地版單一容器，撐爆記憶體會讓容器被系統殺掉、整個產品停擺」。那個設定擋的是 HTTP 請求的大小，擋不到解壓後的工作簿。而解析是**在請求裡同步做的**，打幾次就夠。

**每張工作表 5000 列的上限救不了**——那個檢查在整份已經進記憶體之後才跑。

#### 怎麼修

兩件事，都不大：

1. **解析前先當壓縮檔檢查**：用 Python 內建的 `zipfile.ZipFile(path).infolist()` 把解壓後的總大小加起來，超過上限（例如 200MB 或膨脹倍率 100 倍）就拒絕。
2. **改成串流讀取**：`load_workbook(..., read_only=True)`。既有的 `_iter_data_rows` 本來就是一列一列走，改了不影響邏輯。

**Word 匯入那條線同樣只擋壓縮後大小**，要一起補。

---

## 🔵 我親手核對的部分（含兩處修正掃描工具的說法）

派工卡要求高風險每條自己開檔核對。上面每一行引用都開檔讀過了，另外**修正了掃描工具兩處說法**：

### 修正一：解析編號猜不到

派工卡問「解析編號（`parse_uid`）是不是流水號，可不可以猜」。**不是流水號**：

```python
# domain/oscal/service/ssp_excel_parse_job_domain_service.py:29
entity = SspExcelParseJobEntity(
    uid=str(uuid.uuid4()),     # ← 隨機碼，不是流水號
```

所以「偷別人的解析編號」不是可行的攻擊路徑。**問題 1 的攻擊者不需要偷——他自己開一張解析單，指向別人的範本就好。**

### 修正二：「預覽與確認沒綁專案」這個不對稱，本身不是缺口

派工卡點名要查這條。**確認不對稱存在**：

```python
# api/oscal/__init__.py:110-111
# api.add_resource(SspScopedExcelImportRoute, '/ssp/<ssp_uid>/excel-import/<parse_uid>')
# api.add_resource(SspScopedExcelImportConfirmRoute, '/ssp/<ssp_uid>/excel-import/<parse_uid>/confirm')
```

上傳有綁專案的入口，預覽與確認只有不綁專案的那條。

**但這個不對稱是被處理過的**——`ssp` 那條去路的權限檢查**刻意下移到商業邏輯層**，正是為了涵蓋不綁專案的路徑。檔案裡的註解寫得很清楚：

```python
# app/oscal/service/ssp_excel_import_app_service.py:769-776（_require_project_manager 的說明）
"""P3 security review（2026-06-16）：把 manager gate 從 scoped route 下移到
app service 層，讓通用 ungated route（`/ssp-excel-imports/parse` +
`/ssp-excel-import/<uid>/confirm`，僅 `@jwt_required()`）也受保護。"""
```

實際確認三處都有：上傳 `:812`、預覽 `:257`（要求參與者）、確認 `:520`（要求負責人）、廢棄 `:298`。

**所以那條網址不是缺口，缺口是同一個分派點上的另一條分支（`module_frame`）沒有跟著補。** 2026-06-16 那次安全檢討把 `ssp` 那條補齊了，`module_frame` 沒人管。

### 另外確認的：這一棒沒有 O1 那種「呼叫了檢查卻丟掉結果」

派工卡要求照 O1 的角度整棒檢查。逐支公開方法核對的結果：

| 方法 | 有沒有守門 | 守什麼 |
|---|---|---|
| `upload_and_parse` | ✅ 部分 | `ssp` 要負責人；`framework_version` 驗存在；**`module_frame` 只驗存在** ← 問題 1 |
| `get_parse_result` | ✅ | 公司比對 ＋ `ssp` 要參與者 |
| `discard_parse` | ✅ | 公司比對 ＋ `ssp` 要負責人 |
| `confirm_import` | ⚠️ | 有一道公司比對，**但比的是解析單不是目標範本**；`ssp` 分支再要求負責人，**`module_frame` 分支沒有** ← 問題 1 |

O1 那兩條是「檢查了 A、動手動 B」。**這一棒不是那個形狀**——`module_frame` 那條是從頭到尾沒檢查。倒是 `confirm_import:320` 那道公司比對屬於相鄰的陷阱：看起來在擋跨公司，實際擋不到目標資源，容易讓後續讀程式的人以為這裡有守。

### 其他派工卡要求查、但沒發現問題的

- **檔案暫存與清理**：解析失敗有 `_safe_unlink` 清暫存檔（`:175`），路徑用 `tempfile.NamedTemporaryFile` 產生，**不吃使用者給的檔名**，所以沒有「檔名帶 `../` 寫到別的目錄」的問題。使用者的檔名只用在副檔名判斷與顯示。
- **解析單存活時間**：`confirm_import:325` 與 `get_parse_result:261` 都有過期檢查，過期後不能確認。
- **上傳檔案大小上限**：有擋，但擋的層次不對 → 就是問題 2。

---

## 掃描執行概況

| 項目 | 實際情形 |
|---|---|
| 範圍 | 5 支檔、1,460 行（Excel 上傳入口與確認流程） |
| 修訂版本 | `bac27ddd`（branch `feature/review`，工作區有未提交改動） |
| 工具設定 | `effort=low`、`focus=attack-surface` |
| 派出／回報 | **研究員 2 派 2 回，零重試**（沒撞到那個會誤殺研究員的上游 bug） |
| 候選 | 3 條，去重後 2 條 |
| 審查面板 | ✅ **完整**：6 票全投（3 位審查員 × 2 條）、零漏投、**兩條都 3:0** |
| 驗證章 | `verification.status: verified` |
| 耗時 | 1 小時 50 分 |
| 產物 | `CLAUDE-SECURITY-20260921-081023/`（未入版控，該目錄自帶 `.gitignore`）|

**說明**：這一輪是快速檔（`low`）的單一研究員掃描 ＋ 完整面板驗證。範圍外的 `excel_parser/parser.py` 是順資料流追出去撞到的（問題 2 的位置），**那支檔屬 O3b 範圍**，這裡只報，不代表 O3b 掃過了。

掃描過程**沒有執行任何程式碼**——沒跑測試、沒發實際攻擊、沒驗證過概念驗證程式。每一條發現與每一處核對，都來自讀這個修訂版本的原始碼。
