798a9b5f94a2816767a5d3a1fe78bf61123a9ac5(工作區有未提交改動,屬平行 session 的檔,與本棒無關)claude-security plugin 0.11.0,effort low掃完了,找到 2 個問題,都是同一個病:有三個「看資料」的網址入口只檢查「你有沒有登入」,沒有檢查「你有沒有資格看這個」——任何一個登入帳號都能把合規範本裡的內容讀走,其中一條還能順著讀到的編號把範本的程序書檔案整份下載下來。
兩條都屬中等嚴重度(MEDIUM),不是最高級,原因寫在第 4 節每條的「為什麼是中等不是高」。
系統裡有一種東西叫「合規範本」——把一套法規(例如 ISO 27001)先做成範本,之後每個客戶專案要做稽核時,直接從範本套用,不用每次從零填。
範本底下掛了三組清單:
這一棒把這三組各自的「網址入口 → 資料檢查 → 商業邏輯 → 資料傳遞物件」四層共 12 支檔看完,逐支對外方法核對一件事:每個入口有沒有檢查「這個人有沒有權限做這件事」。
要先講清楚一個本專案的正規寫法,免得誤會:本專案允許「入口只驗登入、真正的權限判斷寫在下一層商業邏輯裡」。所以看到入口只有「要登入」不等於有洞,得往下追一層才知道。這次找到的兩條,是往下追過了、下一層也確實沒有檢查,不是誤判。
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- 發現 1(範本程序書清單無權限檢查)=M11 第 16 條,✅ 已修(CM-2186
aafaa070d;下載歸屬檢查 CM-2033a334f5385,1.21.0 出貨)。- 發現 2(稽核目標預設內容讀取無權限檢查)=M11 第 17 條,✅ 已修(CM-2186,commit
aafaa070d,1.21.0 出貨)。- 「檔案下載網址用一般權杖是否檢查擁有者資源權限」那張評估卡:已由 CM-2033 處理下載歸屬(M11 第 16 條),其餘查不到後續。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| 1 | 列出範本程序書清單的網址,沒有檢查這個人有沒有權限看範本 | 任何登入帳號都能列出範本掛了哪些程序書,而且清單裡附了檔案編號;拿那個編號去打下載網址就能把程序書整份抓走 | 只要有一組能登入的帳號(任何角色都行),再知道或猜到一個範本編號 | api/module_frame/routes/module_frame_reference_document_route.py:70 |
在該方法補上 @require_capability("module-frame.read"),位置放在 @jwt_required() 跟 @inject 之間 |
| 2 | 讀取稽核目標預設內容的兩個網址,同樣沒有檢查權限 | 任何登入帳號都能讀走範本裡每條稽核目標的完成狀態、實作說明、備註,以及掛了哪些文件 | 同上 | api/module_frame/routes/module_frame_control_objective_default_route.py:59 與 :119 |
兩支都補上 @require_capability("module-frame.read"),同樣位置 |
白話說明
系統裡每個網址入口前面會掛「檢查條」,像門口的警衛。這個入口只掛了一條「你要先登入」,沒掛「你要有看範本的權限」。同一支檔案裡,新增、修改、刪除那三個入口都有掛權限檢查——只有「列出清單」這個漏了。
更麻煩的是這個清單回傳的內容裡包含每份程序書的檔案編號(file_uid)。系統的檔案下載網址 /file/download/<編號> 在用一般登入權杖存取時,只認編號、不再檢查「這份檔案是不是你有資格看的」(見 core/plugins/file_upload.py:110)。所以讀到清單 ≈ 讀到檔案本身。
在哪裡
api/module_frame/routes/module_frame_reference_document_route.py:70,方法 ModuleFrameReferenceDocumentListResource.getapi/module_frame/routes/module_frame_control_default_route.py:51實際走一遍會怎樣
GET /api/1.0/module-frame/<範本編號>/reference-documents。GET /api/1.0/file/download/<檔案編號>,用同一組權杖,把程序書整份下載下來。要先有什麼才打得到
為什麼是中等嚴重度不是高
攻擊者必須先有一組合法帳號(不是外部陌生人打得到的),而且影響限於「讀」——不能改、不能刪。但因為任何角色都算數、而且可以一路讀到檔案本體,所以也不算低。
怎麼修
在 api/module_frame/routes/module_frame_reference_document_route.py 的 get 方法上,於 @jwt_required() 與 @inject 之間補一行:
@require_capability("module-frame.read")require_capability 已經在該檔第 36 行 import 進來了(同檔的 POST/PUT/DELETE 都在用),不需要額外改 import。
另外建議分開決策兩件事,不屬本棒範圍、不自行處理:
file_uid。首腦核對註記
已開檔核對:該 get 方法確實只有 @marshal_with / @jwt_required() / @inject 三條,第 70 行直接把網址參數丟進 list_pool();同檔第 84/117/146 行的三個寫入入口都有 require_capability。對照的 module_frame_control_default_route.py 四個入口(51/77/101/128)全部有掛。事實吻合。
白話說明
跟發現 1 完全一樣的病,換個地方。「讀取稽核目標預設清單」和「讀取單一筆稽核目標預設」這兩個入口都只檢查登入。回傳的是這套範本裡每條稽核目標的完成狀態、實作說明文字、備註,以及掛了哪些參考文件。
這條的對照特別明顯:同一支檔案裡的修改(PUT,第 73 行)跟刪除(DELETE,第 133 行)都有掛權限檢查,只有兩個讀取的漏掉。 而且隔壁做同一件事的「控制項預設清單」那支檔,四個入口全部掛齊。所以這不是設計上刻意開放,是漏掉。
在哪裡
api/module_frame/routes/module_frame_control_objective_default_route.py:59,方法 ModuleFrameObjectiveDefaultListResource.get(列出全部)api/module_frame/routes/module_frame_control_objective_default_route.py:119,單筆讀取的 get(裝飾器在第 108~110 行)實際走一遍會怎樣
一個低權限帳號(沒有 module-frame.read 這個權限)打 GET /api/1.0/module-frame/<範本編號>/objective-defaults,回傳整份稽核目標預設內容。同一個帳號去打隔壁的 /control-defaults,會被擋下回 403——同樣性質的資料,一個擋一個不擋。
要先有什麼才打得到
為什麼是中等嚴重度不是高
同發現 1:要先有帳號,且只能讀不能寫。比發現 1 略輕一點的是,這條沒有「順勢下載檔案」的延伸路徑,洩漏範圍就是範本文字內容本身。
怎麼修
兩支 get 都補上同一行,位置同樣在 @jwt_required() 與 @inject 之間:
@require_capability("module-frame.read")首腦核對註記
已開檔核對:第 49~51 行(列表 get)與第 108~110 行(單筆 get)都只有 @marshal_with / @jwt_required() / @inject;同檔 PUT 與 DELETE 有掛。事實吻合。
卡片要求的不是「找沒守門的端點」,是「逐支公開方法核對守門齊不齊」。以下是核完的全貌,包含查了之後確認沒問題的。
module_frame_reference_document_service.py,257 行,8 支對外方法)盤點時的紅旗是「整支檔 grep 權限關鍵字零命中」。核對後的結論:這支檔零命中是正常的,不是洞。 原因是本專案的分工——「你有沒有權限做這件事」寫在網址入口那層(用 require_capability),這支商業邏輯檔負責的是另一個問題:「你指定的這筆資料,是不是真的屬於你指定的那個範本」。
八支方法逐支核對「有沒有檢查歸屬」的結果:
| 方法 | 有沒有檢查歸屬 | 說明 |
|---|---|---|
list_pool |
有(間接) | 先經 _template_ssp_id() 解析範本,只撈該範本底下的池 |
upload_to_pool |
有 | 同上,寫入綁在解析出來的範本 |
update_meta |
有,且檢查得精準 | 第 133 行明文比對 existing.context_id != ssp_id,不符就 404 |
delete_from_pool |
有,且檢查得精準 | 第 151 行同樣比對 context_id != ssp_id,不符就 404 |
attach_to_control_default |
有 | 經 _get_doc_or_404(ssp_id, doc_uid),該 helper 第 200 行比對 context_id != ssp_id |
detach_from_control_default |
有 | 同上 |
attach_to_objective_default |
有 | 同上 |
detach_from_objective_default |
有 | 同上 |
卡片擔心的「檢查 A、動手改 B」在這支檔沒有發生。 每一支都是先解析出網址上那個範本對應的 SSP 編號,再用那個編號去驗證使用者另外送上來的文件編號確實屬於同一個範本,不符就 404。這正是卡片要找的形狀——只是這裡做對了。
刪除路徑(卡片特別點名,對應跨 arc 總表第 133 項)的結論:delete_from_pool 的歸屬檢查是完整的,沒有「刪到別人家東西」的洞。
| 檔案 | 權限關鍵字命中 | 對外方法數 | 判讀 |
|---|---|---|---|
module_frame_control_default_service.py |
1 | 4 | 正常,權限守在入口層,這層做歸屬檢查 |
module_frame_control_objective_default_service.py |
1 | 4 | 同上;四支方法都先走 _resolve_template_ssp_id() 再動作 |
module_frame_reference_document_service.py |
0 | 8 | 正常,理由見 5.1 |
結論:落差檢查在這批三支檔上都是假警報,真正的缺口不在商業邏輯層,在入口層——就是第 4 節那兩條。
delete_from_pool 刪除程序書時,沒有檢查這份文件是不是還有控制項或稽核目標正在引用它。底層 delete_by_uid(jedi-compliance-audit 套件的 ssp_reference_document_repo_impl.py:66)是直接 session.delete(model)。
這跟跨 arc 總表第 133 項(刪框架不檢查還有沒有客戶在用)是同一種形狀,但性質不同:這裡刪的是自己範本底下的文件、刪的人已經通過權限檢查,所以不是越權,是「可能刪出孤兒關聯」的資料完整性問題。列在這裡供決策者判斷要不要另開一般性 bug 卡,本報告不把它算進資安發現數。
分兩層講,這兩層可信度差很多。
第一層:「這兩條真的存在嗎」——可信度高。
兩條發現都經過三個獨立檢查員投票:一個查「打不打得到」、一個查「打到了會怎樣」、一個查「有沒有別的機制其實擋住了」。兩條各 3 票全數認定成立,六票沒有一票反對。 而且本棒收工前我自己開檔核對過行號與裝飾器內容,事實吻合(核對註記寫在每條末尾)。
要注意:全程沒有執行任何程式,沒有真的發一個請求去打那個網址、沒有跑測試、沒有做攻擊驗證。結論全部來自讀程式碼。要百分之百確認,需要人工實際用低權限帳號打一次那三個網址。
第二層:「只有這兩條嗎」——可信度中等,不能當成「這塊掃乾淨了」。
low,意思是「一個研究員把 12 支檔讀完,再交給三人面板驗證」,沒有跑資產盤點、沒有建威脅模型、沒有做廣度橫掃。這是快速定點掃描,不是窮盡式檢查。所以這份報告能說的是「這 12 支檔裡有這兩個洞」,不能說「這塊已經安全了」。
| 項目 | 數值 |
|---|---|
| 掃描範圍 | 12 檔/1,642 行 |
| effort 設定 | low |
| 派出研究員 | 1 名(派 1 回 1,無失聯) |
| 提出的候選問題 | 2 條 |
| 去除重複後 | 2 條 |
| 三人面板投票 | 3 名檢查員 × 2 條發現 = 6 票,全數投出,無漏投 |
| 投票結果 | 發現 1:3 票全認定成立;發現 2:3 票全認定成立 |
| 被面板否決的 | 0 條 |
| 嚴重度被面板下修的 | 0 條 |
| 驗證輪數 | 1 輪(無候選被延到下一輪、無遺失) |
| 驗證章 | verified |
| 總耗時 | 約 50 分鐘 |
| 子代理數 | 7 |
工具原始產出(機器可讀格式、SARIF、版本戳記)在 CLAUDE-SECURITY-20260922-090431/,該目錄自帶 .gitignore 不入版控。
@require_capability("module-frame.read")。三處都在同一個模組、改法相同,建議併成一張卡。