---
title: C16 api → Discord／Telegram
eyebrow: Guidant AI 資安檢視總報告 · 連線清單
h1: C16 api → Discord／Telegram（即時通知推播，依設定）
lede: 系統把流程通知（任務指派、批次完成、專案啟動）推到客戶的 Discord 頻道或 Telegram 群組走的那條線。方向是**單向往外送**，鑰匙是一條 webhook 網址或一個 bot token——拿到就能以系統的名義往那個頻道發話。這一頁回答：這條線**怎麼連**、**哪些功能走它**、它帶來**什麼威脅、駭客怎麼打**、我們**要怎麼防、目前做到哪**。
chips:
  - { text: "依設定", kind: plain }
  - { text: "網址即憑證", kind: warn }
  - { text: "可能手法 3 種", kind: accent }
  - { text: "掃描命中 0 條", kind: plain }
---

> **這一頁怎麼來的**：[DFD Level 0](../DFD/dfd-level0.html#c16) 把產品運作時的連線編成 C01～C25；[STRIDE 六頁](../STRIDE/S-spoofing.html)每一條問題都標了發生在哪幾條線。**這條線在六頁 STRIDE 裡零命中**——不是沒問題，是通知模組的 M16-4（「測試 Discord」填什麼網址就打什麼）在 STRIDE 歸類時標的是 C02（入口在 API），沒標這條。本頁第四段改寫「依這條線的特性可能的攻擊手法」，M16-4 以引用方式列出。個別問題修了沒不在這頁講。

## 一、連線圖

```{.mermaid cap="C16 — api 到 Discord／Telegram。每個租戶各自一組設定（webhook 網址、bot token、chat_id）。流程事件觸發時，api 在背景執行緒把一段純文字 POST 到 Discord 官方 webhook 或 Telegram Bot API。紅色是這條線的兩個入口：網址與 token 由客戶管理員填（網址本身就是發話憑證），訊息內容夾著使用者自填的暱稱與專案名。"}
%%{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
    ADM(["👤 租戶管理員<br/>（notify_config.update）"])
    USR(["👤 任一登入使用者"])
    subgraph MAIN["🏠 主系統 api"]
        direction TB
        CFG[("NOTIFY_CONFIG<br/>每租戶各一組<br/>DISCORD: webhook 網址<br/>TELEGRAM: bot token・chat_id")]
        EVT["流程事件<br/>指派・批次完成・專案啟動"]
        TH["背景執行緒<br/>（主執行緒先讀好設定再丟）"]
        CFG --> TH
        EVT --> TH
    end
    DC(["💬 Discord<br/>discord.com/api/webhooks/…"])
    TG(["✈️ Telegram<br/>api.telegram.org/bot…"])
    ADM -- "C02 · 填網址／token、測試" --> CFG
    USR -- "C02 · 暱稱、專案名進訊息" --> EVT
    TH == "C16 · HTTPS POST 純文字" ==> DC
    TH == "C16 · HTTPS POST 純文字" ==> TG
    classDef ext fill:#FBFCFC,stroke:#9AA8AA,stroke-dasharray:3 2
    classDef open fill:#FDECEC,stroke:#C0392B
    class ADM,USR,DC,TG ext
    class CFG,EVT open
```

## 二、這條線怎麼連

| 項目 | 現況 |
|---|---|
| **誰 → 誰** | `guidant-api` → Discord 官方 webhook 端點，或 Telegram Bot API。目的地：Discord 由設定頁填的 webhook 網址決定（**只放行 `discord.com` 系六個官方網域、https、路徑 `/api/webhooks/`**）；Telegram 固定 `https://api.telegram.org/bot{token}/sendMessage` |
| **協定／埠** | HTTPS，`requests.post()`；憑證驗證是 requests 預設開。**兩支都沒帶 timeout** |
| **用誰的身分** | Discord：webhook 網址本身就是憑證（誰拿到誰能發）；Telegram：bot token（放在網址路徑裡）＋ chat_id。兩者都是客戶在自己的 Discord／Telegram 上建的 |
| **設定存哪** | `NOTIFY_CONFIG` 群組、每租戶各一列（`DISCORD`／`TELEGRAM` 兩個 key），值含 `enabled`、`secret`（網址或 token）、`chat_id`。讀取 API 會刪掉 `secret` 再回（套件 `mask_secret_value()`，`NOTIFY_CONFIG` 在遮罩群組）；**落庫明文** |
| **誰能改** | 能力點 `notify_config.update`（租戶管理員層級，不是總部）；「測試」同一顆能力點，密碼欄沒改時從既存列補回 `secret` 再試 |
| **傳什麼** | 一段純文字（Discord `content`、Telegram `text` 帶 `parse_mode: Markdown`）：事件描述＋使用者暱稱＋專案名＋任務數。**不跳脫**（M06-27 修時刻意只跳脫信件版，聊天頻道吃純文字維持原樣） |
| **怎麼送** | 業務流程在主執行緒先讀好設定（`get_channel_config` 要求有 user_context，背景執行緒沒有 RLS 身分會直接拒讀），再開 `threading.Thread` 丟出去，不等結果 |
| **何時存在** | 依設定（`enabled` 且 `secret` 齊全；Telegram 另要 `chat_id`）。沒設時記一行 warning 略過 |

依據：套件 `jedi_notification/infra/discord/discord_adapter.py`（`is_allowed_webhook_url()`、`_ALLOWED_HOSTS`）、`jedi_notification/infra/telegram/telegram_adapter.py`；主專案 `app/notification/service/notification_service.py`（`get_channel_config`、兩支 send）、`app/notify_config/service/notify_config_test_service.py`、`api/notify_config/routes/notify_config_route.py`（`@require_capability("notify_config.update")`）、呼叫點 `app/flow_control/service/project_service.py:776-782`、`job_batch_complete_service.py:194-200`、`app/flow_engine/service/workflow_execution_service.py:1225-1297`；套件 `jedi_system_core/plugin/contract.py`（`DEFAULT_SECRET_MASKED_GROUPS` 含 `NOTIFY_CONFIG`）。

**DFD 對照**：DFD 寫「webhook URL／bot token（`NOTIFY_CONFIG`）」與實況相符。補充 DFD 沒寫的：Discord 網址有官方網域白名單（CM-2371 後）、Telegram 固定官方、守門是租戶層級能力點（每家各自設，不是全系統一份）。

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

| 群 | 功能 | 對威脅的意義 |
|---|---|---|
| **① 流程通知** | 批次指派任務（`notify_users_batch_assigned`）、批次完成彙總（`job_batch_complete_service`）、專案啟動（`_notify_project_started`）——與 [C10](C10-api-to-smtp.html) 信件版同三個事件 | 訊息裡有使用者自填的暱稱與專案名，**原樣送出不跳脫**；Telegram 端開了 Markdown 解析 |
| **② 測試** | 通知設定頁「測試」按鈕（`POST /notify-config/{channel}/test`）→ 用畫面上的設定（密碼欄沒改就補回存著的）送一則測試訊息 | 目的地由操作者當場指定——Discord 有白名單擋、Telegram 固定官方 |

沒有第三群：這條線不收回應、不讀頻道內容、不做指令互動。

## 四、依這條線的特性可能的攻擊手法（無掃描實例）

這條線在 STRIDE 六頁**零命中**。依它的特性——「網址即憑證、目的地由人填、訊息夾著別人的字、不等結果」——列出三種可能的手法。前兩種是從同模組的 M16-4（STRIDE 標 C02）與 [C10](C10-api-to-smtp.html) T4 延伸來的，第三種是純粹依特性推。

| # | 風險 | 威脅 | 駭客怎麼打 | 得手什麼 | STRIDE | 實例 |
|---|---|---|---|---|---|---|
| T1 | —（無掃描實例） | **拿「測試」按鈕當跳板打內網** | ① 有通知設定權限的租戶管理員打開設定頁；② 把 Discord webhook 網址填成內網位址（例如 `http://10.0.0.5:8080/admin`）；③ 按「測試」；④ 系統替他對那個位址送一個 POST；⑤ 回應內容不會回給他，但「連不連得上」會——可以逐台探測客戶內網哪台活著 | 拿系統當跳板探測內網拓撲（偷不到資料，只能探） | [E 權限提升](../STRIDE/E-elevation-of-privilege.html)、[I 資料外洩](../STRIDE/I-information-disclosure.html) | （STRIDE 歸 C02）M16-4「測試 Discord 群組」填什麼網址就打什麼，見 [M16 模組頁問題一覽第 4 條](../M16-notification.html#問題一覽) ⚪ |
| T2 | —（無掃描實例） | **用系統的名義在客戶頻道發釣魚訊息** | ① 任一登入帳號把暱稱改成一段含連結的文字，Telegram 端可用 Markdown 寫成 `[請重新登入](https://假網址)`；② 他去批次完成任務或啟動專案；③ 系統以 bot／webhook 身分把訊息推到客戶的頻道，顯示為系統發的；④ 頻道裡的同事點了就上當 | 用系統的頻道身分發釣魚訊息；Telegram Markdown 可偽裝連結文字 | [S 冒充身分](../STRIDE/S-spoofing.html) | 同型：[M06-27 通知信把暱稱原樣塞進信件](../STRIDE/S-spoofing.html#m06-27) ⚪（修時刻意只跳脫信件版、聊天頻道維持原樣） |
| T3 | —（無掃描實例） | **webhook 網址外流後冒充系統發話** | ① 攻擊者拿到 webhook 網址或 bot token（DB 備份、診斷包、設定頁截圖、Discord 端的分享）；② 直接對 Discord 官方 webhook POST，不經過我們系統；③ 在客戶頻道以「稽核系統」的名義發任何訊息——假的任務指派、假的「請到此連結完成稽核」 | 冒充系統身分對客戶發話；客戶無從分辨 | [S 冒充身分](../STRIDE/S-spoofing.html) | — |

**三種手法的共同點**：這條線的憑證是**一條網址**——拿到就能用、不需要我們系統的任何身分（T3）；而我們拿它發出去的內容又夾著別人的字（T2）。T1 是「目的地由人填」的老病，Discord 那半 CM-2371 已用白名單擋住，但同一個「測試」端點的 Telegram 半邊目的地固定官方、天然不受影響。

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

對應三種手法與這條線的本質，防線分五條。**沒有掃描實例，所以全部標 ◇**——這些是依連線特性應有的標準防線。「目前」欄寫程式與設定裡**實際有的機制**；⚠️ 表示只靠慣例或只守到局部。

| # | 防線 | 擋哪種威脅 | 目前做到哪 |
|---|---|---|---|
| D1 ◇ | **目的地白名單：只准官方網域、https、固定路徑前綴**；host 精確比對不用 endswith；帶帳密或非預設埠一律拒 | T1 | ✅ Discord：`is_allowed_webhook_url()` 六個官方 host 精確比對、https、埠 443、路徑 `/api/webhooks/`，在套件層擋（測試與正式發送都過這支）；不合格記 warning 只帶 host 不帶完整網址（網址即憑證）。✅ Telegram：網址寫死官方，token 只進路徑。驗證：CM-2371 |
| D2 ◇ | **使用者的字進訊息前做對應格式的跳脫**。Telegram 開了 Markdown 就要跳脫 Markdown 特殊字元；Discord 純文字至少去掉 `@everyone`／`@here` 與連結語法 | T2 | ⚠️ **沒有**。M06-27 修時決定「只跳脫信件版、聊天頻道維持原樣」；Telegram `parse_mode: Markdown` 仍開，暱稱裡的 `[文字](網址)` 會被渲染成可點連結。這是當時的取捨，記在這裡是因為它直接決定 T2 成不成立 |
| D3 ◇ | **憑證加密落庫、讀取遮罩、日誌不記網址** | T3 的外流面 | ✅ 讀取遮罩（`mask_secret_value()` 刪 `secret`）；adapter 的 log 只記 host 不記完整網址。⚠️ 落庫明文（與 SMTP、LDAP、issue token 同一組問題，見 [C10](C10-api-to-smtp.html) D5） |
| D4 ◇ | **外部呼叫有逾時、失敗有紀錄**。背景執行緒丟出去不等結果是對的，但沒逾時的 `requests.post` 會讓執行緒永遠掛著 | 外部慢時執行緒累積 | ⚠️ 兩支 adapter 的 `requests.post()` 都**沒帶 timeout**（requests 預設無限等）；每次事件開一條 thread，Discord 掛了執行緒會堆積。失敗有記 error |
| D5 ◇ | **訊息內容最小化**：頻道是第三方 SaaS，推出去的只該有「有事發生、去系統看」，不帶客戶內部的專案名與人名 | 客戶資料經這條線流到 Discord／Telegram 伺服器 | ⚠️ 目前訊息帶專案名、暱稱、任務數。對合規客戶（不准資料出境）這是一條沒標示的出境路徑；設定頁沒有說明 |

**最便宜的一步**：D4——兩支 `requests.post()` 各加 `timeout=10`，一行一個。D2 的 Telegram Markdown 關掉（或改 `MarkdownV2` 並跳脫）是第二便宜的。

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

- **整條線沒進掃描**：零命中不是零問題——通知模組掃描時看的是信件（SMTP）那半，聊天頻道只在 M16-4 的「測試」按鈕被點到。D2、D4、D5 都是本頁依特性列的，沒有人實際打過。
- **Telegram Markdown 注入**（D2）：該用一個暱稱含 `[連結](url)` 的帳號實測一次批次完成通知，看 Telegram 端渲染出什麼。
- **`@everyone` 濫用**（D2）：Discord webhook 預設允許 `@everyone`——暱稱含它會讓整個伺服器被 ping。
- **執行緒堆積**（D4）：Discord 或 Telegram 慢時，每個流程事件開一條永不逾時的執行緒；該在壓測時看執行緒數。
- **設定頁的出境說明**（D5）：與 [C13](C13-api-to-external-ai.html) D7 同一件事——哪些內容會離開客戶機房，文件層沒寫。

---

*依據：STRIDE 六頁信任邊界連線標記（CM-2403／2404 驗收後版本，本條零命中）、`docs/security-report/M16-notification.md` 第 4 條（M16-4，STRIDE 標 C02）、套件 `jedi_notification/infra/{discord,telegram}/*_adapter.py`、主專案 `app/notification/service/notification_service.py`、`app/notify_config/service/notify_config_test_service.py`、`api/notify_config/routes/notify_config_route.py`、三個流程事件呼叫點、套件 `jedi_system_core/plugin/contract.py`、DFD Level 0。*
