---
title: FR-085.C1 掃描報告：授權鏈核心（session／RLS 注入／身分／例外處理）
---

# FR-085.C1 掃描報告：授權鏈核心（session／RLS 注入／身分／例外處理）

- **卡片**：[CM-1647](https://app.notion.com/p/FR-085-C1-session-RLS-29-jedi-monorepo-Opus-1M-low-3d7346da4cd0814d8c4dde73dfe619ad)（母卡 [CM-1646](https://app.notion.com/p/FR-085-jedi-common-3d7346da4cd081f18862dd193cf355c1)）
- **掃描範圍**：`jedi-common` 套件的 `jedi_common/session/`＋`jedi_common/identity/`＋`jedi_common/handler/`，共 29 個受版控檔
- **版本**：commit `8aa6f060`（jedi monorepo，branch `feature/FR-075`）
- **日期**：2026-09-10
- **工具產物**：`jedi-common/CLAUDE-SECURITY-20260910-123204/`（不入版控）

---

## 1. 🔴 一句話結論

**這一棒證實了一件早就知道、但比原本記錄的更嚴重的事：只要一個請求「還沒有登入身分」，資料庫的客戶隔離就整個關閉——而「還沒有登入身分」不是罕見狀況，登入端點本身、以及一個完全沒有守門的 Google Drive 通知端點，每一次都處在這個狀態。**

另外找到一條獨立的問題：資料庫出錯時，錯誤原文（含資料表名、欄位名、以及使用者剛剛輸入的值）會**原封不動回給前端**。

三條發現裡有兩條（F1／F2）其實是**同一個地方**，工具用兩條不同的路徑各自撞到它——修一個地方兩條一起解決。這個地方就是既有的 **CM-1559**，本報告不重報它的存在，重報的是「它比原本記錄的更好觸及」。

---

## 2. 這一棒在檢查什麼（白話）

我們的產品是**多客戶共用一套系統**的：A 公司與 B 公司的資料放在同一個資料庫裡，靠資料庫本身的一道機制隔開，讓每個人只看得到自己公司的資料。這道機制叫 **RLS**（就是「每個客戶只能看自己資料」的資料庫隔離機制）。

它的運作方式是：**程式每次要查資料庫之前，先告訴資料庫「現在是誰在查、他能看哪幾家公司」**。資料庫收到這個宣告之後，才決定要放行哪幾筆資料。

`jedi-common` 這個共用套件，就是**負責做這個宣告的地方**。全站每一次資料庫存取都經過它。這一棒掃的三個目錄各管一件事：

| 目錄 | 管什麼 | 出問題會怎樣 |
|---|---|---|
| `session/` | 開資料庫連線、對資料庫宣告「現在是誰」 | 宣告錯了＝客戶隔離失效，看到別人的資料 |
| `identity/` | 記住「這個請求是誰發的」、把帳號 id 換成顯示名稱 | 記錯了＝身分張冠李戴 |
| `handler/` | 把程式出錯翻譯成回給前端的訊息 | 翻譯太老實＝把資料庫內部結構洩給外人 |

**這一棒只找問題、不修問題。** 底下所有「怎麼修」都只是建議，沒有動任何一行程式碼。

### 為什麼這一塊特別重要

`jedi-common` 是 25 支套件與主產品共用的地基，主產品有 662 處引用它。**它出問題等於全站都出問題**——不是某一個功能壞掉，是每一個功能都受影響。

---

## 3. 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| **C1-1** | 只要請求還沒有登入身分，程式就對資料庫說「我是最高權限管理員」，隔離整個關掉 | 那段程式的每一次查詢與寫入都跨全部客戶，任何小 bug 都會從「影響一家公司」放大成「影響全部公司」 | 走到一條「沒有登入身分就會碰資料庫」的路。已確認至少兩條：**登入端點本身**、**Google Drive 通知端點（完全沒守門）** | `jedi-common/jedi_common/session/database/db.py:115-118` | **HIGH** |
| **C1-2** | 同上，這是同一個地方被第二條路徑撞到（工具獨立報了兩次） | 同上 | 同上 | 同上（**與 C1-1 是同一行，一起修**） | **HIGH** |
| **C1-3** | 資料庫報錯時，錯誤原文直接回給前端 | 外人不用登入就能問出資料表名稱、欄位名稱、以及「這個帳號是不是已經存在」 | 找得到一個「輸入會撞到資料庫限制」的端點（例如註冊時用已存在的名稱） | `jedi-common/jedi_common/handler/sql_exception.py:37` → `handler.py:43` | **MEDIUM** |
| **C1-4** | 主產品自己那份 Redis 連線工具，寫死「不檢查對方是不是真的 Redis 伺服器」 | 同一個問題在 jedi-iam 已經修好（CM-1565），但主產品這份**沒跟著修**，是漏網的複製品 | 開了 TLS 的部署，且攻擊者能插進網路中間 | `compliance-manager-be/common/util/redis_client_util.py:32` | **MEDIUM** |
| **C1-5** | 排序／篩選會拿前端傳來的字串直接去對資料庫欄位 | 查了：**擋得住**，排序有白名單、篩選也只落在真實欄位上。**列在這裡是為了給結論，不是問題** | — | `base_repository_impl.py:271`、`:177`、`:512` | **無（已查證安全）** |

**C1-1 與 C1-2 請當成一條看待**——工具把它報成兩條是因為兩個研究員走了不同的路徑撞到同一面牆。修法只有一個，改一個地方兩條都解決。

---

## 4. 每條發現的詳述

### C1-1／C1-2（HIGH）沒有登入身分時，程式對資料庫自稱最高權限管理員

**這是什麼問題（白話）**

程式每次開資料庫連線時，會先問一句「現在這個請求是誰？」。

- **問得到**（使用者已登入）→ 程式老實告訴資料庫他是誰、能看哪幾家公司，隔離正常運作。
- **問不到**（請求還沒有身分）→ 程式對資料庫說：**「我是最高權限管理員」**，於是隔離整個關掉。

問題就在第二種情況的選擇。**「不知道你是誰」的正確反應應該是「那你什麼都別想看」，而不是「那就讓你看全部」。**

這個選法在資安上叫**出錯時預設放行**（fail-open）——當程式判斷不出狀況時，它選了對使用者最寬鬆的那條路。安全的做法應該相反：判斷不出來就一律擋下（fail-closed）。

**這個「最高權限」旗標是真的有效力，不是裝飾**——我實際去資料庫的 schema 檔數過：全站 **105 條隔離規則裡，有 84 條把這個旗標放在第一個判斷條件**，而且是「只要它成立就直接放行、不再往下看」的那種寫法。另外還有 **5 個資料庫函式**也讀它。而應用程式連資料庫用的帳號 `cm_app` 是**沒有繞過隔離的特權**的（`scripts/init/00-cluster.sql:41` 只給了登入權限；有繞過權限的是管理帳號 `cmmgr`，在 `:31`）。所以這個旗標是這個帳號**唯一**能關掉隔離的手段——它一被設起來，隔離就是真的沒了。

**出事會怎樣**

任何在「沒有身分」狀態下跑的資料庫操作，**都不是在一家公司的範圍內跑，而是在整個資料庫上跑**。

具體後果不是「馬上就有人偷走資料」，而是**把所有其他 bug 的爆炸半徑放大**：

- 一支查詢原本寫得太寬（忘了加條件），在有隔離時只會多撈到自己公司的資料；在沒隔離時會撈到**全部公司**的。
- 一次寫入原本只會寫壞自己公司的一列，在沒隔離時可能寫到**別家公司**的資料上。
- 任何原本「單一客戶等級」的漏洞，在這條路上都升級成「全資料庫等級」。

**要先有什麼才打得到**

需要走到一條「還沒有身分就會碰資料庫」的路。這一棒確認了三類：

1. **登入端點本身**（已核對屬實）。`jedi-iam/jedi_iam/api/routes/login_route.py:45` 的說明文字自己寫著「**不掛 `@auth_required`**——登入本來就是未認證端點」。登入當然還沒有身分，而登入服務會查資料庫（找帳號、讀登入政策、寫失敗次數），所以**每一次登入請求，這整段都跑在隔離關閉的狀態下**。
2. **Google Drive 通知端點**（已核對屬實，這條最值得注意）。`compliance-manager-be/api/cloud_integration/routes/google_drive_webhook_route.py:33-39` 的 `GoogleDriveWebhookRoute.post` **一個守門都沒有**——同一個目錄的 `google_drive_sync_route.py` 每一條路由都掛了 `jwt_required`，對比非常明顯。它呼叫的服務有 `@transaction`（`app/cloud_integration/service/google_drive_webhook_service.py:41-42`），所以會開資料庫連線，且**開連線時身分是空的**。
3. **背景排程與啟動流程**：主產品的排程器與 app 啟動時的資料庫操作，同樣沒有身分。

**⚠️ 關於 Google Drive 那條，要照實補一點**：那個服務在 `:57` 有比對 `entity.webhook_token != channel_token`（也就是「你有沒有拿對我們發出去的那組隨機密碼」）。**這是一道獨立於資料庫隔離的秘密檢查，確實存在**，這也正是持反對票的那位檢查員的理由。

但**這不改變洞成立**：隔離是在那道檢查跑**之前**就已經關掉的（`@transaction` 先開連線、先設旗標，服務內的比對是之後才跑）。所以正確的說法是：**洞是真的，但可利用性比「完全沒有防護」低**——攻擊者還得先猜中那組隨機密碼，才能讓後面的資料庫操作真的做事。

**在哪裡**

- `jedi-common/jedi_common/session/database/db.py:115-118` — `elif is_pg:` 分支，無條件 `SET LOCAL app.is_super_admin = 't'`（**這一行就是根因**）
- `jedi-common/jedi_common/session/database/db.py:91-114` — 有身分時的正常分支（作為對照：它會設 `allowed_tenant_paths`，無身分分支則什麼都不設）
- `jedi-common/jedi_common/session/auth/auth_context.py:17-21` — `get_user_context()`，查不到身分時 `return None`（原本要拋錯的那行被註解掉了，`:21`）。這是預設放行的上游
- `jedi-iam/jedi_iam/api/routes/login_route.py:45` — 登入端點自陳不掛守門
- `compliance-manager-be/api/cloud_integration/routes/google_drive_webhook_route.py:33-39` — 無守門的 webhook
- `compliance-manager-be/app/cloud_integration/service/google_drive_webhook_service.py:41-42` — `@transaction`（開連線的地方）、`:57` — token 比對（RLS 之外的那道檢查）
- `compliance-manager-be/scripts/init/02-schema.sql` — 105 條隔離規則中 84 條把這旗標放第一位
- `compliance-manager-be/scripts/init/00-cluster.sql:41` — `cm_app` 沒有繞過隔離的特權（`:31` 的 `cmmgr` 才有）

**怎麼修**

把預設反過來——**沒有身分就什麼都看不到**：

在 `db.py` 的 `session_scope()` 裡，把 `:115-118` 的 `elif is_pg:` 分支改成設 `app.is_super_admin = 'f'`，且**不設** `allowed_tenant_paths`（讓它維持空值）。資料庫端的 `app_tenant_allowed_for_session()` 函式（`02-schema.sql:623-643`）已經處理好「路徑是空的就一律拒絕」，所以這樣改就是預設全擋。

**接著要處理「那些真的需要跨客戶跑的系統工作」**——它們必須**明講**自己要提權，而不是靠「什麼都不說」自動拿到：

- 現成的樣板就在 `jedi_iam.infra.elevated_session.elevated_readonly_session()`——把提權限制在一次短命的唯讀連線裡。
- 登入端點是最需要處理的一個：它**確實需要**看得到全部帳號才能驗密碼，所以要給它一個**具名的系統身分**（明確宣告「我是登入流程，我要查 users 表」），而不是靠「沒有身分」隱含拿到全權限。

**修之前務必先盤點**有哪些路徑目前靠這個預設在運作（登入、排程、啟動、signed_token 下載），**每一條都要先給它明確的提權方式，再翻預設**。順序反了會讓登入直接壞掉。

> 順帶一提：`jedi_iam/authz/signed_token.py:82-101` 的 `_set_minimal_context()` 註解自己寫著「`tenant_id=None` → session_scope 判定 is_super（與原本『無 context = RLS bypass』的既有下載/預覽行為一致）」——**這是把預設放行當成契約在依賴**。翻預設時這一支一定會受影響，要一起處理。

**首腦核對註記**：以下全部**已由首腦獨立開檔核對屬實**——
① F1／F2 是同一個洞的兩面（工具自己也這麼說），根因就是 `db.py:117`；
② **這正是既有的 CM-1559**，卡片已列為已知基準線，工具用兩條不同路徑重新發現它，**價值在於證明它比原本記錄的更好觸及**；
③ `app.is_super_admin` 確實是全站隔離規則的第一個短路條件——首腦 grep `scripts/init/02-schema.sql` 得 89/105（以「檔案內出現次數」計），本報告作者以「逐條 CREATE POLICY 語句」精算為 **84/105 條規則 ＋ 5 個函式**，兩者是同一件事的不同數法，**結論一致：絕大多數規則都靠它**；
④ `cm_app` 確實沒有繞過隔離的特權，所以這旗標是真的有效力；
⑤ 登入端點免認證的前提成立（docstring 自陳）；
⑥ Google Drive webhook 無守門的前提成立，且 token 比對確實存在於 RLS 關閉之後。
**未實測**——沒有實際對任何環境發送請求驗證，全部是讀程式碼與 schema 的結果。

---

### C1-3（MEDIUM）資料庫報錯時，錯誤原文直接回給前端

**這是什麼問題（白話）**

程式碰到資料庫錯誤時（例如「這個帳號已經有人用了」），會把**資料庫吐出來的原始錯誤訊息第一行**直接放進回給前端的內容。

那一行長這樣：

```
duplicate key value violates unique constraint "users_login_name_key"
DETAIL: Key (login_name)=(admin) already exists.
```

裡面有三樣不該給外人的東西：**資料表的限制名稱**（`users_login_name_key`，等於告訴對方有一張 users 表、上面有個 login_name 欄位）、**欄位名稱**、以及**使用者剛剛輸入的那個值**。

**特別值得注意的是**：同一個檔案裡**其他每一種錯誤都處理得很正確**——例如 `handler.py:33-36` 的通用錯誤處理，就把細節吞掉、只回一句罐頭訊息 `COMMON_INTERNAL_SERVER_ERROR`。**只有資料庫錯誤這一支是例外**。所以這不是「整套設計都很寬鬆」，而是**一支漏網的**。

**出事會怎樣**

兩件事：

1. **把資料庫結構送給對方**。攻擊者不需要看原始碼，只要故意送幾個會出錯的值，就能一步步問出資料表名稱、欄位名稱、有哪些唯一限制。這對後續攻擊幫助很大——他不必再猜。
2. **變成一台「這個帳號存不存在」的查詢機**。拿一份候選帳號清單，一個一個送去註冊或邀請，回「已存在」的就是真帳號。這叫**帳號枚舉**，是後續密碼嘗試攻擊的第一步。

**為什麼是 MEDIUM 不是 HIGH**：它洩漏的是**結構資訊與帳號存在與否**，不是資料本身——攻擊者拿不到任何一列業務資料。它的價值在於「讓下一步攻擊更容易」，本身不直接造成損失。

**要先有什麼才打得到**

- 這個服務有註冊 `jedi_common.handler.handler.register_error_handlers`（主產品在 `core/app_factory.py:235` 有註冊，所以條件成立）
- 找得到一個端點，能用送進去的值觸發資料庫錯誤——重複值、關聯不存在、欄位型別不對都算。這類端點在任何系統裡都不難找，**而且不需要登入權限**（註冊、邀請這類端點本來就對外開放）

**在哪裡**

- `jedi-common/jedi_common/handler/sql_exception.py:37` — `return str(exc).splitlines()[0], code`（**根因**，`:38` 的 fallback 也是同一行寫法）
- `jedi-common/jedi_common/handler/handler.py:38-43` — `handle_sql_error()`，`:43` 把那串直接 `jsonify` 出去
- 對照組：`jedi-common/jedi_common/handler/handler.py:33-36` — 通用錯誤處理用罐頭訊息（正確做法就在隔壁 5 行）
- `jedi-common/jedi_common/handler/sql_exception.py:9-23` — `SQL_ERROR_MAP`，決定哪些錯誤走這條路

**怎麼修**

在 `sql_exception.py` 的 `sql_error()` 裡，**不要回傳 `str(exc)`，改回傳一組固定的罐頭訊息**，照 `SQL_ERROR_MAP` 已經分好的狀態碼對應：

- 409（重複、關聯衝突）→ 「資料已存在或與現有資料衝突」
- 400（欄位不對、型別錯誤）→ 「輸入資料格式不正確」
- 408 / 500 → 「系統忙碌中，請稍後再試」

**原始錯誤只寫進伺服器 log，不回給前端**（`handler.py:42` 已經有在 log，保留那一行即可）。

**順便修的一點**：目前 `handler.py:43` 回的 `error_code` 是**整數 HTTP 狀態碼**（409、400），但全站約定是 `GRC_*` / `COMMON_*` 的字串格式（見 CLAUDE.md 的 Response Format 段）。**這會讓前端的錯誤訊息翻譯對不上**——前端是拿 error_code 當多語系的 key 用的。建議一併改成 `ErrorCode.COMMON_*` 的字串。

**首腦核對註記**：**已由首腦獨立開檔核對屬實**——`sql_exception.py:37` 的 `return str(exc).splitlines()[0]` 確實流到 `handler/handler.py:43` 的 `jsonify({"msg": message, ...})`；同檔其他處理器都用罐頭訊息（`:35-36` 的通用處理用 `ErrorCode.COMMON_INTERNAL_SERVER_ERROR`），**只有這支是例外**。工具的三位檢查員**一致通過**（3/3），且每位都獨立驗過一個可能的反駁（「Flask-RESTful 的錯誤路由會不會在處理器之前就把例外吃掉？」），答案是不會。這是三條裡**唯一全票通過、可信度最高**的一條。

---

### C1-4（MEDIUM）主產品的 Redis 連線寫死「不檢查對方是不是真的伺服器」——jedi-iam 修好了，主產品這份沒跟著修

**（工具未報，人工查證；卡片重點⑨）**

**這是什麼問題（白話）**

Redis 是放暫存資料的地方（例如 OAuth 流程的中繼狀態）。連線時可以開啟加密傳輸（TLS）。

但**加密**與**確認對方身分**是兩件事：加密只保證「傳輸內容別人看不懂」，**確認對方身分**才保證「我連到的真的是我們家的 Redis，不是別人假扮的」。

主產品這份連線程式碼把「確認對方身分」**寫死關掉**了（`ssl_cert_reqs=False`）。意思是：就算部署時開了加密，程式也**不會檢查對方出示的憑證**——任何人拿一張自己簽的假憑證都能冒充我們家的 Redis。

**這條的重點不在漏洞本身，而在它是一個漏網的複製品。** 完全相同的問題在 `jedi-iam` 套件裡**已經修好了**（就是 **CM-1565**），修法寫得很完整：開了 TLS 就強制驗憑證、驗主機名稱，還加了單元測試。**但主產品自己那份同名工具沒有跟著修**——兩份程式碼長得幾乎一樣，只修了一份。

**出事會怎樣**

能插進網路中間的人（**網路中間人**，就是能看到並改動你連線的人，例如被入侵的網路設備、或同一個雲端網段裡的其他機器）可以：

- 假扮成 Redis 伺服器，讀到程式存進去的暫存內容
- 更麻煩的是**連線時送出去的 Redis 帳號密碼會直接交到假伺服器手上**
- 把偽造的資料餵回給程式（例如竄改 OAuth 的中繼狀態）

**為什麼是 MEDIUM 不是 HIGH**：要出事得同時滿足兩個條件——① 部署時真的開了 `REDIS_SSL=true`（預設是 `false`），② 攻擊者已經在網路中間。多數部署裡 Redis 跟程式在同一台機器或同一個內網，中間人不容易站進去。**但這條的修法成本極低（照抄 jedi-iam 已寫好的那段），沒有理由不修。**

**要先有什麼才打得到**

- 部署時設了 `REDIS_SSL=true`（沒開 TLS 的話這幾行根本不會執行，反而不受影響）
- 攻擊者能插進程式與 Redis 之間的網路路徑

**在哪裡**

- `compliance-manager-be/common/util/redis_client_util.py:32` — `ssl_cert_reqs=False`（**根因，寫死的**）
- `compliance-manager-be/common/util/redis_client_util.py:25-34` — `connect()` 整段
- **已修好的對照組**：`jedi-iam/jedi_iam/common/utils/redis_client_util.py:43-48` — 開 TLS 時強制 `ssl_cert_reqs="required"` ＋ `ssl_check_hostname=True`，還支援自帶 CA 憑證
- **對照組的測試**：`jedi-iam/tests/unittest/test_redis_client_util_tls.py`（檔頭就寫著這是 CM-1565 的修正）
- 使用者：`compliance-manager-be/infra/ai/redis_chat_history_store.py:15`、`compliance-manager-be/infra/survey/adapters.py:41`

**怎麼修**

把 `jedi-iam` 已經寫好的那段搬過來——在 `common/util/redis_client_util.py` 的 `connect()` 裡：

```python
connect_kwargs = dict(host=..., db=..., username=..., password=...,
                      decode_responses=True, ssl=self.__REDIS_SSL, socket_timeout=30)
if self.__REDIS_SSL:
    # 開了加密就必須驗憑證＋主機名稱，不可退回不驗
    connect_kwargs["ssl_cert_reqs"] = "required"
    connect_kwargs["ssl_check_hostname"] = True
    if ca_certs:                       # 用私有 CA 時才需要
        connect_kwargs["ssl_ca_certs"] = ca_certs
self.redis_cli = redis.Redis(**connect_kwargs)
```

注意 `ssl=False` 時**不要帶這兩個參數**（維持原行為），這也是 jedi-iam 那份的做法。

**另外查到但不構成問題的兩點（一併交代）**：
- **Redis key 有沒有分客戶前綴**：目前主產品用到共用 Redis client 的只有 OAuth 中繼狀態一處（`app/cloud_integration/service/google_drive_integration_service.py:34`，前綴 `drive_oauth_state:`），key 裡帶的是**一次性隨機字串**，不同客戶天生撞不到。**這一項沒有問題。**
- **套件本身的 `session/redis/redis.py`**（本次掃描範圍內的那個檔）**只有 3 行**，就是宣告一個 `FlaskRedis()` 物件，連線設定全部由主產品給（`core/app_factory.py:168`）。**套件這一支沒有問題**，問題在主產品那份自己寫的工具上。

**首腦核對註記**：**工具未報，本報告作者人工查證**。工具沒碰到是合理的——問題檔在主產品 repo，不在這次掃描範圍（`jedi-common`）內。**未實測**，沒有實際架設 TLS Redis 驗證，這是讀程式碼與對照 jedi-iam 已修版本的結果。**建議這條直接開修正卡**：修法現成、風險極低、有現成測試可抄。

---

### C1-5（無問題）排序／篩選拿前端字串去對資料庫欄位——查了，擋得住

**（工具未報，人工查證；卡片重點⑥）**

**這是什麼問題（白話）**

列表頁的排序（「照建立時間排」）與篩選（「只看狀態是進行中的」）是前端傳字串上來、後端拿去對應資料庫欄位的。

擔心的是：前端能不能傳一個**不該讓他排序的欄位**（例如 `password_hash`），或傳一個**根本不是欄位的東西**（例如 Python 物件的內部屬性 `__class__`）來搞破壞？

**查證結論：擋得住。** 三個進入點各自有防線：

**① 排序（`base_repository_impl.py:271`）——有白名單，這是最重要的一道**

```python
if hasattr(self.model, sort_spec.field) and sort_spec.field in all_fields:
```

關鍵是後半段的 `sort_spec.field in all_fields`。`all_fields`（`:257`）是**這張表真正的欄位清單 ＋ 明確宣告的計算欄位**，從資料表定義本身算出來的。所以：

- 傳 `__class__` / `__dict__` / `metadata` 這類 Python 內部屬性 → **不在清單裡，直接略過**
- 傳 `password_hash` → 只有當這張表**真的有**這個欄位時才會通過。也就是說「能排序的欄位」＝「這張表的欄位」，不會多也不會少
- 排序方向只認 `desc`，其他一律當 `asc`（`:272-275`），沒有第二條路

**這裡要講清楚一個常被誤解的點**：即使欄位名通過了，它也**不是被當成字串拼進 SQL** 的——`getattr(self.model, field)` 拿到的是 SQLAlchemy 的欄位物件，交給 `desc()` / `asc()` 之後由 SQLAlchemy 產生 SQL。**所以這裡沒有 SQL 注入的可能**，能發生的最壞情況是「排序依據不合預期」。

**② 篩選（`:174-177`、`:512`）——沒有白名單，但也沒有需要**

篩選這邊是 `if value is None or not hasattr(self.model, field): continue`——只檢查「這張表有沒有這個屬性」，沒有額外白名單。但這裡的欄位名**不是前端直接傳的**：它來自後端自己定義的查詢物件的欄位名（`_filter.__dict__`，`:70`），前端只能填**值**、填不了**欄位名**。所以攻擊者控制不了這裡。

**③ JSONB 擴充欄位（`jsonb_extras.py:138` 的 `col.op("->>")(key)`）——參數化的，安全**

這裡的 key 確實可能來自前端（`_ext_<key>` 前綴），但 `col.op("->>")(key)` 是 SQLAlchemy 的**參數化寫法**——key 是當成參數綁進去的，不是拼進 SQL 字串。**沒有注入面**。最壞情況是查一個不存在的 key，得到空結果。

**首腦核對註記**：**工具未報，本報告作者人工查證**。逐行讀了三個進入點與 `all_fields` 的產生方式（`:255-259`）。**結論是「目前擋得住」**，不是「這裡沒有風險概念」——真正撐住這道防線的是 `sort_spec.field in all_fields` 那半句。**若日後有人為了「支援關聯欄位排序」把它拿掉，防線就沒了**，建議在那行加一行註解說明它的角色。

---

## 5. 卡片「重點看什麼」九項逐項回覆

卡片（CM-1647）列了九項要追的點。逐項交代，**沒有留白**：

| # | 卡片的疑問 | 結論 |
|---|---|---|
| **①** | **RLS 的 session 變數用 f-string 直接拼進 SQL**（`db.py:95`／`:108`／`:113`），有沒有路讓攻擊者控制其中的字元？（卡片標為「本棒最重要」） | **工具三票一致否決，理由是「沒有攻擊者可控的值到得了那個內插點」。首腦認同否決，但要寫明這是「目前沒人餵得進去」，不是「這個寫法安全」。**<br><br>值的來源鏈：JWT 內容 → `jedi-iam/jedi_iam/middleware/jwt_mw.py:87-125` 的 `set_user_context_loader` 組出 `UserContextDTO` → 存進 contextvar → `db.py:87` 讀出來 → `:95` 拼進 SQL。其中 `user_ctx.id` 是資料庫的整數主鍵、`allowed_tenant_paths` 是資料庫查出來的樹狀路徑字串（形如 `/1/102/`），**兩者都是後端自己從資料庫撈的，前端碰不到**。<br><br>**但 f-string 拼 SQL 本身仍是脆弱寫法**：它的安全性完全依賴「目前沒有任何呼叫端把外部值帶進來」這個當下事實，而不是依賴寫法本身。**哪天有人加一條路把外部值帶進 `allowed_tenant_paths`，這個洞就成立了**——而且加那條路的人**不會知道自己踩到了什麼**，因為 `db.py` 這一行看起來只是在設定一個內部變數。<br><br>**建議**：即使這次否決，仍應改成 SQLAlchemy 的參數化寫法（`text("SET LOCAL app.user_id = :uid").bindparams(uid=...)`），成本很低，**買的是「以後不必再靠人記得這件事」**。 |
| **②** | **每個登入者都被無條件設 `app.can_manage_orgs = 't'`**（`db.py:97-99`），有沒有 policy 靠它判斷「能不能改組織」？ | **工具三票一致否決，理由是「沒有任何 policy 或函式讀這個變數」。首腦 grep `scripts/init/` 與 `scripts/sql/` 零命中，否決成立——那行是死碼。**<br><br>本報告作者另以逐條解析語句精算複核：`02-schema.sql` 的 **105 條隔離規則、以及所有資料庫函式，提及 `can_manage_orgs` 的數量都是 0**。設了沒人讀，**目前沒有任何效果**。<br><br>**但不建議就這樣留著**：它會讓讀程式碼的人以為「有一道組織權限的守門」而不去補真正的守門。**建議直接刪掉那三行**（`db.py:97-99`），若確實需要組織層權限再另行設計。<br><br>對照：隔壁的 `app.can_read_all_orgs` **是真的有人讀**（`02-schema.sql:26552`、`:26566`、`:26847` 等），且是由 `set_can_read_all_orgs()`（`db.py:43-48`）**明確呼叫時**才設——那才是正確的形狀。 |
| **③** | **CM-1559 兩腳當已知基準線、不重報，但要標出周邊。** `get_user_context()` 回 None 是預設放行的上游，還有哪些呼叫端假設它一定回值？ | **F1／F2 就是它。工具用兩條新路徑（免認證登入端點、Google Drive webhook）證明它比原本記錄的更好觸及，見 C1-1／C1-2。**<br><br>**周邊同型已找到三處**：<br>① `auth_context.py:17-21` — `get_user_context()` 回 None（原本要拋錯的那行在 `:21` 被註解掉），**這是上游根源**；<br>② `db_mw.py:64-86` — 自動填客戶編號的機制，`:71` 的 `not user_ctx` 會**整段跳過**。意思是沒有身分時新建的資料**不會被標上任何客戶**，這是同一個預設放行在「寫入」面的表現；<br>③ `jedi-iam/jedi_iam/authz/signed_token.py:82-101` — `_set_minimal_context()` 註解自陳「`tenant_id=None` → session_scope 判定 is_super（與原本『無 context = RLS bypass』的既有行為一致）」，**這是把預設放行當成契約在依賴**，修 C1-1 時一定會撞到。<br><br>**好消息**：`compliance-manager-be/common/middleware/request_context_mw.py`（CM-1301 的修正）已經在每個請求開始與結束時把身分歸零，**所以「上一個請求的身分殘留給下一個請求」這個更嚴重的問題已經修掉了**（見⑦）。 |
| **④** | **`db.py:30-37` 取不到 dialect 時回 True**（當作 PostgreSQL）。方向是安全的，但要確認反面：非 PostgreSQL 時整條隔離靜默不設，有沒有 consumer 在那個模式下跑正式流量？ | **工具在 F2 的前提裡提到「`_is_postgresql` 在無法判定時也回 True」，但沒有單獨成條。本報告作者查證後認為：這個設計是對的，反面風險目前不成立。**<br><br>**方向確實是安全的那一邊**：程式註解（`db.py:26-27`）自己寫著「取不到 dialect 時回 True：維持既有 PostgreSQL 行為，不讓判斷失敗變成靜默跳過 RLS（跳過 RLS 是資安問題，寧可炸在語法錯誤上）」——**判斷不出來就當作要做隔離**，這是正確的選擇。<br><br>**反面（非 PostgreSQL 時整條隔離靜默不設）目前不構成問題**：這個分支是為 `evidence-agent` 那類**單一客戶、裝在客戶自己機器上、根本沒有多客戶隔離需求**的使用情境開的（`db.py:19-21` 註解說明，出處 FR-066 D2）。那種部署本來就只有一家公司的資料，沒有隔離對象。<br><br>**要注意的是**：如果日後有人為了跑測試把主產品指向 SQLite，那條路上**所有隔離都不會設**——但那是測試環境，且判定依據是**連線本身的方言**（不是環境變數），要誤判得先真的連到非 PostgreSQL 的資料庫。**本棒未發現任何正式流量跑在非 PostgreSQL 模式下。** |
| **⑤** | **SQL 例外原文直接回前端**（`sql_exception.py:35-38`），對照 `handler.py:33-36` 的通用處理一起看；`handler.py:28-31` 回 `exception.name` 有沒有洩漏？ | **就是 F3，見 C1-3（MEDIUM，三票一致通過）。**<br><br>對照確認：**同一個檔案裡只有資料庫錯誤這一支會洩漏**，其餘（`ClientError` `:19`、`ServerError` `:25`、通用 `:36`、驗證錯誤 `:49`、`:59`）全部使用罐頭訊息，處理正確。<br><br>**`handler.py:28-31` 的 HTTPException 分支不構成洩漏**：它回的 `exception.name` 是 werkzeug 內建的標準名稱（`"Not Found"`、`"Method Not Allowed"`），**是固定字串、不含任何應用程式內部資訊**。狀態碼本身（404／405）不算洩漏——那是 HTTP 標準行為。<br><br>**附帶發現**：`handler.py:43` 回的 `error_code` 是**整數**（409、400），與全站「`GRC_*` 字串」的約定不符，**會讓前端的多語系翻譯對不上**（前端拿 error_code 當翻譯 key）。建議與 C1-3 一起修。 |
| **⑥** | **分頁／排序／過濾把前端字串直接 `getattr` 到 ORM model**（`base_repository_impl.py:177`／`:272`／`:477-479`）。前端能不能傳 `sort=password_hash` 或 `sort=__class__`？有沒有白名單？ | **（工具未報，人工查證）擋得住，見 C1-5。**<br><br>**排序有白名單**（`:271` 的 `sort_spec.field in all_fields`，清單來自資料表真實欄位定義），`__class__` 這類內部屬性一律被擋。**篩選沒有白名單但也不需要**——欄位名來自後端自己定義的查詢物件（`_filter.__dict__`），**前端只能填值、填不了欄位名**。<br><br>**沒有 SQL 注入面**：`getattr` 拿到的是 SQLAlchemy 欄位物件不是字串，`jsonb_extras.py:138` 的 `col.op("->>")(key)` 是參數化寫法。<br><br>**唯一的提醒**：整道防線就靠 `sort_spec.field in all_fields` 那半句，**建議在該行加註解說明它的資安角色**，避免日後有人為了功能需求拿掉它。 |
| **⑦** | **身分 contextvar 的生命週期。** request 結束有沒有清掉？gunicorn／APScheduler／eventlet 三種承載下會不會殘留到下一個請求（身分張冠李戴）？ | **（工具未報，人工查證）這個問題確實發生過，而且已經修好了——就是 CM-1301。三種承載逐一確認如下。**<br><br>**歷史事故**（記在 `common/middleware/request_context_mw.py:1-21`，寫得非常完整）：裝機實測時 root admin 同一組帳密連打 6 次只成功 1 次。根因正是身分殘留——gunicorn 預設一個工作程序一條執行緒連續處理多個請求，前一個請求的身分**原封不動留給下一個**；而登入請求本身沒有 JWT，讀到的就是上一個人的身分。<br><br>**現況（已修）**：`request_context_mw.py:60-69` 在 **`before_request`（請求開始）與 `teardown_request`（請求結束）兩邊都把身分歸零**，且在 `core/app_factory.py:22` 最早註冊，確保跑在任何會讀身分的鉤子之前。**兩邊都清是對的**——開頭清是治本（不管上一個請求怎麼結束都從乾淨狀態開始），結尾清是縮短殘留視窗。<br><br>**三種承載逐一確認**：<br>• **gunicorn 同步工作程序** → 已由上述鉤子解決 ✅<br>• **APScheduler 背景執行緒** → 各自在自己的執行緒內設身分（`jedi-detection/.../detection_profile_extraction_service.py:407`、`jedi-evidence-classification/.../evidence_classification_service.py:329`），與請求執行緒無關 ✅<br>• **eventlet / SocketIO** → **自成一套且處理正確**：`jedi-iam/jedi_iam/middleware/socketio_auth.py:24` 的註解明講「連線那次的 `set_user_context()` 不會留到後續事件的 greenthread」，所以把身分存進 socket session（`:176`），每個事件再用 `restore_user_context_from_session()`（`:196-201`）重設 ✅<br><br>**唯一還在的殘留**：`session_context`（資料庫連線那個 contextvar，`session_context.py`）在 `db.py:128` 的 `finally` 有正確 `reset`，**配對完整** ✅。<br><br>**結論：這一項沒有發現問題，且是三個目錄裡處理得最完整的一塊。**<br><br>**但要指出一個副作用**：CM-1301 的修法把身分歸零，**等於保證登入請求一定落進「沒有身分」的分支**——也就是 C1-1 那個關掉隔離的分支。修正註解（`:23-27`）自己也寫著「登入本來就必須看得到全部帳號才能驗密碼」。**所以⑦的修法與 C1-1 的問題是連動的**：修 C1-1 翻預設時，**必須同時給登入端點一個具名的系統身分**，否則登入會直接壞掉。 |
| **⑧** | **`identity/` 的 resolver 降級**（查失敗回空 dict 不 500）。這個「安全降級」會不會把「查不到＝沒權限」變成「查不到＝跳過檢查」？ | **（工具未報，人工查證）不會。這個降級是安全的，因為它從頭到尾不參與任何權限判斷。**<br><br>**這三個 resolver 做的事只有一件：把 id 換成好看的名字。** `resolvers.py:63-98` 定義三個介面——帳號 id → 暱稱、客戶 id → 客戶名稱、部門 id → 部門名稱。**純顯示用途**，回傳值只會被放進畫面上的 `*_name` 欄位。<br><br>**降級的後果是「名字變回 id」，不是「檢查被跳過」**：`context.py:117-129` 的 `_resolve()` 在查不到或出錯時回空字典，呼叫端拿到之後 `.get(key)` 取到 `None`，**畫面就顯示原始 id / 帳號**（`context.py:27` 註解明說）。**沒有任何一條路會因為它回空就放行什麼。**<br><br>**首腦特別確認過「有沒有誰拿它當權限依據」**：主產品唯一的接線在 `core/upload_file_wiring.py:53` 的 `AuthUserNameResolver`，用途是補上傳檔案的建立者暱稱——**純顯示**。<br><br>**唯一值得一提的副作用**（不是資安問題，是可維護性問題）：`upload_file_wiring.py:68-73` 的註解自陳踩過坑——少掛 `@transaction` 會讓查詢炸掉，但**降級機制把它吞成一行警告**，API 照樣回 200、只是暱稱永遠是空的。註解自己形容這是「降級機制把 bug 藏了起來」。**這是「不報錯卻很難查」的失效，值得記著，但不是資安漏洞。**<br><br>**結論：這一項沒有發現資安問題。** |
| **⑨** | **`session/redis/redis.py`**：連線參數從哪來、有沒有 TLS 憑證驗證（CM-1565 曾修過 jedi-iam 的同款）、key 命名有沒有客戶前綴？ | **（工具未報，人工查證）套件那支沒問題，但**在主產品裡找到 CM-1565 的漏網複製品，見 C1-4（MEDIUM）。**<br><br>**逐項回答**：<br>• **套件的 `session/redis/redis.py`** → **只有 3 行**，就是 `redis_client = FlaskRedis()`，連線設定全部由主產品在 `core/app_factory.py:168` 給。**這支本身沒有問題。**<br>• **連線參數從哪來** → `config/config.py:157` 的 `REDIS_URL`，帳密從環境變數 `REDIS_SECRET` 讀（**未印出任何值，只做存在性判斷**）。<br>• **憑證驗證** → **這裡有問題**：`common/util/redis_client_util.py:32` 寫死 `ssl_cert_reqs=False`（不檢查對方是不是真的伺服器），而 `jedi-iam` 的同名檔案**已經修好了**（CM-1565，`jedi-iam/jedi_iam/common/utils/redis_client_util.py:43-48`，還附了單元測試）。**主產品這份是漏網的複製品** → C1-4。<br>• **另外一個小落差**：`config/config.py:157` 的 `REDIS_URL` **寫死 `redis://`**（不加密），完全不理會 `REDIS_SSL` 這個設定；而隔壁 `core/app_factory.py:176` 給 SocketIO 用的那條**有正確依 `REDIS_SSL` 切換 `rediss://`**。**同一支程式兩種行為**——意思是就算部署方設了 `REDIS_SSL=true`，主要那條 Redis 連線**還是不加密的**。建議與 C1-4 一起修。<br>• **key 有沒有客戶前綴** → 目前用到的只有一處（`google_drive_integration_service.py:34` 的 `drive_oauth_state:`），key 帶的是一次性隨機字串，**不同客戶天生撞不到，沒有問題。** |

---

## 6. 這份結果可信到什麼程度

分兩層講，因為這兩件事的可信度差很多。

### 第一層：「這些問題真的存在嗎？」——高

**三條工具發現全部由首腦獨立開檔核對過**，每一條都能指到具體的檔名與行號：

- **C1-1／C1-2 的根因**（`db.py:117`）、**它有效力的證據**（84/105 條隔離規則靠這個旗標、`cm_app` 沒有繞過特權）、**兩條攻擊前提**（登入端點免認證、Drive webhook 無守門）**全部逐項核對屬實**。
- **C1-3** 的資料流（`sql_exception.py:37` → `handler.py:43`）核對屬實，且是三條裡**唯一三票一致通過**的。
- **C1-4** 與 **C1-5**、以及⑥⑦⑧⑨四項，是本報告作者逐檔查證的結果。

**但沒有任何一條做過實際攻擊驗證。** 依卡片紀律，這一棒**只讀不寫**、沒有對任何環境發過請求、沒有連任何資料庫、沒有印出任何金鑰值。所有結論都是「讀程式碼＋讀資料庫 schema 檔」的推論。

**兩條 HIGH 的可信度標記是「中」而非「高」**，原因是工具的三位檢查員**沒有全票通過**（2 比 1）。反對票的理由值得照實記下來：

- 一位認為「機制是真的，但追不到攻擊者實際拿到跨客戶資料的完整路徑」。
- 另一位認為「每一個未登入就能打到的端點，都另外有一道與資料庫隔離無關的秘密檢查擋著」——**這一點首腦核對後確認屬實**（Drive webhook 的 token 比對在 `google_drive_webhook_service.py:57`）。

**這兩張反對票不推翻洞的存在，但它們正確地指出了「可利用性沒有想像中直接」。** 誠實的說法是：**這是一個確實存在、影響面極大、但目前還沒有被證明能一步直達跨客戶資料的問題。** 它的主要危害是**放大其他所有 bug 的爆炸半徑**，而不是它自己就是一把鑰匙。

### 第二層：「只有這些嗎？」——低，不能宣稱掃透

**三個具體理由，都是工具自己講的**：

1. **🔴 工具自陳「本輪沒有逐檔閱讀帳本」**——原文說這一輪沒有申報每個檔案的閱讀情況，**所以無法證明 29 個檔案每一支都被讀到結論**。這是最重要的一條：「只有這三條」這句話**沒有證據支撐**。

2. **`low` 是快篩不是徹查。** 沒有清點階段、沒有威脅建模、沒有廣掃，就是「兩個研究員讀一輪 → 三個檢查員投一輪票」。工具自己也說「這一輪的目標是指定範圍，不是整棵樹，所以沒有完整性檢查可報」。

3. **範圍之外的東西完全沒看**：`jedi_common/` 的其餘部分（log、介面、工具、列舉、常數）與 monorepo 裡的另外 24 支套件，**這一輪一個字都沒讀**。那是 C2／C3 兩棒的事。

**另外，本報告有五項結論（C1-4、C1-5 與⑥⑦⑧⑨）是人工查證、沒有經過三個檢查員投票的**——它們的可信度來自「開檔逐行讀」，但**沒有第二個人獨立複核**。

### 🔴 越界說明（必讀）

**工具的研究員讀到了掃描範圍以外的地方**——進到了 `jedi-iam` 套件、以及主產品 `compliance-manager-be` 及其資料庫 schema。**而且好幾個判斷正是靠跨 repo 才站得住**：

- 「登入端點免認證」這個前提在 `jedi-iam`
- 「84/105 條隔離規則靠這個旗標」「`cm_app` 沒有繞過特權」在主產品的 `scripts/init/`
- 「Drive webhook 無守門」在主產品的 `api/`

**這是必要的追脈絡，不是越界重複掃描**——不追出去就只能說「這裡設了一個變數」，說不出「這個變數決定了 84 條規則的成敗」。本報告作者查證⑥⑦⑧⑨時同樣跨了 repo。

**但必須講清楚：那些 repo 沒有被稽核。**

**這裡乾淨不代表它們乾淨。** 研究員讀 `jedi-iam` 與主產品，是為了回答「jedi-common 的這幾行會不會出事」，**不是為了檢查那兩個地方有沒有自己的問題**。那兩個 repo 的完整檢查是別的 arc 的事（見跨 arc 總表）。

---

## 7. 執行概況

| 項目 | 數值 |
|---|---|
| 掃描範圍 | `jedi_common/session/`＋`identity/`＋`handler/`，**29 個受版控檔**（與卡片指定的檔數一致） |
| 版本 | commit `8aa6f0609c4434c0fa5206d5e99e31cad249646e`（branch `feature/FR-075`，工作目錄乾淨） |
| 工具 | Claude Code `claude-security` plugin |
| effort / focus / mode | `low` ／ attack-surface ／ codebase-scan |
| 研究員 | **派 2 個，回 2 個（零失敗、零中斷）** |
| 候選發現 | 提出 **11 條**，去除重複後剩 **8 條** |
| 檢查員投票 | **三個檢查員對 8 條候選各投一票，24 票全數投出，沒有漏投、沒有一條候選沒人看** |
| 投票結果 | **3 條通過**（C1-3 三票一致；C1-1／C1-2 各 2 票成立 1 票反對）／ **5 條被三票一致否決** |
| 被否決的 5 條 | 含**卡片重點①**（f-string 拼 SQL 注入）、**卡片重點②**（`can_manage_orgs` 無條件為真）、以及一條「`.env` 內的密碼」（判定為綁定本機的 Postgres 預設值，且測試碼裡本來就有同一份備援值） |
| 工具正式發現 | **3 條**（2 HIGH ＋ 1 MEDIUM） |
| 驗證章（stamp） | `verification.status: **verified**`，`reason: null`（**完整跑完，未中斷、未撞額度**） |
| 驗證輪次 | 1 輪，無候選遺失、無未驗項目、無待補項目 |
| 執行時間 | 3798 秒（約 **63 分鐘**） |
| **本報告發現** | **5 條**（工具貢獻 3 條合併為 2 個修法；C1-4、C1-5 及卡片⑥⑦⑧⑨為人工查證） |

---

## 8. 建議的下一步

按投資報酬率排序：

1. **C1-4（Redis 憑證驗證）先修**——修法現成（照抄 `jedi-iam` 已寫好的那段）、有現成測試可抄、風險極低。順便把 `config.py:157` 寫死的 `redis://` 改成依 `REDIS_SSL` 切換。**這是唯一一條「今天就能修完」的。**
2. **C1-3（SQL 錯誤原文外洩）次之**——只動 `sql_exception.py` 一個函式，改成罐頭訊息。順便把 `error_code` 從整數改成字串（前端翻譯才對得上）。
3. **刪掉 `db.py:97-99` 的 `can_manage_orgs`（卡片②）**——確認是死碼，留著只會誤導。**三行的事。**
4. **把 `db.py:95`／`:108`／`:113` 的 f-string 改成參數化（卡片①）**——這次被否決，但改了就不必再靠人記得「別把外部值帶進來」。成本很低。
5. **C1-1／C1-2（隔離預設放行）是大工程，要單獨規劃**——它就是 CM-1559，**決策者已在等修法方向的裁決**。這一棒提供的新資訊是：**它比原本記錄的更好觸及**（登入端點每次都經過、Drive webhook 完全沒守門）。動手前**必須先盤點所有依賴這個預設的路徑**（登入、排程、啟動、signed_token 下載），**每一條先給明確的提權方式，再翻預設**——順序反了登入會直接壞掉。
6. **順手處理 Drive webhook 無守門這件事本身**（`google_drive_webhook_route.py:33-39`）——不管 C1-1 怎麼修，一個完全沒有守門的端點與同目錄其他每一條都掛 `jwt_required` 的對比太刺眼，值得單獨確認是刻意還是遺漏。
7. **在 `base_repository_impl.py:271` 加一行註解**，說明 `sort_spec.field in all_fields` 是資安防線，別隨手拿掉。

---

*本報告依 `security-scan-lead` skill 第十節白話規則撰寫。所有發現均為讀程式碼與資料庫 schema 檔的結果，**未在任何環境實際執行攻擊、未連線任何資料庫、未印出任何金鑰或密碼值**。工具產物目錄 `jedi-common/CLAUDE-SECURITY-20260910-123204/` 不入版控。*
