Guidant AI 資安檢視總報告 · 連線清單

C03 前門 → socketio(guidant-fe → guidant-socketio,HTTP :8002)

即時功能走的那條線——目前只有一個用途:多人同時填同一份問卷時,誰在編輯哪一題、改了什麼,即時同步給房間裡的其他人。它與業務 API(C02)是兩個不同的行程、兩套不同的守門,api 入口那道攔截它一概不經過。這一頁回答:這條線怎麼連、哪些功能走它、它帶來什麼威脅、駭客怎麼打、我們要怎麼防、目前做到哪。

必有 驗 JWT・自成一套守門 威脅 4 種 掃描命中 6 條

這一頁怎麼來的:DFD Level 0 把產品運作時的連線編成 C01~C25;STRIDE 六頁每一條問題都標了發生在哪幾條線。這裡以連線為單位,把這條線的特性、掃描在這條線上抓到的攻擊手法、以及由此該有的防線整理成一頁。個別問題修了沒不在這頁講,請點條目連回 STRIDE 頁。

§1

一、連線圖

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB'}}}%%
flowchart LR
    U(["👤 已登入使用者<br/>(正在填問卷)"])
    FE["guidant-fe<br/>nginx 前門"]
    subgraph SIO["guidant-socketio :8002(獨立行程,不載業務 API)"]
        direction TB
        CONN["連線交握:驗 JWT<br/>(你是誰、哪個租戶)"]
        subgraph NS["唯一頻道 /socket/fill-survey"]
            direction TB
            EV["六個事件:join・leave・editing<br/>update・edit・endedit<br/>每個都帶房號=問卷編號"]
            GUARD["每個事件自己查:<br/>你是這份問卷所屬專案的人嗎?"]
            EV --> GUARD
        end
        CONN --> NS
    end
    subgraph SKIP["api 入口有、這條線沒有的攔截"]
        direction TB
        S1["請求日誌・遮密碼"]
        S2["授權到期唯讀擋寫<br/>(HTTP 中介層)"]
    end
    DB[("guidant-db<br/>填答寫入")]
    RD[("guidant-redis<br/>訊息佇列・線上名單")]
    U == "C01" ==> FE
    FE == "C03 · /socket.io · 先長輪詢再升級 WebSocket<br/>JWT 放在交握 payload" ==> SIO
    GUARD -- "C05" --> DB
    SIO -- "C06" --> RD
    SIO -. "不經過" .- SKIP
    classDef ext fill:#FBFCFC,stroke:#9AA8AA,stroke-dasharray:3 2
    classDef skip fill:#FDECEC,stroke:#C0392B
    class U ext
    class S1,S2 skip
C03 — 前門到 socketio。瀏覽器以 /socket.io 路徑經前門連到 socketio 行程;socketio 不載任何業務 API、自己驗 JWT 入房。目前只有一個頻道(問卷共編),每個事件都帶「房號=問卷編號」,由頻道自己查呼叫者是不是該問卷所屬專案的人。api 入口的攔截(記日誌、授權唯讀擋寫)這條線一概不經過,是這條線的本質風險。
§2

二、這條線怎麼連

