範圍:8 檔/934 行(
core/plugins/survey.py、infra/survey/adapters.py、問卷三支 DI 容器檔、di_containers/dashboard_apis/survey.py、config/socketio_namespaces.py、接縫檔core/plugins/_host.py)。 掃描工具:Claude Code 官方claude-securityplugin,effort low,focus 生產程式碼。 掃描基準 commit:aa23a63554ce(工作區有其他 session 的未提交改動)。 驗證章:verified(2 條候選,面板 6 票全數投出,1 條成立、1 條否決)。只掃不修。 本棒與 W2(CM-2091)由同一個 runner 連續跑,W2 報告會回頭引用本棒第 6 節的「守門零件清單」。
宿主遞給問卷套件的守門零件,本身都是對的;問題出在「遞的時候有兩份、只改了一份」。 目前工作分支(feature/review)上,套件已經開始要求一個新零件「專案參與者檢查」,但宿主這邊兩處專案角色守門、和一處 DI 容器都還沒補上——修正版都在 fix/security-b1 分支上寫好了,兩個分支合起來才對(見 W1-2)。
工具這一棒找到一條低風險的真問題:多人同時填問卷時,「現在誰在線上」那份名單是照前端送來的名字寫的,不是照登入身分寫的,所以一個參與者可以把別人踢出名單、冒用別人的名字「佔住」某一題(W1-1)。
另外有一件事卡片沒問、但本棒讀到了:問卷的「填答」那一半網址,沒有一支掛上商務授權檢查;「設計問卷」那一半 33 支裡 32 支有掛(唯一沒掛的是不需登入的範例檔下載)。沒買問卷模組的客戶,只要知道網址,填答相關功能照樣能用(W1-3)。
卡片點名的其餘幾點都查過了:專案管理者守門是直接委派主專案的正牌實作、即時共編連線時有驗登入、AI 儀表板兩支查詢在修正分支上已補能力點檢查。逐條理由在第 5 節。
問卷功能(設計問卷、派給任務填答、多人同時填)寫在 jedi-survey 套件裡,FR-109 兩棒已經掃完。這一棒看的是主專案把套件接上產品的那一層:套件自己不知道「誰登入了」「這家客戶買了什麼」「這個人在專案裡是什麼角色」,這些都由主專案遞給它。
要問的是:主專案遞過去的守門零件對不對、齊不齊;套件以為主專案會守的地方,主專案真的有守嗎。
問卷套件有三條被外面碰到的路:
| 路 | 誰會走 | 守門零件由誰給 |
|---|---|---|
| 網頁 API(設計問卷+填答兩組網址) | 使用者在畫面上操作 | core/plugins/survey.py 的 build_adapters() |
即時共編(/socket/fill-survey) |
多人同時打開同一份問卷填答 | 連線驗身分:core/app_factory.py 接的 jedi-iam;房間權限:套件自己 |
| AI 儀表板(使用者用一句話要資料,AI 挑一支查詢來叫) | 使用者在 AI 儀表板打字 | di_containers/dashboard_apis/survey.py 的申報 |
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才會發生 | 在哪裡 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| W1-1 | 多人填問卷的「誰在線上」名單,照前端送來的名字寫 | 能把填答人從名單踢掉、用同事的名字顯示「正在編輯第 5 題」讓那題在別人畫面上被鎖住;也能反覆塞超長字串把快取吃大 | ① 登入 ② 是該任務所屬專案的參與者(唯讀的檢視者也行) ③ 知道那份問卷的編號 | 名單寫入點 infra/survey/adapters.py:36(join)、:39(leave);名字來源是套件的即時共編處理器(見 4.1) |
低 | 工具,3:0 成立 |
| W1-2 | 套件要一個新零件「專案參與者檢查」,目前分支上宿主兩處守門、一處容器都沒給 | 在目前分支上:問卷讀取、即時共編房間檢查一呼叫就出錯(不是放行,是整組壞掉);在修正分支上已補齊 | 兩個分支沒有一起合併就上線 | infra/survey/adapters.py:43(ProjectRoleGuardAdapter 類別起點)core/plugins/participant.py:90(_ProjectRoleGuardAdapter 類別起點)di_containers/task_survey/question_answer_containers.py:90(question_answer_history_detail_service)di_containers/survey/survey_containers.py:116(survey_discussion_service) |
不是漏洞:失敗方向是「壞掉」不是「放行」;但合併順序錯就是整組問卷讀取壞 | runner 開檔,未經投票 |
| W1-3 | 問卷「填答」那一半 14 支網址,一支都沒掛商務授權檢查 | 沒買問卷模組的客戶,照樣能叫出任務問卷清單、讀寫答案、看歷史版本、匯入答案 | ① 登入 ② 通過該筆問卷本身的歸屬檢查(修正分支已補) ③ 客戶沒買問卷模組 | 套件 jedi_survey/api/routes/task_survey_route.py:29、:40、:48、:63、:131、:171;question_answer_route.py:28、:37、:55、:84;question_answer_history_route.py:22、:32、:43;question_answer_history_detail_route.py:18(每支方法起點,@require_license("survey") 該掛在這些方法上) |
低:不是資料外洩(歸屬檢查仍在),是商務授權被繞過 | runner 開檔,未經投票 |
白話說明
問卷有一個「多人同時填」的功能:幾個人打開同一份問卷時,畫面上會顯示「誰在線上」,某人點進一題開始打字時,別人畫面上那一題會被鎖住、顯示「某某正在編輯」。
這份線上名單存在快取(Redis)裡,由主專案的 RedisPresenceStoreAdapter 負責寫。問題是:要寫進去的名字,是前端在訊息裡自己填的(data.get('user')),不是伺服器從登入身分取的。伺服器其實早就知道你是誰——同一支處理器裡「更新答案」那個動作已經改成用登入身分署名(CM-2060 修的總表第 85 項),但「加入」「離開」「開始編輯」「結束編輯」這四個動作沒有跟著改。
攻擊情境
一位只有檢視權限的專案成員,打開某份任務問卷後,自己送一則「離開」訊息、名字填負責填答的同事——同事就從大家的線上名單裡消失。接著送「開始編輯」、名字填另一位同事、題目填第 5 題——其他人的畫面顯示「某某正在編輯第 5 題」並把那題鎖住。他也可以反覆送「加入」、每次帶一段很長的新名字,名單沒有過期時間也沒有上限,會一直長下去(而這台 Redis 同時也在跑即時訊息的佇列)。
不會發生的事:答案本身不會被改到、署名不會被偽造——寫答案那條路用的是登入身分,而且有「必須是填答人或專案管理者」的檢查。
為什麼是低不是中:要先是那個專案的成員才進得了房間;造成的是畫面上的誤導與干擾,資料不受影響。
在哪裡
| 位置 | 是什麼 |
|---|---|
infra/survey/adapters.py:36 |
RedisPresenceStoreAdapter.join:把名字 lpush 進名單,無過期、無上限 |
infra/survey/adapters.py:39 |
RedisPresenceStoreAdapter.leave:把名字從名單移除 |
套件 jedi_survey/app/handler/fill_survey_socketio_handler.py:76(on_join)、:124(on_leave)、:207(on_edit)、:229(on_endedit) |
四支事件處理器的起點:名字取自 data.get('user'),該改成取登入身分的位置 |
同檔 :164(on_update) |
對照組:這支已改成 get_user_context().login_name,是正確寫法 |
怎麼修
get_user_context().login_name,忽略前端送來的 user(照 on_update 的寫法,一行一支)。infra/survey/adapters.py):join 時給名單鍵設過期時間,並限制名單長度。runner 核對註記:三個獨立檢查員從「打不打得到」「出事多嚴重」「有沒有其他機制擋」三個角度投票,三票全部認定成立、三票都評低。我另外開套件原始碼核對:本機環境載入的是修正分支上的套件(jedi-wt-fix-security worktree),on_join 在第 82 行、on_leave 在第 130 行取 data.get('user'),屬實;已發版的 jedi-survey 1.1.2 這幾支更早(連房間檢查都還沒有,那是總表第 51 項),修正分支上的房間檢查也不會蓋掉這條。
跟總表的關係:總表第 85 項(M05-9)是「更新答案時署名可偽造」,CM-2060 只修了 on_update 那一支。這一條是同一個病的另外四支——典型的「守了一半」。建議併進同一張修正卡的延伸。
白話說明
問卷套件判斷「你能不能看這份任務問卷」時,會去問主專案:「這個人是不是這個專案的參與者?」這個「問」的動作走的是一個叫 IProjectRoleGuard 的接頭,主專案要實作兩個方法:assert_manager(是不是管理者)和 assert_participant(是不是參與者)。後者是 FR-114.2-4 新加的。
主專案有兩處實作這個接頭:core/plugins/participant.py 的 _ProjectRoleGuardAdapter,和本棒範圍內的 infra/survey/adapters.py 的 ProjectRoleGuardAdapter。它們寫進同一個全域位置,後掛載的會蓋掉先掛載的——而問卷排在人員指派之後,所以實際生效的是問卷那一份。
在目前工作分支 feature/review 上:
assert_manager、沒有 assert_participant。question_answer_history_detail_service(歷史版本明細)和 survey_discussion_service(問卷討論串)兩支 service,套件已改成需要 task_survey_domain_service、task_assignee_domain_service、participant_role_service 才能做歸屬檢查,宿主的 DI 容器還沒注入。修正分支 fix/security-b1 上(commit 05870c4cc、CM-2036;以及 CM-2034 的容器補丁),四處都已補齊,兩處 adapter 都委派主專案的 common.authz.project.assert_project_participant。
為什麼不是漏洞:少了這個方法,套件一呼叫就會拋「沒有這個方法」的錯誤,使用者看到的是「讀不到」而不是「看到別人的」。少了容器注入,套件那端的守門會因為拿不到反查資料而一律擋下(assert_task_survey_reader 查不到專案就拋 403)。兩者都是往「壞掉」的方向失敗,不是往「放行」。
為什麼還是要列:本機環境現在載入的是修正分支的套件+目前分支的主專案程式碼——這個組合正是會壞的那一種。上線時如果套件先發版、主專案的 CM-2036 還沒合,或反過來,問卷的讀取與即時共編會整組壞掉。
在哪裡(該補的位置)
| 位置 | 目前分支缺什麼 | 修正分支 |
|---|---|---|
infra/survey/adapters.py:43(ProjectRoleGuardAdapter 類別) |
缺 assert_participant 方法 |
✅ 已補(第 58 行) |
core/plugins/participant.py:90(_ProjectRoleGuardAdapter 類別) |
缺 assert_participant 方法 |
✅ 已補(第 104 行) |
di_containers/task_survey/question_answer_containers.py:90(question_answer_history_detail_service) |
只注入一個參數,缺四個 | ✅ 已補 |
di_containers/survey/survey_containers.py:116(survey_discussion_service) |
缺三個參數 | ✅ 已補 |
怎麼處理:不需要另開修正卡,只要確認套件發版與主專案 fix/security-b1 合併是同一批。另外建議:兩處 adapter 實作同一個接頭、而且互相覆蓋,這本身就是「同一件事兩份」的形狀,下次加第三個方法時一樣要記得改兩處。可以考慮讓 core/plugins/survey.py 直接用人員指派那一份,刪掉問卷自己那份(要動 infra/survey/adapters.py 被別層 import 的例外,見該檔開頭)。
白話說明
這個產品是按模組賣的,「問卷」是加購包。主專案給套件一個 license_guard(require_license("survey")),套件應該把它掛在每一支問卷網址上:沒買問卷的客戶按下去就回「未授權」。
我逐支數過套件的網址檔:
| 網址檔 | 方法數 | 掛了商務授權 | 掛了能力點 |
|---|---|---|---|
survey_route.py(設計問卷) |
13 | 12 | 8 |
survey_folder_route.py(問卷資料夾) |
6 | 6 | 3 |
survey_page_route.py、survey_question_route.py(頁、題目) |
10 | 10 | 10 |
survey_discussion_route.py(討論串) |
4 | 4 | 2 |
task_survey_route.py(任務問卷) |
6 | 0 | 0 |
question_answer_route.py(填答) |
4 | 0 | 0 |
question_answer_history_route.py(填答歷史) |
3 | 0 | 0 |
question_answer_history_detail_route.py(歷史明細) |
1 | 0 | 0 |
survey_route.py 唯一沒掛的那一支是「下載問卷上傳範例檔」,走的是簽章網址,不需要登入,合理。
設計層 33 支掛了 32 支、作答層 14 支全沒掛——這不是漏一支,是整個作答層都沒有。能力點沒掛是刻意的(作答層用「是不是這個任務的填答人或專案成員」判斷,不看角色能力點),但商務授權沒有理由不掛:問卷模組沒買,就不該能填問卷。
會怎樣:沒買問卷模組的客戶,前端選單不會出現問卷(FR-062 T-8.1 會把選單反灰),但直接打網址照樣能用:列出任務問卷、讀寫答案、看歷史版本、匯入答案。不會看到別人的資料——每一支都還有「這筆問卷歸屬」的檢查(修正分支已補),所以這是「商務授權被繞過」,不是資料外洩。
為什麼是低:客戶要自己知道網址、而且裡面要本來就有問卷資料(沒買問卷模組的客戶通常不會有任務問卷可以填)。
宿主側有沒有擋:沒有。主專案的全域「唯讀閘門」(common/middleware/license_readonly_mw.py)只管「照過期變唯讀時擋寫入」,不管「有沒有買這個模組」。
在哪裡:套件四支網址檔、共 14 支方法的起點(行號見總覽表)。修法是每支方法加 @require_license("survey"),放在 @jwt_required() 之下(和設計層同一個寫法)。
runner 核對註記:這條工具沒報,是我數網址檔時看出來的。FR-109 兩棒只查了「讀取有沒有歸屬檢查」,沒有逐支數商務授權。套件總表 M05 也沒有這一條。需要首腦判斷是否登記為新項次(見第 8 節)。
① ProjectRoleGuardAdapter.assert_manager 是直接委派還是另寫一套?group_id/control_id 有沒有用到? 直接委派,沒有另寫。 infra/survey/adapters.py:51-56 原封不動把四個參數轉給 common.authz.project.assert_project_manager,那支是主專案的正牌實作(由下往上查控制項→群組→專案三層角色)。group_id/control_id 在接頭上有接、有轉,但問卷套件自己呼叫時從來不傳(task_survey_guard.py:76、:84 只帶專案編號)——所以實際只判專案層。這是正確的:問卷掛在任務上、任務屬於專案,不屬於某個控制項,沒有更細的層級可以判。不成立。 唯一要注意的是 W1-2 講的「少了一個方法」。
② 宿主給套件的 capability_required、license_guard 有沒有給對? 給對了。 core/plugins/survey.py:97-113 兩支都是傳「工廠函式本身」,對應 common.authz.require_license(resource_type) 與 require_capability(*names),簽名與套件 api/guards.py:82-122 的呼叫方式吻合(我在本機實際 import 核對過簽名)。套件在掛載前會檢查四樣守門零件(auth_required、license_guard、capability_required、project_role_guard)是否齊全,缺一就拒絕掛載(plugin/assembly.py:19-33),宿主四樣都給了。宿主這側不成立;套件拿到之後有一半沒套用,見 W1-3。
③ 即時共編 socket 連線時有沒有驗登入? 有。 問卷的即時共編處理器繼承 jedi-iam 的 AuthenticatedNamespace,連線時用與網頁同一把 JWT 驗簽、驗期、查登出黑名單、查使用者、查租戶隸屬,任一不過就拒絕連線。宿主在 core/app_factory.py:334-336 接上了這兩個查詢零件;沒接的話是拒連,不是放行(jedi-iam socketio_auth.py:138)。已發版的 jedi-survey 1.1.2 也已經繼承這個類別。不成立。
順帶一提:config/socketio_namespaces.py:43-46 登記的另一條 socket /socket/notification(流程討論區的即時重新整理)是完全不驗登入的普通 Namespace。工具把它列為候選,三個檢查員一致否決:這條只轉發「請重新整理」的訊號、不帶任何資料,前端收到後會自己再打一支有登入檢查的網址去拿內容。所以無法讀到任何東西,最多讓別人的討論區多重新整理一次。記錄在此,下次不用重查。
④ AI 儀表板兩支問卷查詢,空條件時回什麼?
feature/review:兩支申報都沒有寫「需要什麼能力點」,而目前安裝的 jedi-ai-dashboard 會對「沒寫」的查詢一律拒絕(fail-closed)——所以這兩支在目前分支上根本叫不動。已發版的 jedi-ai-dashboard 1.2.0 沒有這層檢查,任何登入者都能叫,空條件時 get_surveys 回整個客戶所有未刪除的問卷(含流程自動產生的快照問卷,網頁那支會排除、這支不會)、get_folders 回全部資料夾(系統資料夾會排除)。這就是總表第 60 項(M15-2)。fix/security-b1(commit 8f94e249d、CM-2038):兩支都補上 required_capabilities=("survey.read",),並接上 viewer_has_capability 判定。修好後只有持有問卷讀取能力點的人叫得動。survey.read 能力點,所以 AI 儀表板這條修好後反而比網頁嚴格,不是比較鬆。快照問卷會多列出來這點,只是多看到自己客戶的內部副本,不是跨客戶。ai-dashboard 模組,不是 survey。所以買了 AI 儀表板、沒買問卷的客戶,還是能透過 AI 儀表板查到問卷清單。跟 W1-3 同一類問題(商務授權被繞過),併進 W1-3 討論。⑤ _host.py 的 host_defaults() 給出去哪幾樣? 四樣:auth_required(jwt_required())、capability_required(require_capability)、current_user_login_name、response_builder。問卷接線沒有用它,而是自己逐項填(core/plugins/survey.py:128-141),填的內容與 host_defaults() 的前兩樣完全相同,外加 license_guard 與 project_role_guard(host_defaults() 沒有這兩樣)。只有資產、公告、議題三支用 **host_defaults() 整包吃。W6 需要知道的事:host_defaults() 不含商務授權與專案角色守門,所以「吃整包就等於守門給齊」這個假設不成立,每支要自己補。
W2 要回答「W1 看到的守門零件,在 W2 那條路上有沒有被用到」,所以把本棒確認過的零件列成一張表:
| 零件 | 宿主在哪裡給 | 實作 | 用在哪 |
|---|---|---|---|
登入檢查 auth_required |
core/plugins/survey.py:130 |
jwt_required() |
套件每支網址 |
商務授權 license_guard |
core/plugins/survey.py:97、:131 |
common.authz.require_license |
套件設計層 32 支(作答層 0 支,W1-3) |
能力點 capability_required |
core/plugins/survey.py:104、:132 |
common.authz.require_capability |
套件設計層寫入類 23 支 |
專案角色守門 project_role_guard |
core/plugins/survey.py:133(實作在 infra/survey/adapters.py:43) |
委派 common.authz.project.assert_project_manager(修正分支加 assert_project_participant) |
套件 task_survey_guard.py 的三支守門:填答者、讀者、管理者 |
任務編號換算 task_uid_resolver |
core/plugins/survey.py:139(實作 infra/survey/adapters.py:65) |
查流程引擎的 workflow_executions/job_executions |
套件用任務編號查問卷時(反查不到回空,不退化成不過濾) |
| 填答歸屬反查 | di_containers/task_survey/task_survey_containers.py:52、question_answer_containers.py:77、:87 注入 task_assignee_domain_service + participant_role_service |
查 task_assignees 找出任務屬於哪個專案 |
套件守門判斷「你是不是填答人/專案成員」 |
| 即時共編連線身分 | core/app_factory.py:336 |
jedi-iam AuthenticatedNamespace |
/socket/fill-survey 每個事件 |
要特別留意的一件事:TaskUidResolverAdapter 只負責「換算」,不做任何權限判斷。它能把任何存在的任務編號換成內部編號,不問你是誰。這沒問題——權限判斷在套件的 task_survey_guard.py,靠的是 task_assignees 表反查專案。但這代表:誰能在 task_assignees 裡寫一列,誰就能決定一份問卷屬於哪個專案。 問卷怎麼被掛上任務、那一列怎麼來的,是 W2 要查的。
「這幾條存在嗎」——W1-1 可信度高;W1-2、W1-3 是開檔看到的事實。
git show 逐檔比對過,行號在修正分支上都找得到。沒有實際啟動程式驗證「會 AttributeError」,是依程式碼推斷。@require_license 有沒有掛是機械可查的事實。「沒買問卷的客戶真的打得到」是依程式碼推斷,沒有實際用一個沒買問卷的測試租戶去打。「只有這幾條嗎」——不保證。
jedi-wt-fix-security,editable 安裝),工具與我讀到的套件程式碼都是修正版,不是已發版的 1.1.2。凡是提到「已發版」的都是我另外用 git show 對照的。| 項目 | 數值 |
|---|---|
| 掃描範圍 | 8 檔 / 934 行 |
| 基準 commit | aa23a63554ce(工作區 dirty,有平行 session 改動) |
| 檔位 | effort low,focus 生產程式碼(含密鑰專項) |
| 研究員 | 派 2 支,回 2 支 |
| 原始候選 | 2 條,去重後 2 條 |
| 投票 | 三個檢查員 × 2 條 = 6 票,全數投出 |
| 票型 | W1-1(工具編號 F1)3:0 成立,三票評低;/socket/notification 未驗登入(F2)0:3 否決 |
| 驗證章 | verified(CLAUDE-SECURITY-REVISION-aa23a63554ce-dirty.json) |
| 工具 run ID | wf_0a88cfa7-e8f |
| 耗時 | 約 52 分鐘(8 個 agent,零失敗) |
| 工具產出原始報告 | CLAUDE-SECURITY-20260923-130757/(未入版控) |
| 密鑰專項 | 無新發現 |
on_join、on_leave、on_edit、on_endedit 四支逐一列成驗收項,外加宿主 infra/survey/adapters.py 名單加過期與上限。ai-dashboard 不驗 survey,是一整組;總表目前沒有)。修法是套件側加 decorator,不動主專案。fix/security-b1 的 CM-2036、CM-2034 容器補丁同一批上。要不要在修正分支的合併檢查清單上寫明。IProjectRoleGuard 實作互相覆蓋(core/plugins/participant.py 與 infra/survey/adapters.py)要不要收成一份。目前靠「兩邊都記得改」維持一致,CM-2036 已經踩過一次。沿革見 FR-115 LOG 與 git log。