FR-085.C1 掃描報告:授權鏈核心(session/RLS 注入/身分/例外處理)

FR-085.C1 掃描報告:授權鏈核心(session/RLS 注入/身分/例外處理)

  • 卡片CM-1647(母卡 CM-1646
  • 掃描範圍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:37handler.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-39GoogleDriveWebhookRoute.post 一個守門都沒有——同一個目錄的 google_drive_sync_route.py 每一條路由都掛了 jwt_required,對比非常明顯。它呼叫的服務有 @transactionapp/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-118elif 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-21get_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:41cm_app 沒有繞過隔離的特權(:31cmmgr 才有)

怎麼修

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

db.pysession_scope() 裡,把 :115-118elif 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:37return str(exc).splitlines()[0], code根因:38 的 fallback 也是同一行寫法)
  • jedi-common/jedi_common/handler/handler.py:38-43handle_sql_error():43 把那串直接 jsonify 出去
  • 對照組:jedi-common/jedi_common/handler/handler.py:33-36 — 通用錯誤處理用罐頭訊息(正確做法就在隔壁 5 行)
  • jedi-common/jedi_common/handler/sql_exception.py:9-23SQL_ERROR_MAP,決定哪些錯誤走這條路

怎麼修

sql_exception.pysql_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:37return str(exc).splitlines()[0] 確實流到 handler/handler.py:43jsonify({"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:32ssl_cert_reqs=False根因,寫死的
  • compliance-manager-be/common/util/redis_client_util.py:25-34connect() 整段
  • 已修好的對照組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:15compliance-manager-be/infra/survey/adapters.py:41

怎麼修

jedi-iam 已經寫好的那段搬過來——在 common/util/redis_client_util.pyconnect() 裡:

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)——有白名單,這是最重要的一道

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

關鍵是後半段的 sort_spec.field in all_fieldsall_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:138col.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 直接拼進 SQLdb.py:95:108:113),有沒有路讓攻擊者控制其中的字元?(卡片標為「本棒最重要」) 工具三票一致否決,理由是「沒有攻擊者可控的值到得了那個內插點」。首腦認同否決,但要寫明這是「目前沒人餵得進去」,不是「這個寫法安全」。

值的來源鏈:JWT 內容 → jedi-iam/jedi_iam/middleware/jwt_mw.py:87-125set_user_context_loader 組出 UserContextDTO → 存進 contextvar → db.py:87 讀出來 → :95 拼進 SQL。其中 user_ctx.id 是資料庫的整數主鍵、allowed_tenant_paths 是資料庫查出來的樹狀路徑字串(形如 /1/102/),
兩者都是後端自己從資料庫撈的,前端碰不到


但 f-string 拼 SQL 本身仍是脆弱寫法:它的安全性完全依賴「目前沒有任何呼叫端把外部值帶進來」這個當下事實,而不是依賴寫法本身。哪天有人加一條路把外部值帶進 allowed_tenant_paths,這個洞就成立了——而且加那條路的人不會知道自己踩到了什麼,因為 db.py 這一行看起來只是在設定一個內部變數。

建議:即使這次否決,仍應改成 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/ 零命中,否決成立——那行是死碼。

本報告作者另以逐條解析語句精算複核:02-schema.sql
105 條隔離規則、以及所有資料庫函式,提及 can_manage_orgs 的數量都是 0
。設了沒人讀,目前沒有任何效果

但不建議就這樣留著:它會讓讀程式碼的人以為「有一道組織權限的守門」而不去補真正的守門。建議直接刪掉那三行db.py:97-99),若確實需要組織層權限再另行設計。

對照:隔壁的 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。

周邊同型已找到三處
auth_context.py:17-21get_user_context() 回 None(原本要拋錯的那行在 :21 被註解掉),這是上游根源
db_mw.py:64-86 — 自動填客戶編號的機制,:71not user_ctx
整段跳過**。意思是沒有身分時新建的資料不會被標上任何客戶,這是同一個預設放行在「寫入」面的表現;
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 時一定會撞到。

好消息compliance-manager-be/common/middleware/request_context_mw.py(CM-1301 的修正)已經在每個請求開始與結束時把身分歸零,所以「上一個請求的身分殘留給下一個請求」這個更嚴重的問題已經修掉了(見⑦)。
db.py:30-37 取不到 dialect 時回 True(當作 PostgreSQL)。方向是安全的,但要確認反面:非 PostgreSQL 時整條隔離靜默不設,有沒有 consumer 在那個模式下跑正式流量? 工具在 F2 的前提裡提到「_is_postgresql 在無法判定時也回 True」,但沒有單獨成條。本報告作者查證後認為:這個設計是對的,反面風險目前不成立。