項目 現況
誰 → 誰 guidant-fe(nginx)→ guidant-socketio(Flask-SocketIO,eventlet)。走 docker 內部網路,:8002 不對外開(compose 只 expose),只有前門連得到。它與 guidant-api 是同一顆 image、兩個不同容器:api 容器以 enable_socketio=False 起、不提供即時服務;socketio 容器以 RUN_MODE=socketio 起、不載任何業務 API 路由(只留一支健康檢查)
協定/埠 HTTP :8002。瀏覽器以 /socket.io 路徑連進來,先用長輪詢交握、再升級成 WebSocket;前門為此段設了 Upgrade 標頭與 3600 秒讀寫逾時(一般 API 是 600 秒)。頻道名稱(/socket/fill-survey)走在 socket.io 協定內層,不出現在 HTTP 路徑上
傳什麼 只有問卷共編:誰進房、誰離房、誰正在改哪一題(鎖定/解鎖)、單題答案更新與廣播;進房時若房裡已有別人,伺服器回一份目前答案快照。訊息經 Redis 佇列在多個 socketio 行程間轉發(C06);線上名單也存 Redis
怎麼驗身分 連線交握時驗 JWT:前端把登入權杖放在交握的 auth 欄位(io(url, { auth: { token, tenant_id } }));伺服器用與 HTTP 同一把金鑰驗簽、驗期、查已登出黑名單、查使用者、驗作用租戶,驗不過直接拒連。也接受網址參數 ?token= 與 Authorization 標頭(相容舊呼叫端,前端目前沒用)。身分存在伺服器端的 socket session,之後每個事件重新注入(eventlet 每個事件是獨立執行緒,不注入守門就拿不到「你是誰」)。沒接好驗證的後果是「連不上」而不是「無身分放行」(fail-closed)
事件層守門 六個事件每一個都先以房號反查問卷、再查呼叫者是不是該問卷所屬專案的參與者(_assert_room_member);寫答案那一步(update)再多一層「可寫」檢查。署名一律取伺服器端登入身分,不信畫面送的 user 欄位
api 入口有、這裡沒有的 ① HTTP 中介層的請求日誌與遮密碼——socket 事件不經過;② 授權到期唯讀擋寫是 HTTP 中介層,socket 事件不經過,頻道自己向宿主問 readonly_check 才擋得到;③ 速率與大小限制——socketio 沒設 max_http_buffer_size,事件內容大小只靠下游寫入邏輯把關
跨來源 socketio 的 cors_allowed_origins="*"(任何網站的頁面都能發起連線;但沒有有效 JWT 連不上)。HTTP 側 CORS 有白名單設定 CORS_ALLOWED_ORIGINS,這裡沒跟
紀錄 逐封包紀錄(logger/engineio_logger)只在 DEBUG 模式開;正式環境不記。連線成功會記一行「誰、哪個頻道」
何時存在 必有(stack 一起就起)。但實際只有問卷共編在用;流程討論推播頻道已於 2026-10-01 拔除

依據:FE nginx.onprem.conf /socket.io 段、docker/production/docker-compose.yml guidant-socketio 段、main.py RUN_MODE 表、core/app_factory.py socketio.init_app() 與 configure_socket_identity()、config/socketio_namespaces.py(目前只有一列)、套件 jedi-iam middleware/socketio_auth.py、套件 jedi-survey app/handler/fill_survey_socketio_handler.py、FE useSurveySocket.js。

§3

三、哪些功能會走這條線

只有一個:問卷多人同時填答(survey-v2 的填答頁)。對威脅分析,依「這條線跟 api 那條線的差別」分三群看:

群 功能 對威脅的意義
① 進房與旁聽 加入/離開房間、看誰在線、進房拿目前答案快照、看誰正在改哪題 房號就是問卷編號,可猜可撞。進房守門沒守好=任何登入者都能旁聽別專案正在填的問卷
② 寫入 單題答案更新(寫資料庫+廣播給房間) 這是這條線唯一的寫入點,不經 api 入口的任何中介層——授權到期、請求日誌、遮密碼都要頻道自己補;漏一項就是一個與 HTTP 那邊不對稱的縫
③ 已拔除、但特性仍在 流程討論推播頻道(/socket/notification,2026-10-01 拔) 它當時不驗身分、不查房間歸屬。拔掉解了那一條,但「新增一個頻道時忘了繼承驗身分的基底」這條路還在——下次有人加頻道,同樣的洞會再長出來
§4

四、這條線會帶來什麼威脅、駭客怎麼打

掃描在這條線上抓到 6 條問題(M05-7、M24-9、M06-2、M05-9、M18-13、M24-5),歸納起來是四種攻擊手法。每種寫駭客實際的步驟與得手什麼,條目是實例。

