檢查日期 2026-09-25|對應卡片 CM-2164(母卡 CM-2152)|範圍 16 個檔案、1,921 行|工具 run ID
wf_a2018933-ffc|掃描版本e1c92303(feature/review)|驗證章verified(3 候選、9 票全投完、零漏投)
卡片點名最要緊的「系統設定守門」確實是這一棒的重點:工具兩條 3:0 都落在這支,而且都是總表已知的洞(第 74、72 項),掃描當時主線一條都還沒修(後已修,1.21.0 出貨)。本棒淨新增的是修正線那道「總部層級」守門本身的一個縫:它擋得住子公司,擋不住別家客戶的總部。
verified,3 候選 9 票全投完):F1(高,3:0)任何客戶的管理員都能改掉全站唯一那一列寄信/LDAP 設定=總表第 74 項;F2(中,3:0)任何登入者讀得到物件儲存的帳號密碼明文=總表第 72 項。工具另報的第三條「即時通知連線不驗身分」被面板 0:3 否決。feature/review)上仍然只有能力點一道,子公司管理員還是寫得進全站共用那一列。修正線(fix/security-b1)已經補了「總部層級」第二道(CM-2054),但我實測發現那道守門擋得住「同一家客戶的子公司」,擋不住「另一家客戶的總部」——而寄信和 LDAP 的共用列是全站只有一列,不是每家客戶一列。WITH CHECK (true)),但寫進去的公司編號一律來自登入身分,不從網址或內容拿,目前沒有可以利用的路。讀/改/刪的保護是正確版本(5 月已修好),跟總表第 178 項那兩張壞掉的表不一樣。notification_socketio_handler.py)真的完全不驗身分,但目前沒有東西經過它:不用登入就能連、進任何房間、對房間廣播任意內容,我實測重現。面板否決的理由我開檔核對過屬實——前端唯一會連它的元件 ProcessDiscussBox.vue 沒有任何地方引用,伺服器也從不往這個頻道送資料。所以今天是一扇沒有門的空房間:評低,但哪天有人把討論串元件接回畫面,就會變成不用帳號就能偷聽別家討論串。common/integrity/adapters.py:不寫 api_logs,只讀。 卡片問的「雜湊鏈寫入 api_logs 的租戶歸屬」前提不成立。有一個小問題:竄改事件旁證裡的「來源 IP」直接讀 X-Forwarded-For 標頭,可以偽造;修正線已改掉。tenant_storage_config_seeder.py):寫入有限定租戶;把原廠 AI 金鑰整格抄給新客戶這件事=總表第 161 項,修正線 CM-2192 已修,主線還沒合回。三塊零散但決策者裁定一起做的程式:
| 檔案 | 行數 | 角色 |
|---|---|---|
app/system_config/service/guarded_system_config_service.py |
665 | 系統設定讀寫的總守門:遮罩機密、共用設定落總部那列、受限群組加驗平台管理員 |
common/integrity/adapters.py |
357 | 防竄改套件的主專案接線:驗章、機器指紋、竄改時蒐集旁證 |
app/system_config/service/tenant_storage_config_seeder.py |
129 | 建新客戶時,把原廠的儲存設定與 AI 設定抄一份給他 |
domain/oscal/service/reconciliation/base.py |
107 | 人員對帳共用骨架:人工指定 → 完全相同 → 正規化後相同 → 模糊比對 四段 |
domain/oscal/service/reconciliation/person_reconciler.py |
101 | 人員比對:用 email、帳號名稱找系統使用者 |
domain/oscal/service/reconciliation/organization_reconciler.py |
101 | 組織比對:用單位名稱找組織 |
domain/oscal/service/ssp_docx_parse_job_domain_service.py |
71 | Word 匯入解析工作單的建立/狀態轉換 |
domain/oscal/service/ssp_excel_parse_job_domain_service.py |
71 | Excel 匯入解析工作單的建立/狀態轉換 |
infra/oscal/repository/ssp_excel_parse_job_repo_impl.py |
56 | Excel 解析工作單表的讀寫 |
infra/oscal/repository/ssp_docx_parse_job_repo_impl.py |
54 | Word 解析工作單表的讀寫 |
app/notification/handler/notification_socketio_handler.py |
53 | 即時通知 WebSocket 處理器 |
domain/oscal/service/reconciliation/_normalizers.py |
51 | 比對前的字串整理(去空白、去 +別名、去「股份有限公司」) |
api/oscal/serializers/ssp/ssp_party.py |
47 | SSP 人員欄位的輸入/輸出格式 |
domain/oscal/service/party_reconciliation_service.py |
30 | 人員對帳入口:分派給人員/組織比對 |
domain/oscal/service/reconciliation/__init__.py |
18 | 套件匯出 |
domain/oscal/service/reconciliation/match_method.py |
10 | 比對方式的列舉值 |
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度+為什麼 | 來源 |
|---|---|---|---|---|---|---|
| F1 | 任何客戶的管理員都能改掉全站唯一那一列寄信伺服器/LDAP 設定,進而攔截任何人(含平台管理員)的重設密碼信 | 見總表第 74 項 | 任一客戶的系統管理員(新客戶開通時自動拿到能力點) | guarded_system_config_service.py:458(寫入全站共用列之前要求平台管理員) |
🔴 高 | 工具 F1,面板 3:0;=總表第 74 項,不另計 |
| F2 | 任何登入者都讀得到物件儲存的帳號密碼明文 | 見總表第 72 項 | 任一有效登入 | guarded_system_config_service.py:554(儲存設定比照 AI/雲端硬碟遮罩);api/system_config/routes/system_config_route.py:131(讀取補能力點) |
🟠 中 | 工具 F2,面板 3:0;=總表第 72 項,不另計 |
| U12-1 | 即時通知連線不驗身分、加入任何房間不檢查、reload 事件把呼叫端內容原樣廣播 |
今天沒有實害:伺服器從不往這個頻道送資料、前端唯一的使用元件沒被引用。哪天討論串元件接回畫面、或伺服器那行 namespace 對齊,就變成不用帳號偷聽別家流程討論串(含留言者帳號與留言全文) | 不需要任何帳號;偷聽要知道房間名稱(process-discussion:<流程編號>) |
app/notification/handler/notification_socketio_handler.py:19(改繼承 AuthenticatedNamespace);:34 on_join 核對流程歸屬;:43 on_reload 整支刪掉 |
🟢 低:門完全沒鎖,但房間裡沒東西 | 工具 C3,面板 0:3 否決(否決理由 runner 開檔核對屬實);runner 實測重現「不驗身分」本身 |
| U12-3 | 修正線的「總部層級」守門:同一家客戶的子公司擋得住,另一家客戶的總部擋不住,而寄信/LDAP 的共用列全站只有一列 | 客戶 B 的總部管理員仍然能改掉全站唯一那一列寄信伺服器/LDAP 設定,影響到客戶 A 和平台自己 | 另一家客戶的總部管理員帳號(持有 smtp-config.update 或 ldap-config.update,新客戶開通時自動拿到) |
修正線 common/authz/tenant_hq.py(判準是「站在第二層」,沒區分「是哪一家的第二層」);產品面要先決定:寄信/LDAP 究竟是「全站一份」還是「每家客戶一份」 |
🔴 高(若產品定義是全站一份):跟第 74 項原本的危害完全相同,只是攻擊者從「任何客戶的子公司管理員」縮小到「任何客戶的總部管理員」 | runner 對修正線實測(未經三人面板);見下方「疑點 ②」 |
| U12-4 | 人員比對收到公司編號但沒拿來過濾;平台管理員身分下會橫跨所有客戶比對 | 平台管理員替客戶 A 匯入 SSP 時,文件上的 email 若剛好對到客戶 B 的某人,SSP 上會註記成客戶 B 那個人的使用者編號;匯入當下預覽畫面顯示對方名字與帳號,編號隨 SSP 匯出帶出去(客戶 A 自己的使用者之後打開,名字會被資料庫隔離擋成空白) | 平台管理員身分操作匯入;文件裡的 email 在別家公司有同名帳號(開發環境已有 1 組實例) | domain/oscal/service/reconciliation/person_reconciler.py:26、:41、:58、:76(四處查詢都要比照組織比對加 tenant_id) |
🟢 低:一般客戶身分被資料庫隔離擋住;配對結果只是文件上的附註,不給權限、不把人加進專案 | runner 開檔+實測(未經三人面板) |
| U12-5 | 解析工作單兩張表的「新增」資料庫保護是 WITH CHECK (true) |
程式若哪天從請求內容拿公司編號,資料庫不會擋 | 目前沒有路:公司編號一律取自登入身分 | 出貨基線 scripts/init/02-schema.sql:24970、:25004 |
🟢 低(第二道防線有缺、第一道完整) | runner 開檔+DEV 唯讀查 pg_policies(未經三人面板) |
| U12-6 | 防竄改旁證的「來源 IP」直接讀 X-Forwarded-For 標頭 |
竄改事件的旁證紀錄可被偽造 IP,事後追查誤導 | 在竄改被偵測的那個請求裡帶假標頭 | common/integrity/adapters.py:269-273(修正線已改成 request.remote_addr) |
🟢 低:只影響旁證,旁證本身文件就寫明「不是身分溯源」 | runner 開檔(未經三人面板);修正線已修 |
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- U12-1(即時通知頻道不驗身分)=總表第 213 項,✅ 已修(拔除,CM-2373,1.21.1;頻道、留言 API 與前端元件一併拆除)。
- U12-3(總部層級守門)=總表第 212 項,✅ 已修(CM-2202,commit
6cfcb116e,1.21.0 出貨)。- U12-4(人員對帳不分公司)=總表第 214 項,✅ 已修(CM-2211,commit
fe928859f,1.21.0 出貨)。- U12-5(解析工作單表新增規則)=總表第 215 項,✅ 已修(CM-2215,commit
822216b6c,1.21.0 出貨)。- U12-6(旁證來源 IP)=總表第 216 項,✅ 已修(FR-114 CM-2171,commit
908f9dd0e,1.21.0 出貨)。- 工具 F1/F2 舊帳:總表第 74/72 項,均 ✅ 已修。
工具只讀程式碼、沒有執行任何攻擊;三條候選各由三位檢查員從「打不打得到」「後果多大」「有沒有別的防線」三個角度投票。
app/system_config/service/guarded_system_config_service.py:458(_write_shared_root_config)。PUT /api/1.0/system/config/SMTP/*,把主機改成自己的機器、changePwd: false(系統會保留原本的寄信密碼)。然後對任何人(包括平台管理員)按「忘記密碼」,重設信就經過攻擊者的郵件伺服器,攻擊者拿到重設連結就能改掉對方密碼。系統登入攻擊者的郵件伺服器時,還會把公司真正的寄信帳密送過去。LDAP 同理:指到攻擊者的帳號目錄,就能控制所有用 LDAP 登入的人。smtp-config.update/ldap-config.update,而這兩個是客戶層級、每家客戶的系統管理員開通時自動拿到;_platform_admin_only(:252-257)只列 AI 與雲端硬碟兩組。寫入走繞過資料庫隔離的 write_root_config_value。:458、:651-657、:607-616 三處寫入都沒有平台管理員檢查;DEV 唯讀查三個能力點 is_platform=f(2026-09-25 15:30);寄信讀取端 app/notification/service/notification_service.py:58 確實讀 read_root_config_value("SMTP","*")。屬實。app/system_config/service/guarded_system_config_service.py:554(get_system_config_by_key);入口 api/system_config/routes/system_config_route.py:131 只有 @jwt_required。storage-config.* 能力點的稽核員)打 GET /api/1.0/system/config/STORAGE_CONFIG/CONFIG,回應裡 access_key/secret_key 是明文。_mask_secrets(:234-245)只遮 AI 與雲端硬碟兩組;套件的遮罩名單沒有 STORAGE_CONFIG。_mask_storage),但修正線的 system_config_route.py:131 讀取入口仍然沒補能力點(我開 fix/security-b1 那一行確認)——遮罩補了,門還是開的;拿不到密鑰了,但端點、帳號、儲存桶名稱仍然看得到。reload 原樣轉發。flow_engine_route.py:121)送往 /socket/process-discussion,不是這個頻道,所以進了房間也聽不到任何東西;②前端唯一連這個頻道的元件 ProcessDiscussBox.vue 沒有任何地方引用,是死碼;③就算接回去,收到 reload 也只是用受害者自己的登入身分重抓留言,不顯示廣播內容。ProcessDiscussBox 除了元件本身零引用;/socket/notification 在 jedi 套件裡也沒有任何人往裡送。否決理由屬實,我原本的「中」降為「低」(見 U12-1 與疑點 ⑥)。零發現。
guarded_system_config_service.py:守門的涵蓋名單完不完整?其他設定群組有沒有兩道?先講結論:AI 金鑰(AI_PROVIDER_CONFIG)和 Google 雲端硬碟應用程式設定(GOOGLE_DRIVE_APP_CONFIG)兩組有「能力點+平台管理員」兩道;其他群組只有能力點一道,這是設計,不是漏。
逐組對照(主線 feature/review):
| 設定群組 | 畫面 | 第一道:能力點 | 第二道 | 實體放在哪 | 只有一道要不要緊 |
|---|---|---|---|---|---|
AI_PROVIDER_CONFIG |
AI 服務設定 | 泛用端點走 system_config.*(誰都沒有,只有超級管理員);選單綁 storage-config.* |
✅ 平台管理員(:252-277) |
每家客戶一列 | — |
GOOGLE_DRIVE_APP_CONFIG |
雲端硬碟應用程式設定 | 同上 | ✅ 平台管理員 | 平台那一列 | — |
STORAGE_CONFIG |
儲存設定 | storage-config.*(非平台層) |
只有一道 | 每家客戶一列,資料庫隔離擋別家 | 不要緊:改的是自己那列 |
NOTIFY_CONFIG |
通知設定 | notify_config.*(非平台層) |
只有一道 | 每家客戶一列(讀取端 tenant_notify_config_reader.py:37 按租戶讀) |
不要緊 |
ISSUE_INTEGRATE_CONFIG |
議題整合(GitLab/GitHub) | issue-integrate-config.*(平台層,DEV 實查 is_platform=t) |
能力點本身就只發給平台 | 平台那一列 | 不要緊:客戶拿不到能力點 |
SMTP |
寄信伺服器 | smtp-config.*(非平台層,DEV 實查) |
❌ 主線沒有 | 全站只有平台那一列(shared_config_app_service.py:38-41) |
🔴 要緊=總表第 74 項 |
THIRD_PARTY_LOGIN/LDAP |
LDAP 帳號目錄 | ldap-config.*(非平台層) |
❌ 主線沒有 | 全站只有平台那一列 | 🔴 同上 |
RUNTIME_CONFIG |
登入安全政策 | security-policy.*(非平台層) |
❌ 主線沒有(專屬端點 security_policy_app_service.py:162 也沒有) |
全站只有平台那一列 | 🔴 同上 |
PASSWORD_POLICY、WEB_IDEL_CONFIG 等不在對照表的 |
— | 退回 system_config.*(平台層,DEV 實查 is_platform=t) |
能力點本身就只發給平台 | 平台那一列 | 不要緊 |
判斷方法:「只有一道要不要緊」取決於這組設定的實體是每家一列、還是全站一列。每家一列的,能力點加資料庫隔離就夠;全站一列的(SMTP、LDAP、登入規則),能力點發給了每家客戶的管理員,就一定需要第二道。守門服務裡的 _platform_admin_only(:252-257)名單只列 AI 與雲端硬碟兩組,這份名單的判準是「第一版不開放客戶自填」,不是「全站共用一列」——所以 SMTP/LDAP 不在裡面是對的,它們的第二道本來就應該是另一條規則(總部層級),而那條規則主線還沒有。
另外看到兩件相關但不在本棒範圍的事:
api/system_config/routes/system_config_route.py:131 與套件的兩支)=總表第 72 項/第 39 項,修正線上 :131 仍然沒補(我開修正線 fix/security-b1 那一行確認過)。本棒守門服務的讀取路徑對 AI/雲端硬碟兩組有補平台管理員,所以這兩組的讀取是安全的;其他組靠第 72 項那張工單。di_containers/dashboard_apis/system_config.py)沒有申報 required_capabilities,AI 儀表板套件的新版規則是「沒申報就拒絕」,所以這支目前是拒絕狀態,不是公開。主線:是,還是寫得進去。修正線:子公司擋住了,但另一家客戶的總部還寫得進去。
主線(feature/review,本棒掃描版本):
PUT /system/config/<group>/<key>(system_config_route.py:142-150)、套件 by-uid PUT/DELETE、套件 POST、登入政策專屬端點。core/plugins/system_core.py:98-115 的 assert_config_capability)。守門服務裡的 _assert_restricted_group_access 對這三組直接 return(因為不在 _platform_admin_only 名單)。update_config_by_group_key(:651-657)與 update_system_config(:607-616)看到是共用設定,就走 _write_shared_root_config 寫進平台那一列,繞過資料庫隔離(infra/system_config/system_config_root_reader.py 的 write_root_config_value)。smtp-config.update、ldap-config.update、security-policy.update 三個能力點 is_platform 都是 f。修正線(fix/security-b1,CM-2054 e90d8e3cb):
新增「軸⑦ 總部層級」common/authz/tenant_hq.py,名單 common/constant/company_wide_config.py 列了 SMTP、THIRD_PARTY_LOGIN、RUNTIME_CONFIG 三組。
守門服務的六個寫入方法、core/plugins/system_core.py 的能力點分流、登入政策專屬端點,全部都補了,連 by-uid 寫共用列那條提前 return 的分支(update_system_config 裡)也補了。覆蓋面我逐條對過,沒有漏的入口。
但判準有一個洞:viewer_is_tenant_hq() 的判準是「可見租戶路徑最短那條剛好兩層」。我寫了一段小程式實測:
| 身分 | 路徑 | 能不能寫全站共用那一列 |
|---|---|---|
| 客戶 A 總部 | /1/102/ |
能 |
| 客戶 A 子公司 | /1/102/152/ |
擋住 ✅ |
| 另一家客戶的總部 | /1/131/ |
能 ⚠️ |
寄信與 LDAP 的共用列是全站只有一列(SHARED_ROOT_CONFIGS 寫入一律落平台那列,讀取端 read_root_config_value("SMTP","*") 也只讀那列)。所以客戶 131 的總部管理員改寄信伺服器,改到的是客戶 102 和平台自己在用的那一台。第 74 項原本的危害在修正線上只從「任何客戶的子公司管理員」縮小成「任何客戶的總部管理員」,沒有消失。
這個洞的根源是產品定義還沒說清楚:第 74 項討論時補的「方向④」寫的是「只給客戶自己的第一層租戶改」,這句話在「每家客戶有自己一份」的前提下才成立;但寄信/LDAP 目前的資料模型是全站一份。修正線照方向④寫,卻沒有把資料模型改成每家一份。
修法要先做產品決策(二選一):①寄信/LDAP 真的是全站一份 → 這三組的寫入改成只有平台管理員(require_platform_admin,現成);②每家客戶該有自己一份 → 要把 SHARED_ROOT_CONFIGS 的落點從「平台那列」改成「該客戶總部那列」,讀取端(寄信、LDAP 登入)也要跟著改成按客戶讀,這是一件資料模型的改動,不是守門。登入規則(RUNTIME_CONFIG)同理。
tenant_storage_config_seeder.py:AI 設定寫入時有沒有限定租戶?有。 寫入走 infra/system_config/tenant_config_seed_writer.py:70-84 的 INSERT ... ON CONFLICT DO NOTHING,tenant_id 是呼叫端傳進來的新客戶編號,而資料庫的新增規則是 app_tenant_allowed_for_session(tenant_id)(沒有超級管理員例外),建立者的身分必須看得到那家新客戶才寫得進去。只會新增、永遠不會蓋掉既有列。
真正的問題是「抄了什麼」:主線把原廠 AI_PROVIDER_CONFIG 整格(含金鑰)照抄給每家新客戶(:52-55)=總表第 161 項。修正線 CM-2192(21874f869)已加 _strip_ai_secrets 只抄非機密欄位,主線尚未合回。
common/integrity/adapters.py:雜湊鏈寫入 api_logs(沒有客戶隔離)時的租戶歸屬?前提不成立:這支不寫 api_logs。 全檔唯一碰資料庫的地方是 _active_sessions_snapshot(:284-327),對 public.api_logs 做唯讀 SELECT,目的是竄改事件成立當下,記一份「最近 30 分鐘有哪些帳號在線」當旁證。它不分客戶是對的——竄改是整台主機的事件,不屬於任何一家客戶;而這份旁證只寫進防竄改套件自己的事件表,不回給任何 API。
沒有雜湊鏈寫入;驗章用的是套件內建公鑰(LicenseEngineVerifier),環境變數只能改「標記檔放哪」「要不要在源碼模式強制」這類部署參數,註解明寫「可設定=可關閉」的參數刻意不接環境變數。
順帶看到一個小問題(U12-6):_current_request_context(:269-273)的來源 IP 優先讀 X-Forwarded-For 標頭,呼叫端可以自己帶假的。影響只是竄改旁證被誤導,修正線已改成讀 request.remote_addr(由 ProxyFix 設定)。
這兩張(ssp_docx_parse_jobs/ssp_excel_parse_jobs)的讀/改/刪規則是對的;寫壞的是另外兩張。
pg_policies:兩張表的 SELECT/UPDATE/DELETE 都是 is_super_admin='t' OR app_tenant_allowed_for_session(tenant_id)——這是 5 月 2026-05-01-fix-ssp-docx-parse-jobs-rls.sql 修好的正確寫法。ap_docx_parse_jobs/ar_xlsx_parse_jobs(稽核計畫與稽核結果),不是這兩張。盤點檔 6 節把 SSP 這兩張也標「RLS 規則寫壞」是誤植。INSERT 規則是 WITH CHECK (true)——任何身分都能新增任何公司編號的工作單。目前打不到,因為建立工作單時公司編號一律取自登入身分(ssp_docx_import_app_service.py:170 tenant_id=user_context.tenant_id),請求內容裡沒有這個欄位。update/deactivate(ssp_docx_parse_job_repo_impl.py:31-54)只用編號查、不帶公司條件,靠資料庫隔離擋;呼叫端(ssp_docx_import_app_service.py:256-300)讀、捨棄、確認三步都先核 job.tenant_id != user_context.tenant_id,外加專案參與者/管理員檢查。update 會跳過 tenant_id、created_user 等保護欄位(:12)。Excel 那支同構(多一個 metadata 欄位名對映),:253/:294/:320 同樣三步都核。不成立。notification_socketio_handler.py:連線時怎麼認身分?能不能訂閱別人的通知頻道?不認身分;能訂閱任何頻道;還能對任何頻道廣播——程式碼層面成立,我親手重現。但今天這個頻道上沒有任何東西,所以沒有實害(工具 C3 被面板 0:3 否決,否決理由我核對屬實)。評低(U12-1)。
程式碼:
:19 class NotificationSocketioHandler(Namespace)——繼承的是 flask-socketio 原生的 Namespace,不是 jedi_iam.middleware.socketio_auth.AuthenticatedNamespace。同伺服器上另一支 FillSurveySocketioHandler 繼承的是後者,它會在連線時驗登入憑證(authenticate_socket_connection,驗不過拒連)、每個事件前重新確認身分。:23-31 on_connect:印 log、回一句「Connected」。沒驗任何東西。:34-41 on_join:join_room(data.get('room')),房間名稱完全由呼叫端決定。:43-52 on_reload:emit('reload', data, room=data.get('room')),把呼叫端送的整包內容原樣廣播給該房間所有人。config/socketio_namespaces.py:41-45,路徑 /socket/notification;伺服器 core/app_factory.py:180-186 設定 cors_allowed_origins="*"(任何網站都能連)。落地版 docker/production/docker-compose.yml:314 由 nginx 把 /socket.io 代到 socketio 服務,與前端同網址、對外可達。誰在用——沒有人:前端唯一連這個頻道的是 ProcessDiscussBox.vue:37(流程討論串,加入房間 process-discussion:<流程編號>、收到 reload 就重抓留言),但這個元件整個 FE repo 沒有任何地方引用(面板指出、我 grep 確認)。伺服器端在有人留言時由 api/flow_engine/routes/flow_engine_route.py:115-121 廣播 {room, user_name, message}(留言者帳號與留言全文),但送的是 /socket/process-discussion,這個頻道沒註冊,也不是 /socket/notification。jedi 套件也沒有人往 /socket/notification 送東西。
所以今天的狀態:門完全沒鎖,但房間是空的——連得進來的只有其他匿名連線。會變成真問題的時機:有人把討論串元件接回畫面、並把伺服器那行 namespace 對齊成 /socket/notification(兩件都像是「修個即時更新」會順手做的事)。那一刻起,不用帳號就能偷聽任何知道編號的流程討論串。建議趁現在沒人用,把門先裝上。
實測(scratchpad sock_test.py,用 flask-socketio 的 test client 直接載入這支處理器):
attacker connected w/o token: True
victim received: [('reload', [{'room': 'process-discussion:SOME-OTHER-TENANT-WORKFLOW-ID', 'user_name': 'boss', 'message': 'FAKE'}])]
攻擊者不帶任何登入憑證就連上、加入房間、送出 reload,同房間的其他連線收到一則冒名「boss」的內容。這證明「不驗身分、房間任意進、內容原樣轉發」三件都成立;但如上所述,今天房間裡不會有真的使用者。
打折說明:用 test client 在單一行程內模擬,不是對真實落地版的 8002 連線;沒有接 Redis message queue。處理器程式碼就是那 53 行,沒有其他地方可以補身分檢查,所以結論不受影響。
修法:①:19 改繼承 AuthenticatedNamespace(一行,連線與事件身分就都有了);②on_join 加入前核對 process-discussion:<id> 這個流程是不是呼叫者看得到的(比照問卷那支的 _assert_participant);③on_reload 整支刪掉,前端沒有任何地方送 reload(我 grep 過 FE repo,唯一的 emit('reload') 是 Vue 元件事件,不是 socket);④順便把 flow_engine_route.py:121 的 namespace 對齊。
會有條件地發生:一般客戶身分被資料庫隔離擋住;平台管理員身分下會。寫進 SSP 的是「附註」,不是權限。
程式碼:
party_reconciliation_service.py:21-30 把 tenant_id 傳給人員與組織兩個比對器。organization_reconciler.py:26-27、:44、:62、:83)四個查詢都有 if tenant_id is not None: q.tenant_id = tenant_id。✅person_reconciler.py)四個查詢——人工指定(:26 用帳號名稱)、完全相同(:41 用 email)、正規化(:58)、模糊(:76 抓全部使用者當候選)——全部沒帶 tenant_id。參數收到了、沒用。實測(scratchpad recon_test.py,假的使用者服務記下收到的查詢條件):
person query filters: {'email': 'alice@vendor.com'} -> matched_user_id 777 exact
org query tenant filter: ('org', 5)
label query filters: {'login_name': 'alice_b'} -> matched 777 user_selected
替公司 5 做比對,人員查詢沒帶公司條件,直接配到公司 7 的使用者 777;組織查詢有帶公司 5。
資料庫隔離擋不擋(DEV 唯讀交易,cm_app 身分,2026-09-25 15:4x):
| 模擬身分 | 看得到的使用者 | 其中客戶 131 的人 |
|---|---|---|
客戶 102 的一般使用者(allowed_tenant_paths=/1/102/) |
38 | 0 |
平台管理員(is_super_admin=t) |
42 | 2 |
users 表的讀取規則是 is_super_admin OR app_tenant_allowed_for_session(tenant_id) OR 是自己。一般客戶看不到別家;平台管理員(路徑 /1/)的隔離是全開的(jedi_common/session/database/db.py:190-195 路徑是根就設 is_super_admin=t)。DEV 另有 1 組 email 同時存在於兩家客戶。
所以:平台管理員替客戶 102 匯入一份 SSP,文件上寫的 email 若在客戶 131 也有帳號,完全相同比對可能先拿到 131 那個人(資料庫回傳順序決定)。模糊比對更寬:同網域+名字相同就配。
配到了會怎樣:
parsed_result(ssp_docx_import_app_service.py:629、ssp_excel_import_app_service.py:885-889),預覽畫面會顯示那個人的暱稱與帳號(party_match_enrich.py)。matched-user-id(import_adapter/_common.py:121-123)。之後 SSP 人員清單(ssp_party_app_service.py:70-74)與 Excel 範本下載(ssp_import_template_app_service.py:1111)會用這個編號反查出名字與帳號。matched-user-id 做授權判斷。另一條相鄰的路(不在本棒範圍,順帶記):SSP 人員新增/編輯 API(ssp_party.py:21-22 的 matched_user_id/matched_org_unit_id)接受呼叫端直接給任意整數,ssp_party_app_service.py:98-110 原樣寫進附註,不核這個人是不是同公司。一般客戶身分之後反查會被資料庫擋住,只能寫進一個「看不到名字的編號」,影響同上但更小。
嚴重度 🟢 低(U12-4):需要平台管理員自己操作匯入、需要剛好有同 email 的別家帳號;後果是文件附註洩漏一個別家帳號的名字與 email 給客戶,而且只有平台管理員當下看得到名字。修法:person_reconciler.py 四處查詢比照組織比對加 tenant_id;SSP 人員 API 寫入 matched_user_id 前核對同公司。
| 型態 | 本棒有沒有 | 在哪 |
|---|---|---|
| 只驗「你登入了沒」、沒驗「資料是不是你的」 | ✅ 有 | F2 讀取入口只驗登入(=第 72 項);U12-1 即時通知連線連登入都沒驗(但目前沒東西經過) |
| 只檢查列表、沒檢查單筆 | ❌ 沒有 | 守門服務讀寫七條路徑都各自補了 |
| 權限寫在 route 裝飾器、有第二支路由沒掛 | ⚠️ 有類似的 | 修正線四條寫入入口全補,但主線 :131 讀取端仍只驗登入(=總表第 72 項) |
守門條件用 or 串起來其中一個永遠成立 |
❌ 沒有 | _platform_admin_only 兩個條件都綁群組名稱 |
| 例外處理把「查不到」與「沒權限」混成同一個回應 | ✅ 刻意的 | get_system_configs 對非平台管理員把受限列當作不存在,註解寫明理由(不讓泛用查詢整支失敗),合理 |
| 背景作業假設呼叫者是自己人 | ❌ 沒有 | seeder 用建立者身分寫,資料庫擋 |
| 註解寫「這裡刻意不檢查」但上一層沒真的檢查 | ⚠️ 有 | 套件 SystemConfigListRoute 註解「讀取只要登入即可;受 RLS 與宿主 service 兩層限制」——但宿主 service 只限制 AI/雲端硬碟兩組(=第 72 項) |
| 判準抓對了層級、沒抓對「是哪一家」 | ✅ 有,本棒新的型態 | U12-3 修正線總部守門 |
第一層:工具正式報告——驗證章 verified:研究員 2 派 2 回、3 候選去重後 3 條、9 票全投完、零漏投、零失敗。F1(高)與 F2(中)3:0 通過,兩條都是總表既有項(第 74、72 項),淨新增 0;C3 0:3 否決。密鑰掃描零發現。
第二層:runner 人工——本報告 U12-1~U12-6 全部是 runner 依卡片逐支開檔、實測或 DEV 唯讀查核追出來的,沒有經過三人面板投票:
pg_policies。DEV 查核時間:2026-09-25 15:30~15:45(本機 guidant_ai_dev,唯讀交易、ROLLBACK 結束,未寫入任何資料)。
| 項目 | 數值 |
|---|---|
| 範圍 | 16 檔/1,921 行(wc -l 實跑) |
| 工具指令 | /claude-security scan codebase ... --scope <16 檔> --effort low(focus attack-surface) |
| run ID | wf_a2018933-ffc |
| 掃描版本 | e1c9230334051bb91cc77a87e5a8b8ea404ea8c4(feature/review) |
| 驗證章 | verified(stamp CLAUDE-SECURITY-REVISION-e1c923033405-dirty.json,-dirty 是工作區有平行線未 commit 的改動,不在本棒範圍) |
| 候選數/票數 | 3 候選 → 去重 3 → 面板 9 票全投完(F1 3:0、F2 3:0、C3 0:3),零漏投 |
| 研究員 | 派 2(研究 1+密鑰掃描 1)回 2,零失敗、零重試 |
| 耗時 | 約 1 小時 33 分(研究約 1 小時 22 分、面板約 11 分) |
| 子代理 token | 約 113 萬 |