⚠️ 歷史紀錄:本報告描述的問題已於 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);範圍 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。
白話: 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 沒有設定。
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 |
wf_65f8e735-360749d3b4,branch ws-CM-3507-CM-3516,工作目錄乾淨verification.status:verified;reason_kind:nullcandidates:2;panel_votes:6;researchers_dispatched:1;researchers_returned:1docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-165639-B1/(原目錄名與 B2、B3 同秒撞名,加 -B1 後綴區分)