# 風險 威脅 駭客怎麼打 得手什麼 STRIDE 實例
T1 🟠 高 頻道不查房間歸屬,猜一個房號就進去旁聽或廣播 ① 用任一帳號(或某些頻道連帳號都不用)連上即時頻道;② 房號就是問卷編號或流程編號,從別處撈一個、或猜;③ 送「加入」事件,頻道不查你是不是那個專案的人,直接進房;④ 旁聽別人正在填的問卷、觸發快照拿到還沒存檔的答案;或往房間廣播一則假訊息,看起來像同事發的 別專案填寫中的問卷內容;對該專案全員發假訊息的管道 I 資料外洩、S 冒充身分 M05-7 多人同時填問卷的「房間」想進哪間就進哪間 🟡
M24-9 即時通知有一個頻道不驗身分,任何人都能加入任意房間 ⚪
M06-2 流程留言的讀與寫都只檢查有沒有登入 🟠
T2 🟡 中 署名由畫面送上來,紀錄記成別人改的 ① 合法填答者打開問卷、進入即時填答;② 改一題時,把送出資料裡「使用者」那一欄改成同事的帳號;③ 伺服器照收,這筆修改記成同事改的,廣播出去其他人看到的也是同事的名字;④ 事後追「誰改了這題」,指向沒做的人 把自己的修改署成別人、填答歷程不可信 R 事後無法追查 M05-9 多人同時填問卷時,署名採用畫面送上來的名字 🟡
T3 ⚪ 低 api 那邊擋了、這邊沒擋:繞過 HTTP 中介層的攔截 ① 客戶授權過期,產品轉唯讀,網頁上所有寫入都被 HTTP 中介層擋下;② 填問卷的人照常打開問卷,答案更新走的是即時頻道而不是 HTTP;③ 中介層根本看不到這個事件,答案照樣寫進資料庫 唯讀期間仍能寫入問卷答案(商務授權形同虛設) E 權限提升 M18-13 過期唯讀的兩個縫:代理程式網址整段放行、問卷即時填答不經檢查 ⚪
T4 🟡 中 逐封包紀錄把交握裡的登入權杖寫進容器紀錄,再隨診斷包出門 ① 不用打——使用者正常連線,交握封包(含登入權杖)被逐筆寫進容器紀錄;② 客戶遇到問題,管理員產診斷包寄給原廠;③ 經手診斷包的任何人搜尋一下就拿到權杖;④ 權杖還在效期內,直接冒用登入 使用者的登入權杖 I 資料外洩 M24-5 即時通訊逐筆封包紀錄夾帶通行證進診斷包 🟡

四種手法的共同點:T1~T3 都是同一件事——這條線與 api 那條線是兩套獨立的守門,api 入口的檢查(成員資格、唯讀、日誌)這裡一樣都沒有,要頻道自己逐項重做;做了一半就是一個 HTTP 擋得住、socket 擋不住的縫。T4 是另一件事——權杖不是放在標頭、是放在交握內容裡,任何「把內容記下來」的機制都會順手把它記進去。這兩個特性是這條線的本質,不是某支程式的 bug——頻道只剩一個,但下一個頻道加進來時,六項檢查有沒有都繼承到,沒有機制保證。

§5

五、我們要怎麼防、目前做到哪

對應四種威脅與這條線的本質,防線分六條。D1~D4 直接對應掃描抓到的四種手法;標 ◇ 的(D5、D6)是依這條線的特性補的標準防線,掃描範圍沒涵蓋、沒有實例。「目前」欄寫程式與部署裡實際有的機制;⚠️ 表示目前只靠慣例或只守到局部,沒有機制保證。

