A1 檢查結果:AI 聊天機器人

A1 檢查結果:AI 聊天機器人

檢查日期 2026-09-10|耗時 65 分鐘|對應卡片 CM-1637


§1

🔴 一句話結論

聊天功能沒有任何長度或次數限制——任何一個普通員工帳號,開四到八個連線就能讓整個系統停止回應,或者無限次呼叫把公司的 AI 額度燒光。


§2

這一棒在檢查什麼

系統裡有一個 AI 聊天機器人:使用者打字問問題,系統把訊息原封不動送給 Anthropic(Claude),拿回覆顯示給使用者。對話記錄存在 Redis,一小時沒動就自動清掉。

這一棒檢查這個套件的 13 個檔(632 行)——能不能看到別人的對話、能不能塞東西進別人的記錄、能不能無限次呼叫把公司的錢燒光

這支跟前面掃過的都不一樣:它是 25 支套件裡唯一會把使用者打的字送到公司網路外面、而且每次呼叫直接產生金錢成本的。


§3

找到什麼

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 修正卡
1 🟡 聊天訊息沒有長度上限,呼叫次數也沒有限制 兩種後果,第一種更嚴重
整個產品停止回應——系統只有 4 個工作程序,一個一次處理一個請求。攻擊者開 4~8 個連線各送一則接近 50MB 的訊息,就佔滿全部工作程序,其他人連登入都會逾時
燒公司的錢——AI 金鑰是全公司共用一把,任何登入者可無限次呼叫
① 一個普通員工帳號(端點有要求登入,但沒有任何角色檢查
② 系統用預設方式部署
api/ai_bot_route.py:64 待開

只找到這 1 條。 另外三條候選被檢查員否決(理由見下)。


§4

詳細說明

問題 1:沒有長度上限,也沒有次數限制

白話:使用者在聊天框打什麼、打多長,系統完全不管,直接送給 Claude。也沒有「一個人一分鐘最多問幾次」的限制。

為什麼「整個產品停止回應」比「燒錢」更嚴重——檢查員把這條鏈追得很完整:

主專案跑 4 個同步工作程序(main.py:258)
    ↓ 一個工作程序一次只能處理一個請求
呼叫 Claude 本來就慢
    ↓ 而 Anthropic 套件的預設逾時是 10 分鐘,這個套件從來沒改過
工作程序被佔住
    ↓ 系統要等 120 秒才會強制砍掉卡住的工作程序
4~8 個並發請求 → 全部工作程序被佔滿
    ↓
整個產品的所有功能都停止回應,不只聊天

單則訊息能多大:主專案的全站請求上限是 50MB(CM-1587 訂的)。這個上限擋得到聊天訊息,但 50MB 對一則聊天訊息來說等於沒擋。

在哪裡

# jedi_ai_bot/api/ai_bot_route.py
57 行  message = (payload.get('message') or '').strip()   ← 只去頭尾空白
60if not message: return ...400                       ← 只檢查空字串
64 行  reply = ctx.service.chat(ctx.current_user_id(), session_id, message)
          ↑ 沒有長度檢查、沒有次數限制,直接送出去

檢查員做了額外查證:他們去主專案和 nginx 設定裡找有沒有其他層擋著——沒有找到任何頻率限制

怎麼修(工具給的建議很具體,首腦認同):

做什麼 放哪裡
訊息長度上限(建議 4000 字)、session_id 長度上限(建議 64 字),超過回 400 套件的 route 層(post),上限做成 AiBotConfig 可調欄位
呼叫 Claude 的逾時 AiBotService.__init__request_timeout 參數傳給 Anthropic(...)讓上游卡住時自己放棄,不要等系統砍工作程序
每人呼叫頻率限制 做成 adapter 讓主專案用既有的 Redis 實作——「怎麼限制」是產品知識,但「有沒有限制」這個插槽該由套件開出來

§5

🔴 首腦補查:卡片點名的兩個重點,工具沒答

重點 ①(我開卡時標為「本棒核心」):對話記錄能不能跨到別人那裡?→ ❌ 不能

我原本的擔心:對話記錄的 Redis 儲存位置是 chat_history:{使用者編號}:{對話編號},而對話編號完全由呼叫端自己給,只做了去空白處理(ai_bot_route.py:58)。Redis 的儲存位置沒有跳脫機制,冒號在這裡是有意義的分隔符——所以我擔心有人塞 x:chat_history:999 之類的東西跨到別人的記錄。

實際查證結果:跨不過去。

儲存位置 = chat_history:{使用者編號}:{對話編號}
                        ↑ 這一段由伺服器填(api/ai/__init__.py:34 的 get_user_context().id)

攻擊者(編號 5)能產生的位置一律是   chat_history:5:<任意內容>
想碰的目標(編號 9)的位置是         chat_history:9:default

第一段是伺服器填的、而且是數字不含冒號 → 前綴偽造不了

我原本的擔心是錯的。

但這條仍值得記:這個保護完全依賴「使用者編號由伺服器填、且排在最前面」。哪天有人把編號改成呼叫端可控、或把順序調成對話編號在前,這道保護就沒了。建議在程式碼加一行註解說明為什麼順序不能改。

重點 ④:缺少認證設定時會怎樣?→ ✅ 會炸,是安全的

背景:這支套件把認證設定做成「必填欄位」,但沒有任何明確的檢查程式碼(首腦已確認整支 plugin.py 沒有 raise,也沒有建構後驗證)。這跟 jedi-issue 那支不同——那支會直接報錯拒絕掛載。

實測結果

情況 結果
AiBotAdapters(auth_required=None) 建構 成功(必填欄位擋不住 None)
但接著 plugin.py:223 呼叫 adapters.auth_required(...) TypeError 炸掉

所以結果是安全的——系統啟動時就會炸,不會變成「不用登入就能用」的端點。

不如 jedi-issue 那支友善(那支會給明確的中文錯誤訊息說「拒絕掛載,因為會讓端點變公開」),但安全性上沒有洞。這一項不開卡。


§6

被否決的兩條候選,理由首腦認同

候選 否決票數 理由
Redis 對話記錄無限增長 2:1 否決 記錄是在 Claude 接受請求之後才寫入的,所以 AI 模型自己的內容長度上限就是天花板,遠低於 HTTP 的 50MB;而且每筆記錄都有 1 小時自動清除
開發用啟動檔寫死 debug=True 3:0 否決 Flask 沒指定主機時只綁本機、除錯器需要 PIN 且只信任本機、而且開發用檔案不會被打包進發行版

§7

這份結果可信到什麼程度

「這條是真的嗎」→ ✅ 可信

這是六個檢查案以來 stamp 最乾淨的一次verified,而且連「拒收原因」欄位都是空的(前一個案子六棒有五棒帶著 findings-refused 標記)。

三個獨立檢查員對 3 條候選各投一票,9 票全數投出、沒有漏投、沒有中斷。F1 是 3 票全過。

檢查員還做了額外查證:他們去主專案和 nginx 設定裡找有沒有頻率限制,確認沒有——這不是讀套件就能知道的,是主動追出去查的。

「是不是只有這一條」→ ❌ 不可信

快篩模式。13 個檔裡有 4 個是空的,實際有內容的 9 個。工具也沒有留下逐檔閱讀紀錄。

而且首腦補查證明了工具沒照卡片的重點清單走——我點名要查的兩項(對話記錄能不能跨人、缺認證設定會怎樣)工具一項都沒答,是首腦自己查的。兩項結論都是好消息,但那不是工具給的

📌 一個開卡時的疏失(首腦自陳)

卡片寫「檔數 13」,工具報告寫 14。查了原因:工具算全部受版控檔,我給的驗證指令只算 .py,差的是 harness/docker-compose.yml

掃描範圍沒問題,反而多掃一個檔(那個檔也確實該掃,它可能有寫死的憑證)。但驗證指令的口徑該跟工具一致,記錄下來給下一棒參考。


§8

執行概況(技術細節,工程師看的)

項目 數字
檢查範圍 14 個受版控檔(13 支 .py + 1 支 docker-compose)
派出/回報的研究員 2 / 2
候選問題 → 去除重複 4 → 3
投票數 9(3 條 × 3 個檢查員)
沒投到票的 0
投票中斷的 0
被檢查員否決的 2
stamp 狀態 verified(無拒收原因,最乾淨的一次)
耗時 65 分鐘