方向確實是安全的那一邊:程式註解(db.py:26-27)自己寫著「取不到 dialect 時回 True:維持既有 PostgreSQL 行為,不讓判斷失敗變成靜默跳過 RLS(跳過 RLS 是資安問題,寧可炸在語法錯誤上)」——判斷不出來就當作要做隔離,這是正確的選擇。

反面(非 PostgreSQL 時整條隔離靜默不設)目前不構成問題:這個分支是為 evidence-agent 那類單一客戶、裝在客戶自己機器上、根本沒有多客戶隔離需求的使用情境開的(db.py:19-21 註解說明,出處 FR-066 D2)。那種部署本來就只有一家公司的資料,沒有隔離對象。

要注意的是:如果日後有人為了跑測試把主產品指向 SQLite,那條路上所有隔離都不會設——但那是測試環境,且判定依據是連線本身的方言(不是環境變數),要誤判得先真的連到非 PostgreSQL 的資料庫。本棒未發現任何正式流量跑在非 PostgreSQL 模式下。
SQL 例外原文直接回前端sql_exception.py:35-38),對照 handler.py:33-36 的通用處理一起看;handler.py:28-31exception.name 有沒有洩漏? 就是 F3,見 C1-3(MEDIUM,三票一致通過)。

對照確認:
同一個檔案裡只有資料庫錯誤這一支會洩漏
,其餘(ClientError :19ServerError :25、通用 :36、驗證錯誤 :49:59)全部使用罐頭訊息,處理正確。

handler.py:28-31 的 HTTPException 分支不構成洩漏:它回的 exception.name 是 werkzeug 內建的標準名稱("Not Found""Method Not Allowed"),是固定字串、不含任何應用程式內部資訊。狀態碼本身(404/405)不算洩漏——那是 HTTP 標準行為。

附帶發現handler.py:43 回的 error_code整數(409、400),與全站「GRC_* 字串」的約定不符,會讓前端的多語系翻譯對不上(前端拿 error_code 當翻譯 key)。建議與 C1-3 一起修。
分頁/排序/過濾把前端字串直接 getattr 到 ORM modelbase_repository_impl.py:177:272:477-479)。前端能不能傳 sort=password_hashsort=__class__?有沒有白名單? (工具未報,人工查證)擋得住,見 C1-5。

排序有白名單:271sort_spec.field in all_fields,清單來自資料表真實欄位定義),__class__ 這類內部屬性一律被擋。篩選沒有白名單但也不需要——欄位名來自後端自己定義的查詢物件(_filter.__dict__),前端只能填值、填不了欄位名

沒有 SQL 注入面getattr 拿到的是 SQLAlchemy 欄位物件不是字串,jsonb_extras.py:138col.op("->>")(key) 是參數化寫法。

唯一的提醒:整道防線就靠 sort_spec.field in all_fields 那半句,建議在該行加註解說明它的資安角色,避免日後有人為了功能需求拿掉它。
**身分 contextvar 的生命週期。 request 結束有沒有清掉?gunicorn/APScheduler/eventlet 三種承載下會不會殘留到下一個請求(身分張冠李戴)? (工具未報,人工查證)這個問題確實發生過,而且已經修好了——就是 CM-1301。三種承載逐一確認如下。

歷史事故(記在 common/middleware/request_context_mw.py:1-21,寫得非常完整):裝機實測時 root admin 同一組帳密連打 6 次只成功 1 次。根因正是身分殘留——gunicorn 預設一個工作程序一條執行緒連續處理多個請求,前一個請求的身分
原封不動留給下一個**;而登入請求本身沒有 JWT,讀到的就是上一個人的身分。

現況(已修)request_context_mw.py:60-69before_request(請求開始)與 teardown_request(請求結束)兩邊都把身分歸零,且在 core/app_factory.py:22 最早註冊,確保跑在任何會讀身分的鉤子之前。兩邊都清是對的——開頭清是治本(不管上一個請求怎麼結束都從乾淨狀態開始),結尾清是縮短殘留視窗。