# 防線 擋哪種威脅 目前做到哪
D1 連線先驗身分,每個事件再驗房間歸屬。頻道一律繼承「驗過身分的基底」(連線交握驗 JWT、每個事件重注入身分、例外只回原發送者);每個帶房號的事件都反查資源、確認呼叫者是該專案的人——不能只守「加入」,其他事件不必先加入就能指定房號 T1 ✅ 唯一頻道已繼承 AuthenticatedNamespace(套件 jedi-iam),六個事件全部呼叫 _assert_room_member,歸屬判斷走宿主注入的統一函式(assert_task_survey_reader)。✅ 不驗身分的推播頻道已拔除。⚠️ 「新頻道必須繼承基底」只有註解寫著,config/socketio_namespaces.py 不檢查登記的 handler 是不是 AuthenticatedNamespace 子類;build 時的 socketio_probe.py 只對 fill-survey 做「無 token 必拒」的負向驗證,新頻道不會自動被測到
D2 署名與租戶只取伺服器端身分,畫面送來的 user、tenant_id 只能當顯示用途或要經過隸屬檢查 T2 ✅ on_update 署名改取 get_user_context().login_name;交握給的 tenant_id 經 resolve_identity 驗隸屬、不隸屬拒連(0 這類假值也不會被當「沒帶」放過)。⚠️ join/leave/edit/endedit 四個事件廣播給房間的 user 欄位仍取畫面送的值(只影響「誰在線上」「誰在改哪題」的顯示,不寫資料庫),可以冒名顯示
D3 HTTP 中介層擋的事,socket 這邊要有對應的擋法。授權到期唯讀、租戶隔離、請求上限——每一項 HTTP 有的,列表核對 socket 有沒有 T3 ✅ 唯讀:頻道向宿主問 readonly_check,update 唯讀時不寫、回同一個錯誤碼(LICENSE_403002)。✅ 租戶:連線時寫進身分,RLS 照常生效。⚠️ 沒有這份「HTTP 有、socket 要對應」的核對清單——唯讀這一項是掃描抓到才補的;請求日誌(HTTP 每個請求都記,socket 事件不記進操作日誌)、事件內容大小上限(socketio 沒設 max_http_buffer_size,一個 update 事件可以帶任意大的答案內容)兩項目前沒有對應
D4 權杖不進紀錄。逐封包紀錄正式環境關閉;交握裡的權杖不得出現在任何紀錄與診斷包;收紀錄的地方一律過遮罩 T4 ✅ logger/engineio_logger 綁 DEBUG,正式關閉;診斷包打包前統一過遮罩(DiagPacker.pack())。⚠️ 權杖也接受網址參數 ?token=——前端沒用,但只要有人用,那一行會進前門 nginx 的存取紀錄(nginx 設定沒關 access log);相容用的那條路沒有必要留著
D5 ◇ 連線來源限制:socketio 的跨來源白名單與 HTTP 側對齊,只接受本站來源的連線 任何網站的頁面都能發起連線(配合偷到的權杖,或誘導使用者在惡意頁面上帶權杖連線) ⚠️ cors_allowed_origins="*"。HTTP 側已有 CORS_ALLOWED_ORIGINS 白名單,socketio 沒跟;兩者在同一個 create_app() 裡,對齊是一行的事
D6 ◇ 連線數與事件速率上限:每個使用者同時連線數、每秒事件數有上限,不讓單一帳號用大量連線或高頻 update 把 socketio 行程或資料庫寫入占滿 這條線的停擺風險——長連線、每事件一次資料庫寫入 ⚠️ 沒有。前門對 /socket.io 只設逾時(3600 秒),沒有連線數限制;socketio 側沒有速率限制;update 每次都寫一筆資料庫

最便宜的一步:D5 的來源白名單一行就能對齊 HTTP 側;D1 的「登記時檢查是不是 AuthenticatedNamespace 子類」在 _register_socketio_namespaces() 加一個 issubclass 斷言就能把「新頻道忘了驗身分」從慣例變成啟動時就炸的機制。

§6

六、依這些防線,掃描還沒看過的地方

  • HTTP 中介層 × socket 對應表(D3):HTTP 入口有幾道攔截(日誌、遮密碼、唯讀、租戶、大小),逐一標 socket 有沒有對應。這輪只補了唯讀一項,其他沒盤。
  • 事件內容大小與速率(D3、D6):update 的答案內容、join 的快照大小都沒上限,一條線沒看過。
  • 頻道登記的機制化檢查(D1):登記表只是清單,沒驗 handler 型別。
  • 顯示用 user 欄位的冒名(D2):四個非寫入事件仍信畫面送的名字,掃描只抓了會寫資料庫的那一個。

依據:STRIDE 六頁信任邊界連線標記(CM-2403/2404 驗收後版本)、FE nginx.onprem.conf、docker/production/docker-compose.yml、core/app_factory.py、config/socketio_namespaces.py、套件 jedi-iam socketio_auth.py、套件 jedi-survey fill_survey_socketio_handler.py、FE useSurveySocket.js、scripts/build/socketio_probe.py、DFD Level 0。DFD 與卡上寫的三個頻道(/socket/notification、/socket/fill-survey、/socket/project-survey-report)開檔核實後只剩一個:notification 已於 2026-10-01 拔除,project-survey-report 後端從未註冊、前端呼叫也已註解。