# FR-177 B1 掃描報告：主控台維運與執行紀錄路由

> ⚠️ **歷史紀錄**：本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線（修正卡 CM-3559～3564、3573～3576、3584，驗收紀錄見母卡 CM-3507）。文中的檔名與行號是掃描當下（commit `bab2aa3`）的版本，修正後已變動，請勿拿來定位現行程式。

## 🔴 一句話結論

**沒有急件。** 找到 2 個問題：1 個中、1 個低。最嚴重的是「登入入口 `POST /console/session` 收 body 沒有大小上限」，連得到 8444 的人不用登入，就能送超大請求把整支 Agent 的記憶體吃光（資料面、心跳、主控台一起掛）。另一條是「下載診斷包只要 `configure` 權限，沒有要求 `download-files`」。兩條都經三位獨立檢查員投票確認（3:0、2:1）。共用守門殼 `require_session` 本身沒有發現洞。

## 這一棒在檢查什麼

主控台（8444 埠，客戶 IT 人員用瀏覽器操作的那一面）登入之後的部分：

- 守門殼 `require_session`：所有主控台路由共用的唯一一道門，負責檢查登入與權限（scope，就是「這張票被授權做哪些事」）；
- 頁面與靜態資產出口（`pages.py`）；
- 維運路由（重啟、離線登入開關、改名、檢查漏洞庫／規則包／授權清冊、下載診斷包、儲存設定）；
- 執行紀錄、日誌、金鑰頁；
- console 模組只掛在 8444、不掛到 8000／8443 這件事。

範圍 7 檔：`guidant_agent/console/__init__.py`，以及 `guidant_agent/console/api/` 底下的 `__init__.py`、`pages.py`、`ops_routes.py`、`runs_routes.py`、`log_routes.py`、`keys_routes.py`。為了查證呼叫端，有翻範圍外的 `body_limit.py`、`audit_pipeline.py`、`cert_renew.py`、`support_bundle.py`、`file_protect.py`、`secret_form.py`、`files/api/browser_plane.py`、`platform/registry.py`，**只用來查證，不當成發現**。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 登入入口收 body 沒有大小上限 | 沒登入的人送幾個好幾 GB 的請求，Agent 記憶體爆掉，整支程式停擺（含 8443 資料面與心跳） | ①Agent 已註冊（正常狀態）②來源 IP 在 8444 允許網段內（預設不限） | `guidant_agent/console/api/__init__.py:96` | 改用 `body_limit.read_limited(小上限)`＋`json_body()`；8444 app 加 `MAX_CONTENT_LENGTH` 當最後一道 | 中 |
| F2 | 下載診斷包只檢查 `configure`，沒檢查 `download-files` | 有 `configure`、刻意沒給 `download-files`／`net-diag` 的人，能把含主機網路拓撲、網路除錯結果、log 尾段（密碼已遮）的 zip 帶走 | 已經登入，而且票上有 `configure` | `guidant_agent/console/api/ops_routes.py:147` | 加 `@require_session("download-files")`（照 `export_logs` 的寫法）；網路區段看是否另要 `net-diag` | 低 |

**為什麼 F1 是「中」不是「高」：** 它只能讓 Agent 停擺，拿不到資料，也改不了東西；8444 有來源網段白名單可以收緊。**為什麼 F2 是「低」：** 攻擊者本來就是有 `configure` 的內部人員，診斷包內的祕密已經遮過，外洩的是網路資訊與 log。

## 每條發現的詳述

### F1 — 登入入口收 body 沒有大小上限

**白話：** `POST /console/session` 是登入用的，還沒登入就能打。它用 `request.get_json()` 把整個 body 一次讀進記憶體。其他子路由（ops、scan、netdiag、local_auth）都有掛 `body_limit.guard` 限制大小（SR-04.1），只有 `console_bp` 本體沒掛，8444 app 也沒有設 `MAX_CONTENT_LENGTH`，所以送多大就收多大。

**出事會怎樣：** 8444 與資料面、心跳是同一個程序。記憶體吃光，整支 Agent 被系統砍掉或卡死，期間收不到主系統派工，也收不到撤銷。

**要先有什麼才打得到：** 連得到 8444 就好，不需要任何帳號或票。

**在哪裡：** `guidant_agent/console/api/__init__.py:96`（`create_session`）。

**怎麼修：** `create_session` 開頭呼叫 `body_limit.read_limited(8 * 1024)`，再用 `body_limit.json_body()` 取值；8444 app 加 `MAX_CONTENT_LENGTH` 當備援。

**runner 開檔核對：** 成立。第 96 行確實是 `request.get_json(silent=True)`，前面只有「是否已註冊」的檢查；`console_bp` 沒有 `before_request` 上限；全 repo grep `MAX_CONTENT_LENGTH`，`files/api/browser_plane.py` 沒有設定。

### F2 — 下載診斷包沒有要求 `download-files`

**白話：** 規範（`.claude/rules/security/uploads-and-output.md`）寫明「讓使用者把檔帶走」的出口 `send_protected` 都要守 `download-files`，診斷包也點名在內。日誌匯出（`log_routes.py:71-72`）有照做；診斷包只守 `configure`。

**出事會怎樣：** 主系統把「改設定」和「下載檔案」拆成兩個權限，就是為了讓有些人只能設定、不能把東西帶走。這條讓這種拆法對診斷包失效。

**要先有什麼才打得到：** 已經登入，而且票上有 `configure`。

**在哪裡：** `guidant_agent/console/api/ops_routes.py:147-158`（`download_support_bundle`）。

**怎麼修：** 加 `@require_session("download-files")`；網路區段決定要不要另外要求 `net-diag`，沒有就不放進包裡；把這條路由補進 scope 對表守衛。

