檢查日期 2026-09-15|掃描耗時 2 小時 39 分|對應卡片 CM-1804|掃描範圍 jedi-system-core 套件 24 個檔
讀取系統設定的三個入口只檢查「你有沒有登入」、完全不檢查權限,任何一個最低權限的員工帳號都能把物件儲存(放所有客戶上傳檔案的地方)的帳號密碼抓走——而同一支檔案裡,寫入類的四個入口每一個都有權限檢查,證明這裡本來就該有。
「系統設定」是一張資料表,存的是這套系統要連到外面時用的連線資訊——寄信伺服器、公司目錄服務(LDAP)、放檔案的物件儲存。這些設定裡有帳號也有密碼。
這一棒把這張表從「網路上打得到的入口」一路追到「資料庫」,要回答三個問題:
檢查的是 jedi-system-core 這支套件的 24 個檔——這支是「起手式五支」之一,每個新客戶裝上就有,所以這裡的洞是全產品範圍。
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 修正卡 |
|---|---|---|---|---|---|---|
| 1 | 🔴 高 | 讀設定的三個入口只檢查「有登入」,沒檢查權限。同一支檔案裡,新增/修改/刪除三個入口每一個都有檢查 | 任何一個普通員工帳號打一行指令,就拿到物件儲存的帳號密碼 + 伺服器位址 + 儲存空間名稱。這組帳密是整套部署共用的(安裝時產生一組,之後每建一個新客戶就原封不動複製一份),儲存空間也是共用的同一個,所以拿到就等於所有客戶上傳的檔案都可讀可寫。同一條路還會吐出寄信伺服器與 LDAP 的位址、埠號、登入帳號 | 任何一個能登入的帳號(不必有任何權限、不必是管理員、任何客戶底下的都可以) | jedi_system_core/api/routes/system_config_route.py:41(清單)同檔 :56(單筆)主專案 api/system_config/routes/system_config_route.py:131 |
待開 |
| 2 | ⚪ 低 | 密碼遮罩名單漏掉物件儲存這一組——名單裡有寄信、第三方登入、通知、議題整合四組,就是沒有物件儲存;要遮的欄位名也只寫了 secret 與 private_token,沒有物件儲存實際用的 secret_key |
就算問題 1 補好了權限檢查,有正當權限的客戶管理員打開「儲存設定」頁時,瀏覽器仍會收到那把共用密鑰的明文。任何看得到他瀏覽器流量、存檔、前端錯誤日誌的人都拿得到 | 系統用 minio/seaweedfs 儲存(安裝版預設就是)+ 打得到任一個會回傳設定列的入口 | jedi_system_core/plugin/contract.py:61(群組名單)同檔 :65(欄位名單) |
待開 |
這兩條是連在一起的:問題 1 是「誰都進得來」,問題 2 是「進來之後看到的是明文」。單看問題 2 只是「管理員的瀏覽器裡有密碼」,影響有限;配上問題 1,就是任何人都能直接讀到明文密碼。
現況:已修(M22-2,CM-2063)
白話:系統設定這張表有七個對外入口。寫入類的四個(新增、修改、刪除、還原預設)每一個都會檢查「你有沒有改這組設定的權限」,讀取類的三個一個都沒有,只檢查「你有沒有登入」。
首腦自己打開檔案核對(不是採信報告):
jedi_system_core/api/routes/system_config_route.py
第 38-41 行 依群組取清單 只有「要登入」 ← 沒有權限檢查
第 53-56 行 依編號取單筆 只有「要登入」 ← 沒有權限檢查
第 63-71 行 修改 要登入 + assert_config_capability(..., "update")
第 78-84 行 刪除 要登入 + assert_config_capability(..., "delete")
第 105-110 行 新增 要登入 + assert_config_capability(..., "create")
同一支檔案裡就有反例——這證明「讀取這裡本來就該有權限檢查」不是猜測,是漏掉了。
而且檔案自己寫了出來。第 30-34 行那段說明文字白紙黑字:
「無寫入守門——讀取只要登入即可;受 RLS 與宿主包過的 service 兩層限制(別的租戶的列看不到、出廠快照列不存在)。」
(RLS 就是資料庫層「每個客戶只能看到自己資料」的隔離機制。)
但那兩層限制擋不住這件事。RLS 擋的是「看到別的客戶的列」,它不擋「看自己客戶的那一列」——而問題就在自己那一列裡面:
scripts/installer/install.sh:1885-1908 的 configure_storage_config_row,把內建物件儲存的 access_key 和 secret_key 寫進這一列。app/system_config/service/tenant_storage_config_seeder.py:52 的 seed_from_root() 會把 ROOT 那一份逐字複製給新客戶。所以每個客戶自己那一列裡放的,是同一組全域共用的密鑰。RLS 認真擋住了「看別人的列」,但每個人自己的列裡裝的都是同一把鑰匙。
攻擊情境很簡單:
GET /api/1.0/system/configs/STORAGE_CONFIG。data[0].value 直接含伺服器位址、儲存空間名稱、access_key、secret_key。「沒有權限檢查」在三個地方,不是一個:套件內的清單(:41)、套件內的單筆(:56),以及主專案自己保留的那支 GET /system/config/<group>/<key>(compliance-manager-be/api/system_config/routes/system_config_route.py:131,也只掛 @jwt_required())。三個都要補,補一個沒有用。
怎麼修:讀取端比照寫入端補上按群組分流的檢查——
assert_config_capability(group, "read"),群組從網址上拿。assert_config_capability(service.resolve_group_for_uid(uid), "read"),跟同檔修改/刪除的寫法一模一樣。assert_config_capability("STORAGE_CONFIG", "read"),形狀照抄即可。需要的能力點已經存在、不用新建:主專案 core/plugins/system_core.py 的 _GROUP_CAPABILITY_RESOURCE 已經有 storage-config / smtp-config / ldap-config 的對應,storage-config.read 這類能力點在 04-seed-core.sql 也已經 seed 過。這是接線,不是造新東西。
⚠️ 修的時候要注意順序:同檔第 68-70 行有一段註解說明「先 403 再 404」不可對調——查不到群組時 resolve_group_for_uid 回 None,宿主的守門對 None 會退回最嚴的權限。讀取端補守門時要沿用同樣的順序,否則沒權限的人可以用不存在的編號試探出「這筆存不存在」。
現況:已修(M22-3,CM-2054)
白話:系統本來有一道「回傳設定前先把密碼欄位刪掉」的遮罩機制。這道機制靠兩份名單決定要遮什麼,兩份都漏掉了物件儲存。
首腦自己打開檔案核對:
jedi_system_core/plugin/contract.py
第 61 行 要遮罩的群組:SMTP、THIRD_PARTY_LOGIN、NOTIFY_CONFIG、ISSUE_INTEGRATE_CONFIG
↑ 沒有 STORAGE_CONFIG
第 65 行 要遮罩的欄位名:secret、private_token
↑ 沒有 secret_key(物件儲存實際用的欄位名就叫這個)
兩份名單都漏,所以物件儲存的密鑰在遮罩這關是完全透明的——不是遮得不夠,是根本沒進遮罩的視野。
寄信和 LDAP 是怎麼做的:密碼從來不回傳,前端要表示「密碼沒改」就送 changePwd=false,後端沿用舊的(機制在 domain/service/system_config_domain_service.py 的 _preserve_existing_secret)。儲存設定沒有跟上這套做法,於是同一把共用密鑰散落在每個客戶管理員的瀏覽器裡。
怎麼修:
STORAGE_CONFIG 加進第 61 行的群組名單。secret_key、minio_secret_key 加進第 65 行的欄位名單。changePwd 做法(不回密鑰、沒帶就沿用),現成機制已經有了。tests/unittest/test_plugin_contract.py:311-313 有一條斷言把現在這份名單釘死了,不改會紅。⚠️ 這是有意識的契約變更,不是單純補漏:第 59-60 行的註解明寫這份名單「凍結」。動它之前要確認沒有別的地方依賴現在這份名單的內容。
派工卡列了八個要追的點。掃描與首腦核對後的結果:
| 卡上第幾點 | 要追什麼 | 結果 |
|---|---|---|
| ① | 讀設定時 logger.info(f"Get system configs: {configs}") 把整列(含密碼)寫進日誌 |
面板未判為獨立發現。密碼確實會進日誌,但這與總表已登記的 FR-085 C2「密碼與 JWT 原文進 log」是同一根因,依卡片指示標為同根因、不另計。真正被判為新問題的是「誰都讀得到」這一層(問題 1) |
| ② | 遮罩名單漏 STORAGE_CONFIG 與 access_key/secret_key |
✅ 成立,就是問題 2。與 FR-086 B2-A 是同一根因的兩側——B2-A 從「上傳那一側」報,這次從「名單這一側」確認 |
| ③ | 遮罩機制對 dict 與物件兩種形狀的處理,有沒有第三種形狀會讓它靜默失效 | 掃描未提出,未成為發現。注意這是「沒被提出」不是「查過沒問題」(理由見下面可信度段) |
| ④ | 「先擋再查」的順序擋不擋得住、新增端點的群組取自送進來的內容 | 研究員提了一條「新增端點可用任意欄位灌進實體」,三個檢查員一致否決——實體是只有六個欄位的固定結構,多送任何欄位會直接報錯,不會被吸收 |
| ⑤ | 四道守門是不是真的都掛上了、有沒有辦法繞過 | 掃描未提出繞過手法。但反過來查出了更基本的問題:四道守門確實都掛上了,可是守的全是寫入,讀取根本沒有第五道(問題 1) |
| ⑥ | 密碼保留邏輯會不會把舊密碼吐回來 | 未成為獨立發現。但問題 2 指出儲存設定壓根沒有這層保護——寄信/LDAP 有,儲存設定沒有 |
| ⑦ | 三支 SQL:隔離規則有沒有漏洞、預設值有沒有硬編密碼 | 研究員提了一條「預設值的登入效期是毫秒當秒填」,三個檢查員一致否決——同一個數值在另外兩個地方也是編譯內建的預設值,刪掉這行效期一秒都不會變。沒有查出硬編的密碼 |
| ⑧ | 底層查詢沒給條件就回全表、哪些呼叫者用了它 | 掃描未提出。未成為發現,同樣是「沒被提出」不是「查過沒問題」 |
要特別講的是第①點與第②點:兩條都對上了總表既有的舊發現,這一棒確認了源頭就在這張表,不必再從下游一條一條撿。
分兩層講,這兩層的答案不一樣。
工具給出的驗證章是 verified(完整通過,不是 unverified)。兩條發現都是三個獨立檢查員投票、三票全過。
而且檢查員真的在做對抗性判斷,不是橡皮圖章——研究員總共提了四條,兩條被一致否決(上表第④、⑦點那兩條),否決理由具體到打開檔案指出「實體只有六個欄位、多送會報錯」「同一個數值在另外兩處也是預設值」。否決率一半。
首腦也沒有只採信工具,兩條都自己開檔核對:
system_config_route.py 第 30-110 行,確認讀取的兩個方法確實只有 @auth_required、寫入的三個方法確實都有 assert_config_capability,檔案自己的說明文字也承認「讀取只要登入即可」。另外自己去查了主專案的 _host.py:208,確認 auth_required 被填進去的就是光禿禿的 jwt_required()。contract.py 第 55-66 行,確認第 61 行就是那四個群組、第 65 行就是那兩個欄位名,上面的註解確實寫了「凍結」。install.sh:1885-1908 確認安裝程式把密鑰寫進那一列,打開 tenant_storage_config_seeder.py:52 確認新客戶是逐字複製 ROOT 那份。這條鏈是「為什麼 RLS 擋不住」的關鍵,是首腦自己串的,不是報告給的。三個原因:
low)。只派一個研究員讀完全部 24 個檔,沒有做全面盤點、沒有做威脅建模。它找的是「讀下來最明顯的東西」,不是「窮舉所有可能」。/system/config/<group>/<key> 也有同樣的洞,那支正是第 2 棒的範圍——這暗示第 2 棒很可能還有同類問題。還有一點要講明:整個掃描過程沒有執行任何程式碼。沒有跑測試、沒有真的送出那個請求、沒有真的去連物件儲存。所有結論都是「讀程式碼推出來的」。上面講的密鑰內容,是從安裝程式和複製程式的程式碼推出來的,不是從哪個資料庫裡讀出來的。
想自己確認問題 1,照這個順序做(只讀不改,不會動到任何環境):
jedi-system-core/jedi_system_core/api/routes/system_config_route.py,看第 38-41 行(讀清單)和第 63-71 行(修改)。對比兩者的裝飾器:前者只有 @auth_required,後者多一行 assert_config_capability。compliance-manager-be/api/system_config/routes/system_config_route.py,看第 131 行那支 GET,確認也只有 @jwt_required();再看第 103 行的「還原預設」,那支有 assert_config_capability("STORAGE_CONFIG", "read")——同一支檔案裡的正反例。scripts/installer/install.sh 第 1885-1908 行,確認安裝時把 access_key/secret_key 寫進 STORAGE_CONFIG 那一列。app/system_config/service/tenant_storage_config_seeder.py 第 52 行起,確認新客戶是複製 ROOT 那份。想確認問題 2:開 jedi-system-core/jedi_system_core/plugin/contract.py 第 55-66 行,數一下第 61 行那四個群組名稱裡有沒有 STORAGE_CONFIG,第 65 行那兩個欄位名裡有沒有 secret_key。
| 項目 | 數字 |
|---|---|
| 檢查範圍 | 24 個檔(1,674 行) |
| 掃描模式 | low(快篩:單一研究員 + 三人檢查員面板,不做盤點、不做威脅建模) |
| 派出/回報的研究員 | 1 / 1 |
| 候選問題 → 去除重複 | 4 → 4 |
| 投票數 | 12(4 條 × 3 個檢查員) |
| 通過 / 否決 | 2 通過(都是 3:0) / 2 否決(都是 0:3) |
| 沒投到票的 | 0 |
| 投票中斷的 | 0 |
| 被檢查員降低嚴重度的 | 0 |
| 驗證章 | verified(完整通過) |
| 掃描 run ID | wf_881b24e3-df3 |
| 掃描的程式版本 | 327c283beef3a475c90eb176830f1a8a2d1e8511(branch feature/FR-075,工作區乾淨) |
| 耗時 | 2 小時 39 分 |
| 工具原始產物 | jedi-system-core/CLAUDE-SECURITY-20260915-040159/(該目錄有自己的 .gitignore,不入版控) |