---
title: C03 前門 → socketio
eyebrow: Guidant AI 資安檢視總報告 · 連線清單
h1: C03 前門 → socketio（guidant-fe → guidant-socketio，HTTP :8002）
lede: 即時功能走的那條線——目前只有一個用途：多人同時填同一份問卷時，誰在編輯哪一題、改了什麼，即時同步給房間裡的其他人。它與業務 API（C02）是**兩個不同的行程、兩套不同的守門**，api 入口那道攔截它一概不經過。這一頁回答：這條線**怎麼連**、**哪些功能走它**、它帶來**什麼威脅、駭客怎麼打**、我們**要怎麼防、目前做到哪**。
chips:
  - { text: "必有", kind: plain }
  - { text: "驗 JWT・自成一套守門", kind: warn }
  - { text: "威脅 4 種", kind: accent }
  - { text: "掃描命中 6 條", kind: plain }
---

> **這一頁怎麼來的**：[DFD Level 0](../DFD/dfd-level0.html#c03) 把產品運作時的連線編成 C01～C25；[STRIDE 六頁](../STRIDE/S-spoofing.html)每一條問題都標了發生在哪幾條線。這裡以**連線**為單位，把這條線的特性、掃描在這條線上抓到的攻擊手法、以及由此該有的防線整理成一頁。個別問題修了沒不在這頁講，請點條目連回 STRIDE 頁。

## 一、連線圖

```{.mermaid cap="C03 — 前門到 socketio。瀏覽器以 /socket.io 路徑經前門連到 socketio 行程；socketio 不載任何業務 API、自己驗 JWT 入房。目前只有一個頻道（問卷共編），每個事件都帶「房號＝問卷編號」，由頻道自己查呼叫者是不是該問卷所屬專案的人。api 入口的攔截（記日誌、授權唯讀擋寫）這條線一概不經過，是這條線的本質風險。"}
%%{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
```

## 二、這條線怎麼連

| 項目 | 現況 |
|---|---|
| **誰 → 誰** | `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](../DFD/dfd-level0.html#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`。

## 三、哪些功能會走這條線

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

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

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

掃描在這條線上抓到 6 條問題（[M05-7](../STRIDE/I-information-disclosure.html#m05-7)、[M24-9](../STRIDE/I-information-disclosure.html#m24-9)、[M06-2](../STRIDE/I-information-disclosure.html#m06-2)、[M05-9](../STRIDE/R-repudiation.html#m05-9)、[M18-13](../STRIDE/E-elevation-of-privilege.html#m18-13)、[M24-5](../STRIDE/I-information-disclosure.html#m24-5)），歸納起來是**四種攻擊手法**。每種寫駭客實際的步驟與得手什麼，條目是實例。

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

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

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

對應四種威脅與這條線的本質，防線分六條。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` 斷言就能把「新頻道忘了驗身分」從慣例變成啟動時就炸的機制。

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

- **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 後端從未註冊、前端呼叫也已註解。*
