P1 檢查結果:系統設定表的完整路徑

P1 檢查結果:系統設定表的完整路徑

檢查日期 2026-09-15|掃描耗時 2 小時 39 分|對應卡片 CM-1804|掃描範圍 jedi-system-core 套件 24 個檔


§1

🔴 一句話結論

讀取系統設定的三個入口只檢查「你有沒有登入」、完全不檢查權限,任何一個最低權限的員工帳號都能把物件儲存(放所有客戶上傳檔案的地方)的帳號密碼抓走——而同一支檔案裡,寫入類的四個入口每一個都有權限檢查,證明這裡本來就該有。


§2

這一棒在檢查什麼

「系統設定」是一張資料表,存的是這套系統要連到外面時用的連線資訊——寄信伺服器、公司目錄服務(LDAP)、放檔案的物件儲存。這些設定裡有帳號也有密碼。

這一棒把這張表從「網路上打得到的入口」一路追到「資料庫」,要回答三個問題:

  1. 讀設定的時候,密碼會不會被送出去或寫進日誌檔?
  2. 誰改得動、誰讀得到這些設定?
  3. 一個客戶碰得到別的客戶、或碰得到平台層的設定嗎?

檢查的是 jedi-system-core 這支套件的 24 個檔——這支是「起手式五支」之一,每個新客戶裝上就有,所以這裡的洞是全產品範圍。


§3

找到什麼:2 個問題

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 修正卡
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,就是任何人都能直接讀到明文密碼。


§4

詳細說明

問題 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 認真擋住了「看別人的列」,但每個人自己的列裡裝的都是同一把鑰匙。

攻擊情境很簡單:

  1. 用任何一個普通員工帳號登入,拿到登入憑證。
  2. 打 GET /api/1.0/system/configs/STORAGE_CONFIG。
  3. 回傳內容裡 data[0].value 直接含伺服器位址、儲存空間名稱、access_key、secret_key。
  4. 拿這組帳密直連物件儲存,所有客戶上傳的檔案與框架檔可讀可寫。