三種承載逐一確認
gunicorn 同步工作程序 → 已由上述鉤子解決 ✅
APScheduler 背景執行緒 → 各自在自己的執行緒內設身分(jedi-detection/.../detection_profile_extraction_service.py:407jedi-evidence-classification/.../evidence_classification_service.py:329),與請求執行緒無關 ✅
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)重設 ✅

唯一還在的殘留session_context(資料庫連線那個 contextvar,session_context.py)在 db.py:128finally 有正確 reset配對完整 ✅。

結論:這一項沒有發現問題,且是三個目錄裡處理得最完整的一塊。

但要指出一個副作用:CM-1301 的修法把身分歸零,等於保證登入請求一定落進「沒有身分」的分支——也就是 C1-1 那個關掉隔離的分支。修正註解(:23-27)自己也寫著「登入本來就必須看得到全部帳號才能驗密碼」。所以⑦的修法與 C1-1 的問題是連動的:修 C1-1 翻預設時,必須同時給登入端點一個具名的系統身分,否則登入會直接壞掉。
identity/ 的 resolver 降級(查失敗回空 dict 不 500)。這個「安全降級」會不會把「查不到=沒權限」變成「查不到=跳過檢查」? (工具未報,人工查證)不會。這個降級是安全的,因為它從頭到尾不參與任何權限判斷。

這三個 resolver 做的事只有一件:把 id 換成好看的名字。 resolvers.py:63-98 定義三個介面——帳號 id → 暱稱、客戶 id → 客戶名稱、部門 id → 部門名稱。純顯示用途,回傳值只會被放進畫面上的 *_name 欄位。

降級的後果是「名字變回 id」,不是「檢查被跳過」context.py:117-129_resolve() 在查不到或出錯時回空字典,呼叫端拿到之後 .get(key) 取到 None畫面就顯示原始 id / 帳號context.py:27 註解明說)。沒有任何一條路會因為它回空就放行什麼。

首腦特別確認過「有沒有誰拿它當權限依據」:主產品唯一的接線在 core/upload_file_wiring.py:53AuthUserNameResolver,用途是補上傳檔案的建立者暱稱——純顯示

唯一值得一提的副作用(不是資安問題,是可維護性問題):upload_file_wiring.py:68-73 的註解自陳踩過坑——少掛 @transaction 會讓查詢炸掉,但降級機制把它吞成一行警告,API 照樣回 200、只是暱稱永遠是空的。註解自己形容這是「降級機制把 bug 藏了起來」。這是「不報錯卻很難查」的失效,值得記著,但不是資安漏洞。

結論:這一項沒有發現資安問題。
**session/redis/redis.py:連線參數從哪來、有沒有 TLS 憑證驗證(CM-1565 曾修過 jedi-iam 的同款)、key 命名有沒有客戶前綴? (工具未報,人工查證)套件那支沒問題,但在主產品裡找到 CM-1565 的漏網複製品,見 C1-4(MEDIUM)。

逐項回答
套件的 session/redis/redis.py只有 3 行,就是 redis_client = FlaskRedis(),連線設定全部由主產品在 core/app_factory.py:168 給。這支本身沒有問題。
連線參數從哪來config/config.py:157REDIS_URL,帳密從環境變數 REDIS_SECRET 讀(未印出任何值,只做存在性判斷)。
憑證驗證這裡有問題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。
另外一個小落差config/config.py:157REDIS_URL 寫死 redis://(不加密),完全不理會 REDIS_SSL 這個設定;而隔壁 core/app_factory.py:176 給 SocketIO 用的那條有正確依 REDIS_SSL 切換 rediss://同一支程式兩種行為——意思是就算部署方設了 REDIS_SSL=true,主要那條 Redis 連線還是不加密的。建議與 C1-4 一起修。
key 有沒有客戶前綴 → 目前用到的只有一處(google_drive_integration_service.py:34drive_oauth_state:),key 帶的是一次性隨機字串,不同客戶天生撞不到,沒有問題。

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

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

第一層:「這些問題真的存在嗎?」——高

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

  • C1-1/C1-2 的根因db.py:117)、它有效力的證據(84/105 條隔離規則靠這個旗標、cm_app 沒有繞過特權)、兩條攻擊前提(登入端點免認證、Drive webhook 無守門)全部逐項核對屬實
  • C1-3 的資料流(sql_exception.py:37handler.py:43)核對屬實,且是三條裡唯一三票一致通過的。
  • C1-4C1-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-99can_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/ 不入版控。