**附註：** 三位檢查員中有一位投「不成立」，理由是程式註解（`ops_routes.py:4-5`）表示只守 `configure` 是刻意的設計。所以這條要先由決策者判斷：是改程式，還是改規範。

## 「重點看什麼」逐條人工查證

| 卡上重點 | 人工結果 | 為什麼 |
|---|---|---|
| `require_session(scope)` 的 scope 是字串或 tuple 時怎麼判 | 不成立（沒問題） | `__init__.py:54`：字串包成單元素 tuple，tuple 任一命中就放行，`None` 是空 tuple，只驗登入。寫入動作都給單一 scope |
| 不帶 scope 只驗登入的路由，是不是都該這樣 | 不成立 | 共 5 處：`/api/me`、`/api/overview`（內容依 scope 增減）、`/api/runs`（依 `_LIST_SCOPE` 過濾）、`/api/runs/<uid>/resend`（函式內依紀錄種類查 scope，查無回 404）、`local_auth_routes.py:118`（A6 已掃）。都在函式內自己再分權限 |
| `acting_as` 裝進 g 之後有沒有清 | 不成立 | `audit_pipeline.py:56-66` 的 `finally` 會還原或 pop。Flask 的 `g` 綁在每個請求的 app context 上，請求結束就丟，執行緒重用時撿不到上一個人的身分 |
| CSRF：有沒有 token，SameSite=Strict 夠不夠 | 不成立（可接受） | 沒有 CSRF token，靠 cookie 的 `SameSite=Strict`、`path=/console`。同站的別的頁可以發請求，但 8444 只跑主控台本身，而 `browser_plane.py:146` 的 CORS 不對 `/console` 回 `Allow-Origin`。寫入路由都用 JSON body 的 POST／PUT／DELETE。建議之後加一道 Origin 檢查當縱深防禦，不列為發現 |
| `pages.py` 的 `/<path:page>`、`/assets/<path:filename>` 能不能跳出目錄 | 不成立 | `spa_page` 只接受白名單 `_SPA_PAGES` 內的名字。`assets` 用 `send_from_directory`，它內部的 `safe_join` 會擋 `..` 與絕對路徑，`Path(...) / "assets"` 只拼固定的根目錄，不碰使用者輸入 |
| CSP nonce 每次請求都是新的嗎 | 不成立 | `pages.py:43`：每次 `_index()` 都用 `secrets.token_urlsafe(16)` 產新的 |
| 診斷包 `Content-Disposition` 檔名注入、尺寸上限 | 檔名不成立；權限部分見 F2 | 檔名由伺服器時間組成（`support_bundle.py` 的 `guidant-agent-support-%Y%m%d-%H%M%S.zip`），不含使用者輸入。log 尾段與最近診斷筆數由 `support_bundle` 自己限制（B7 掃）。權限不足這點即 F2 |
| `reset_storage` 是 POST 加 configure 嗎，有沒有二次確認 | 部分成立，風險低 | 是 `DELETE` 加 `configure`（`ops_routes.py:232-233`），有稽核。後端沒有二次確認，但只是退回安裝時的設定，不會刪掉證據檔。二次確認屬於前端體驗，不列為發現 |
| `put_offline_login`／`put_agent_name`／`put_storage` 的欄位白名單，`body_limit` 上限 | 不成立 | 三支都只取指定欄位，沒有 `**body` 展開；`put_storage` 經 `secret_form.validate`，不認得的 key 直接報錯（`secret_form.py:82`）。ops 的上限 32 KiB（`ops_routes.py:32`），PUT 也擋 |
| runs／logs 有沒有依 `started_by` 過濾、log 有沒有遮罩、分頁 limit | 不成立（設計如此） | runs 依 scope 分種類，不依人分，主控台定位是「這台的維運者都看得到這台的紀錄」。logs 要四種 scope 任一，message 在寫入時已遮（`audit_log.py:147`）。分頁 size 上限 100／200，offset 上限 10,000；logs 的 `page` 上限 1,000,000，是靠 SQL offset，代價可接受 |
| `renew_now` 換證頻率限制 | 不成立（風險低） | `cert_renew.request_now` 正在換或已排就回 `IN_PROGRESS`（409），同一時間只會有一次。換完可以立刻再按，沒有冷卻時間，但需要 `configure` 權限，主系統簽發端也應該有自己的限流。建議之後補冷卻時間，不列為發現 |
| console 只掛 8444、沒掛到 8000／8443 | 不成立 | `console/__init__.py` 的 `register()` 是空的；blueprint 只在 `register_browser_plane` 裡由 `platform/registry.py:88` 的 `mount_browser_plane` 掛到 8444 app |

## 可信度兩層

- **「這些問題存在嗎」：** 可以回答。F1 3/3、F2 2/3 經檢查員確認，F1 也由 runner 開檔核過。
- **「只有這些嗎」：** low effort 只派一位研究員讀這 7 檔，沒有做威脅建模，也沒有廣度掃描。上面那張表是 runner 人工補的一輪，不能宣稱「全部找完了」。所有結論都是讀程式得出的，沒有實際執行或打過。

## 執行概況（照 stamp 抄）

- Workflow run ID：`wf_65f8e735-360`
- 掃描版本：`749d3b4`，branch `ws-CM-3507-CM-3516`，工作目錄乾淨
- effort：low，scope 7 檔，耗時約 22 分鐘（1325 秒）
- `verification.status`：**verified**；`reason_kind`：null
- `candidates`：2；`panel_votes`：6；`researchers_dispatched`：1；`researchers_returned`：1
- 歸檔：`docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-165639-B1/`（原目錄名與 B2、B3 同秒撞名，加 `-B1` 後綴區分）
