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

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

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

---

## 🔴 一句話結論

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

---

## 這一棒在檢查什麼

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

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

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

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

---

## 找到什麼：2 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 修正卡 |
|---|---|---|---|---|---|---|
| 1 | 🔴 **高** | **讀設定的三個入口只檢查「有登入」，沒檢查權限**。同一支檔案裡，新增／修改／刪除三個入口每一個都有檢查 | 任何一個普通員工帳號打一行指令，就拿到**物件儲存的帳號密碼 ＋ 伺服器位址 ＋ 儲存空間名稱**。這組帳密是**整套部署共用的**（安裝時產生一組，之後每建一個新客戶就原封不動複製一份），儲存空間也是共用的同一個，所以拿到就等於**所有客戶上傳的檔案都可讀可寫**。同一條路還會吐出寄信伺服器與 LDAP 的位址、埠號、登入帳號 | 任何一個能登入的帳號（**不必有任何權限、不必是管理員、任何客戶底下的都可以**） | `jedi_system_core/api/routes/system_config_route.py:41`（清單）<br>同檔 `:56`（單筆）<br>主專案 `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`（群組名單）<br>同檔 `:65`（欄位名單） | 待開 |

**這兩條是連在一起的**：問題 1 是「誰都進得來」，問題 2 是「進來之後看到的是明文」。單看問題 2 只是「管理員的瀏覽器裡有密碼」，影響有限；配上問題 1，就是**任何人都能直接讀到明文密碼**。

---

## 詳細說明

### 問題 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 行的註解明寫這份名單「**凍結**」。動它之前要確認沒有別的地方依賴現在這份名單的內容。

---

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

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

| 卡上第幾點 | 要追什麼 | 結果 |
|---|---|---|
| ① | 讀設定時 `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`）。兩條發現**都是三個獨立檢查員投票、三票全過**。

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

**首腦也沒有只採信工具**，兩條都自己開檔核對：

- 問題 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 棒很可能還有同類問題。

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

---

## 手測清單（給決策者驗）

想自己確認問題 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`。

---

## 執行概況（技術細節）

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