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

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

§1

🔴 一句話結論

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

§2

這一棒在檢查什麼

主控台(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,只用來查證,不當成發現。

§3

掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
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。

§4

每條發現的詳述

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 是刻意的設計。所以這條要先由決策者判斷:是改程式,還是改規範。

§5

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

卡上重點 人工結果 為什麼
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
§6

可信度兩層

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

執行概況(照 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 後綴區分)