「沒有權限檢查」在三個地方,不是一個:套件內的清單(: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"),跟同檔修改/刪除的寫法一模一樣。
  • 主專案那支:同檔第 103 行對「還原預設」已經用過 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 會退回最嚴的權限。讀取端補守門時要沿用同樣的順序,否則沒權限的人可以用不存在的編號試探出「這筆存不存在」。

問題 2:遮罩名單漏掉物件儲存(低)

現況:已修(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)。儲存設定沒有跟上這套做法,於是同一把共用密鑰散落在每個客戶管理員的瀏覽器裡。

怎麼修:

  1. STORAGE_CONFIG 加進第 61 行的群組名單。
  2. secret_key、minio_secret_key 加進第 65 行的欄位名單。
  3. 儲存設定頁改用跟寄信/LDAP 一樣的 changePwd 做法(不回密鑰、沒帶就沿用),現成機制已經有了。
  4. 記得同步改測試:tests/unittest/test_plugin_contract.py:311-313 有一條斷言把現在這份名單釘死了,不改會紅。

⚠️ 這是有意識的契約變更,不是單純補漏:第 59-60 行的註解明寫這份名單「凍結」。動它之前要確認沒有別的地方依賴現在這份名單的內容。


§5

卡片點名的八項,查了什麼、結果是什麼

派工卡列了八個要追的點。掃描與首腦核對後的結果:

卡上第幾點 要追什麼 結果
① 讀設定時 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:隔離規則有沒有漏洞、預設值有沒有硬編密碼 研究員提了一條「預設值的登入效期是毫秒當秒填」,三個檢查員一致否決——同一個數值在另外兩個地方也是編譯內建的預設值,刪掉這行效期一秒都不會變。沒有查出硬編的密碼
⑧ 底層查詢沒給條件就回全表、哪些呼叫者用了它 掃描未提出。未成為發現,同樣是「沒被提出」不是「查過沒問題」

要特別講的是第①點與第②點:兩條都對上了總表既有的舊發現,這一棒確認了源頭就在這張表,不必再從下游一條一條撿。


§6

這份結果可信到什麼程度

分兩層講,這兩層的答案不一樣。

「這兩條是真的嗎」→ ✅ 可信

工具給出的驗證章是 verified(完整通過,不是 unverified)。兩條發現都是三個獨立檢查員投票、三票全過。

而且檢查員真的在做對抗性判斷,不是橡皮圖章——研究員總共提了四條,兩條被一致否決(上表第④、⑦點那兩條),否決理由具體到打開檔案指出「實體只有六個欄位、多送會報錯」「同一個數值在另外兩處也是預設值」。否決率一半。

首腦也沒有只採信工具,兩條都自己開檔核對:

  • 問題 1:打開 system_config_route.py 第 30-110 行,確認讀取的兩個方法確實只有 @auth_required、寫入的三個方法確實都有 assert_config_capability,檔案自己的說明文字也承認「讀取只要登入即可」。另外自己去查了主專案的 _host.py:208,確認 auth_required 被填進去的就是光禿禿的 jwt_required()。
  • 問題 2:打開 contract.py 第 55-66 行,確認第 61 行就是那四個群組、第 65 行就是那兩個欄位名,上面的註解確實寫了「凍結」。
  • 共用密鑰那條因果鏈:自己打開 install.sh:1885-1908 確認安裝程式把密鑰寫進那一列,打開 tenant_storage_config_seeder.py:52 確認新客戶是逐字複製 ROOT 那份。這條鏈是「為什麼 RLS 擋不住」的關鍵,是首腦自己串的,不是報告給的。

「是不是只有這兩條」→ ❌ 不可信

三個原因:

  1. 這是快篩模式(low)。只派一個研究員讀完全部 24 個檔,沒有做全面盤點、沒有做威脅建模。它找的是「讀下來最明顯的東西」,不是「窮舉所有可能」。
  2. 卡片點名的八項裡,有三項(③⑦部分、⑧)掃描根本沒提出來。沒提出不等於查過沒問題——以這個模式的深度,更可能是沒追到那一層。這三項若要有結論,需要人工另外追。
  3. 範圍只有這支套件的設定那一半。主專案的接線(第 2 棒)、選單字典(第 3 棒)都不在這次範圍內。特別是問題 1 已經證明主專案那支 /system/config/<group>/<key> 也有同樣的洞,那支正是第 2 棒的範圍——這暗示第 2 棒很可能還有同類問題。

還有一點要講明:整個掃描過程沒有執行任何程式碼。沒有跑測試、沒有真的送出那個請求、沒有真的去連物件儲存。所有結論都是「讀程式碼推出來的」。上面講的密鑰內容,是從安裝程式和複製程式的程式碼推出來的,不是從哪個資料庫裡讀出來的。


§7

手測清單(給決策者驗)

想自己確認問題 1,照這個順序做(只讀不改,不會動到任何環境):

  1. 開 jedi-system-core/jedi_system_core/api/routes/system_config_route.py,看第 38-41 行(讀清單)和第 63-71 行(修改)。對比兩者的裝飾器:前者只有 @auth_required,後者多一行 assert_config_capability。
  2. 看同檔第 30-34 行的說明文字,確認它自己承認「讀取只要登入即可」。
  3. 開 compliance-manager-be/api/system_config/routes/system_config_route.py,看第 131 行那支 GET,確認也只有 @jwt_required();再看第 103 行的「還原預設」,那支有 assert_config_capability("STORAGE_CONFIG", "read")——同一支檔案裡的正反例。
  4. 開 scripts/installer/install.sh 第 1885-1908 行,確認安裝時把 access_key/secret_key 寫進 STORAGE_CONFIG 那一列。
  5. 開 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。


§8

執行概況(技術細節)

項目 數字
檢查範圍 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,不入版控)