U3 檢查結果:首次安裝精靈+版本資訊(登入前就打得到)

U3 檢查結果:首次安裝精靈+版本資訊(登入前就打得到)

檢查日期 2026-09-25|對應卡片 CM-2155(母卡 CM-2152)|檢查範圍 9 個檔案 930 行|工具 run ID wf_7cfdaa3f-a72

§1

🔴 一句話結論

沒有能讓外人搶先建管理員、或在系統開通後重新打開精靈的洞。 卡片最擔心的三件事——設定碼能不能猜、用過會不會失效、把使用者刪掉精靈會不會重開——逐條開檔核對後都不成立,程式裡有明確的防範。

掃描工具這一棒零發現(沒有提出任何候選,所以三人面板沒投票)。我依卡片逐支開檔、再手做惡意輸入實測,另外找到三件低度問題,都不需要緊急處理:

  1. 在設定碼欄位塞一個非英文字元(例如 é),伺服器會回 500 錯誤而不是「設定碼不正確」——擋得住,只是擋的姿勢難看,而且會在錯誤收集站留一筆假警報。
  2. 程式註解說「兩個人同時送出開通時,資料庫會擋下第二個」,但資料庫實際上沒有這條規則——擋不住。要打到這個窗口必須先拿到只印在裝機終端機上的設定碼,所以實際風險很低,但註解是錯的。
  3. 免登入的「前端設定」端點會回傳錯誤收集站的連線字串,裡面有站台位址(可能是客戶內網 IP)。這是前端要能回報錯誤的必要設計,不是程式寫錯,列出來讓決策者知道有這回事。
§2

這一棒在檢查什麼

一般的功能都要先登入,這一棒的 9 支程式不用登入就打得到,攻擊面在所有功能的最前面:

  • 首次安裝精靈(5 支):客戶剛裝好系統時,系統裡一個帳號都沒有,沒辦法用帳號密碼登入。所以安裝程式 install.sh 會在終端機印出一組一次性設定碼(32 個英數字元的隨機碼),客戶把它貼進瀏覽器的精靈畫面,證明「我人在這台機器旁邊、看得到安裝畫面」,精靈才讓他建立第一個公司(租戶)跟這家公司的管理員帳號。建好之後,這條通道要永久關閉。
  • 版本資訊(4 支):/api/1.0/version 回傳產品版本號,/api/1.0/client-config 回傳前端要用的設定(錯誤收集站連線字串、環境名、版本)。兩支都完全不用登入。

要回答的核心問題是:沒有身分可以驗的時候,這扇門靠什麼關?關得牢不牢?

檔案 行數 角色
app/setup/service/setup_wizard_service.py 395 精靈的主邏輯:查狀態、建公司+管理員
api/setup/routes/setup_route.py 128 精靈的兩支網址入口,驗設定碼
common/setup/setup_token.py 113 讀設定碼檔、比對、用完清空
infra/setup/setup_state_reader.py 89 判斷「系統開通了沒」
common/util/app_version.py 88 取版本號與程式碼識別碼
api/version/routes/client_config_route.py 47 前端設定端點
api/version/routes/version_route.py 29 版本端點
api/setup/__init__.py 22 精靈網址登記
api/version/__init__.py 19 版本網址登記
§3

掃到什麼(總覽)

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度+為什麼 來源
U3-1 設定碼欄位放非英文字元,比對函式直接當掉 回 500(不是 403);錯誤收集站多一筆假警報、log 多一筆 ERROR。不會放行 什麼都不用,只要連得到網站;且系統還沒開通(開通後設定碼檔已清空,不會走到比對那一行) common/setup/setup_token.py:92(比對前先確認只含英數字,或把兩邊轉成位元組再比) 🟢 低:沒有繞過、沒有洩漏,只是錯誤處理難看+可灌假警報;錯誤收集站本身每分鐘有上限 runner 自行開檔+實測(未經三人面板)
U3-2 註解說「資料庫會擋同名帳號」,實際上資料庫沒有這條規則 兩個人同時送出開通,可能建出兩家公司、兩個管理員 必須持有設定碼,且要跟正牌裝機者在同一瞬間送出 app/setup/service/setup_wizard_service.py:37-41(修正註解),或 provision_first_tenant 用資料庫鎖把「判斷沒開通+建立」包成不可插隊 🟢 低:拿到設定碼的人本來就能直接開通,多一個並發窗口沒有多給攻擊者什麼;問題是註解宣稱的防線不存在 runner 自行開檔+查 DEV 資料庫(未經三人面板)
U3-3 免登入的前端設定端點回傳錯誤收集站連線字串 任何人都拿得到錯誤收集站的位址(可能是內網 IP)與「寫入用」公開金鑰,可以往站台灌假錯誤事件 什麼都不用;且客戶有設定錯誤收集站(出貨預設沒設,沒設就回空字串) api/version/routes/client_config_route.py:40 ⚪ 資訊:前端要回報錯誤就必須拿到這串,業界(Sentry)設計本來就把它當公開值;列出供知悉 runner 自行開檔(未經三人面板)

現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • U3-1(設定碼特殊字元回 500)=總表第 183 項,✅ 已修(CM-2215,commit 822216b6c,1.21.0 出貨)。
  • U3-2(開通沒上鎖)=總表第 184 項,✅ 已修(CM-2216,commit a02409392,1.21.0 出貨)。
  • U3-3(免登入端點回傳錯誤收集站連線字串):標「設計使然」,SUMMARY/M24 頁沒有對應條目,查不到修正卡。

工具報的:零條。

§4

工具報的逐條

無。工具派了兩位研究員(一位讀全部範圍、一位專找寫死在程式裡的密碼金鑰),兩位都交回空清單,所以三人面板沒有東西可投。

§5

卡片點名的疑點逐條回答

① 設定碼怎麼比對?是不是「常數時間比對」?——✅ 是,不成立

白話:「常數時間比對」是指不管你猜對幾個字,系統回應的快慢都一樣。如果用一般的字串比對,猜對的字越多、回應越慢,攻擊者量時間就能一個字一個字試出來。

common/setup/setup_token.py:92 用的是 hmac.compare_digest,這是 Python 專門做常數時間比對的函式,符合要求。設定碼本身是 openssl rand -hex 16(scripts/installer/install.sh:2097)= 128 位元隨機數,暴力猜不可能猜中。

但實測發現一個副作用 → 見 U3-1:hmac.compare_digest 碰到非英文字元會直接拋錯。我在本機用實際的 verify_token 放一組假設定碼檔實測:送英文錯碼回 False(正確)、送正確碼回 True、送 é 組成的字串拋出 TypeError。再用 Flask 測試客戶端把同樣的比對放進網址入口,對方收到的是 HTTP 500。這支程式沒有自己接住這個錯誤,會一路冒到全站的「未預期錯誤」處理,寫一筆 ERROR log 並回 500。結果仍是「不放行」,所以不是繞過。

② 用過一次後真的失效嗎?——✅ 會失效,而且有兩道,不成立

第一道(真正的鎖)是資料庫事實:開通成功後,資料庫裡就有了「非原廠公司底下的使用者」,精靈每次開通前都會查這件事(setup_wizard_service.py:302-307),查到就回 409「已開通」。這道鎖存在資料庫裡,重開機、多台機器、重送同一個請求都一樣擋得住。查不出來(資料庫掛了)時也當成已開通擋下,失效方向是安全的。

第二道(額外保險)是清空設定碼檔:開通成功時把磁碟上的設定碼檔內容清空(setup_token.py:95-113)。清空後讀出來是空的,比對直接失敗,連 409 都走不到,一律 403。

特別確認了「刪檔失敗」的情況:程式註解明確說清空失敗不算開通失敗,因為真正的鎖是第一道。我同意這個設計——如果反過來以「檔案在不在」當鎖,刪檔失敗就會留下一條永久可建管理員的通道。安裝程式重跑時也只在檔案「非空」才沿用舊碼(install.sh:2093),已清空的檔不會被當成可用碼印出來。

③「已開通」之後再打 POST /setup/provision,會不會再建一個管理員?——✅ 不會,不成立

順序是:網址入口先驗設定碼(setup_route.py:117)→ 精靈主邏輯先查「開通了沒」(setup_wizard_service.py:167)→ 才開始建。已開通時:

  • 設定碼檔已清空 → 在第一步就 403。
  • 就算清檔失敗、設定碼還對 → 第二步 409。

兩條路都到不了「建帳號」那一行。既有測試 test/test_setup_wizard_one_shot.py(34 項)涵蓋了這幾條,本棒實跑全數通過。

④「是否已開通」的判據能不能被繞?把那個使用者刪掉,精靈會不會重開?——✅ 從產品介面刪不到,不成立

判據是 infra/setup/setup_state_reader.py:73-79:「users 表裡有沒有任何一筆屬於原廠公司(tenant 1)以外的使用者」。刻意不看帳號是否停用——停用的管理員仍算「開通過」。

我追了所有能讓這個判據變回「否」的路:

路徑 會不會讓精靈重開 依據
從使用者管理畫面按「刪除」 ❌ 不會 這個按鈕實際做的是把狀態改成「撤銷」(jedi-iam/.../user_route.py:172-178 呼叫 update_user_status(REVOKE)),資料列還在;判據不看狀態
真的把資料列刪掉的程式 ❌ 沒有入口 user_service.delete_user() 存在,但全專案與 jedi 套件沒有任何網址入口呼叫它
刪掉整家公司 ❌ 不會 資料庫有「公司底下還有使用者就不准刪公司」的規則(users_tenant_fk ... ON DELETE RESTRICT,DEV 已查證)
把業務公司所有使用者搬到原廠公司 理論上會 要平台管理員才做得到,而且要搬光每一個業務使用者;能做這件事的人本來就有全站權限
直接進資料庫下 DELETE 會 只有持有資料庫管理帳號的人做得到(例如出貨前的清理腳本 scripts/sql/2026-08-17-fr065-t20-shipping-baseline-cleanup.sql,這正是它要的效果)

而且即使精靈重開了,攻擊者還是需要設定碼——開通時已清空,要重新產生只能在主機上以 root 重跑安裝程式。換句話說,能讓精靈重開的人,本來就已經是系統的主人。

DEV 唯讀查證(2026-09-25 11:30 +08,BEGIN READ ONLY … ROLLBACK,沒有寫入):tenant 1 有 1 位使用者(原廠 root admin),tenant 102/131/158 各有使用者 → 判據回「已開通」。users 表沒有「已刪除」欄位,只有 status,確認刪除走的是改狀態。

⑤ 版本與前端設定端點(完全免認證)有沒有夾帶環境變數、主機路徑、內部 IP?——🟡 部分成立,見 U3-3

  • GET /api/1.0/version:只回 version(版本號)與 commit(程式碼識別碼)。版本號來源是打包時寫死的常數檔或 pyproject.toml,commit 是 git rev-parse HEAD 的輸出;兩者都不是使用者輸入、不含路徑或 IP。✅ 乾淨。
  • GET /api/1.0/client-config:回 sentry_dsn、environment、version、commit。
    • environment 是 ENV 環境變數的值(例如 DEVELOP_PREMISE),只是一個部署型態名稱,不含機密。
    • sentry_dsn 是錯誤收集站連線字串,格式 http://<公開金鑰>@<站台位址>:<埠>/<專案編號>。站台位址如果是客戶內網的自架站,就會是內網 IP;公開金鑰讓人可以往站台送事件。→ 列為 U3-3。這是前端錯誤回報的必要設計(瀏覽器要直接送事件到站台),Sentry 官方文件本來就把它當公開值;出貨預設不設定、回空字串。
    • 沒有夾帶其他環境變數、主機路徑、資料庫連線或密碼。✅

⑥「有人守了一半」共通疑點

疑點樣式 本棒結果
只驗「你是誰」沒驗「這筆是不是你的」 不適用:精靈沒有「你的資料」,只有「開通了沒」一個全域狀態
列表有守、單筆沒守 不適用:兩支端點,都有守
route 裝飾器守門但有第二支路由沒掛 ❌ 不成立:精靈只登記了兩支網址(api/setup/__init__.py:15-16),provision 一律驗設定碼,status 刻意分級(無碼只給「開通了沒」,有碼才給機器指紋)
守門條件用 or 串、其中一個恆真 ❌ 不成立
「查不到」跟「沒權限」混成同一個回應 刻意混:設定碼缺漏/錯誤/檔案不存在都回同一個 403,避免幫探測者判斷系統狀態——這是對的
系統身分執行時假設呼叫者是自己人 ❌ 不成立:精靈設的「系統身分」(_set_setup_context)只在驗過設定碼+確認未開通之後才設,而且每個請求開頭都會把身分歸零(common/middleware/request_context_mw.py),不會殘留給下一個請求
防重放只在單一進程有效 ❌ 不成立:鎖在資料庫,不在記憶體
註解寫「這裡不用檢查」但上一層沒檢查 🟡 成立一處 → U3-2:註解說資料庫有唯一約束擋並發,實際沒有

另外順手確認:精靈網址被加進了「授權過期唯讀模式」的豁免名單(common/middleware/license_readonly_mw.py:74,屬 U2 範圍,本棒只讀不報)。豁免的理由是「裝機當下一定沒有授權」,而精靈另有設定碼+開通後永久 409 兩道門,豁免沒有擴大攻擊面。請求 log 會記錄標頭,但 X-Setup-Token 不在可記錄白名單內,值會被遮成 ***;開通請求的 body 裡的密碼欄位也有遮罩。✅

§6

各條詳細

U3-1 — 非英文字元的設定碼讓比對函式當掉,回 500

  • 嚴重度:🟢 低
  • 位置:common/setup/setup_token.py:92(hmac.compare_digest(candidate.strip(), expected));兩個呼叫點 api/setup/routes/setup_route.py:70、:97
  • 白話說明:Python 的常數時間比對函式只接受純英數的文字,碰到 é、中文這類字元會直接報錯。這支程式沒接住這個錯誤,於是一路冒到全站的錯誤處理,回給對方 500。
  • 影響:不會放行,也不會洩漏設定碼。實際影響是:①任何人都能讓 /setup/status 和 /setup/provision 回 500;②每一次都會寫一筆 ERROR log,有接錯誤收集站的話還會送一筆假警報(站台每分鐘有 60 筆上限,不會被打死,但真的錯誤可能被淹掉)。
  • 觸發前提:連得到網站;系統尚未開通(或開通時清檔失敗)——開通後設定碼檔是空的,程式在比對前就回 False,走不到這行。已開通的正式環境打不到。
  • 實測:本機放一組假設定碼檔,直接呼叫 verify_token('éééééééé') → TypeError: comparing strings with non-ASCII characters is not supported;用 Flask 測試客戶端把同樣的比對掛成網址 → HTTP 500。沒有對正在跑的服務實測(本機 BE 當時未啟動)。
  • 建議修法:比對前先確認輸入只含英數字(不符就直接回 False),或把兩邊都 .encode() 成位元組再比——hmac.compare_digest 對位元組不挑字元。

U3-2 — 註解宣稱的「資料庫唯一約束」不存在,並發開通擋不住

  • 嚴重度:🟢 低
  • 位置:app/setup/service/setup_wizard_service.py:37-41(註解);實際的判斷在 :167(先查未開通)與 :249(建帳號)之間
  • 白話說明:精靈的流程是「先查開通了沒 → 沒有就建」,中間沒有上鎖。程式作者知道這個窗口,並在註解寫「不另加鎖的理由是:帳號名稱與 email 在資料庫是全域唯一,第二個請求會撞到約束而整批作廢」。但查了資料庫,users 表只有 uid 有唯一約束,帳號名稱只有一般索引(不擋重複),email 什麼都沒有(DEV 與出貨基線 scripts/init/02-schema.sql 都一樣)。帳號重複的檢查只在程式層做(先查有沒有同名、再寫入),這個檢查本身也有同樣的並發窗口。
  • 影響:兩個請求同一瞬間送出時,可能建出兩家公司、兩個管理員;若帳號名稱相同,甚至會出現兩筆同名帳號。
  • 觸發前提:必須持有設定碼(只印在裝機終端機、存在只有 root 讀得到的檔案裡),且兩個請求要在同一瞬間送出。拿到設定碼的人本來就能直接開通,所以這個窗口沒有多給攻擊者新能力——實務上比較可能發生的是裝機者自己在瀏覽器連點兩次。
  • 建議修法:擇一。①最小:改正註解,寫清楚「並發窗口存在,因需持有設定碼而接受」。②補鎖:在「查開通了沒+建立」外面包一個資料庫層的鎖(例如 PostgreSQL 的 advisory lock),讓第二個請求排隊,排到時查到已開通就回 409。

U3-3 — 免登入端點公開錯誤收集站連線字串(設計使然)

  • 嚴重度:⚪ 資訊
  • 位置:api/version/routes/client_config_route.py:40
  • 白話說明:前端程式是「一顆映像檔賣所有客戶」,沒辦法在打包時寫死每個客戶的錯誤收集站位址,所以改成開站時向後端要。前端在登入前就可能出錯,所以這支必須免登入。
  • 影響:客戶有設錯誤收集站時,任何連得到網站的人都能拿到站台位址(自架站就是內網 IP+埠號)與可以寫入事件的公開金鑰,可以往站台灌假的錯誤事件。拿不到讀取權限(讀事件用的是另一組權杖 GLITCHTIP_API_TOKEN,不在這裡)。
  • 觸發前提:客戶有設定 SENTRY_DSN(出貨預設沒設)。
  • 建議修法:不需修程式。若決策者介意,可在站台端限制「只接受來自本系統網域的事件」(GlitchTip/Sentry 的 Allowed Domains 設定),部署文件補一句。
§7

這份結果可信到什麼程度

第一層:工具正式報告(經三人面板驗證)

  • 驗證章:verified(stamp CLAUDE-SECURITY-REVISION-3f6290400be3-dirty.json)
  • 候選數 0、面板投票 0、研究員派出 2/交回 2、失敗 0、續跑 0。
  • 「零發現」代表工具的研究員讀完這 9 支沒提出任何疑點,不代表這 9 支乾淨——工具擅長找「程式寫錯」,不擅長找「註解跟現實不符」「輸入格式讓函式當掉」這類需要實測或查資料庫才看得出的問題。

第二層:runner 自行開檔核對(未經三人面板投票)

  • 範圍 9 支逐支通讀;卡片「重點看什麼」五條+「有人守了一半」八種樣式逐條答完。
  • 跨讀了範圍外的相關程式(只讀不報):install.sh 的設定碼產生段、license_readonly_mw.py 豁免名單、request_context_mw.py 身分歸零、app_mw.py 標頭遮罩、jedi-iam 的使用者刪除路徑與建帳號重複檢查、jedi-common 的全站錯誤處理。
  • 實測:U3-1 用真的 verify_token 與 Flask 測試客戶端重現;既有測試 test/test_setup_wizard_one_shot.py 34 項全過。
  • DEV 資料庫唯讀查證(2026-09-25 11:30 +08,全程 BEGIN READ ONLY … ROLLBACK):開通狀態、各公司使用者數、users 表的約束與索引。
  • 打折處:U3-1 沒有對正在運行的服務實測(本機 BE 當時未啟動),是用同一支函式+Flask 測試客戶端模擬;U3-2 的並發窗口是讀碼+查約束推論,沒有實際並發打兩個請求。
§8

執行概況

項目 值
掃描目標 BE repo compliance-manager-be,branch feature/review
版本 3f6290400(工作區有平行線未 commit 改動,範圍 9 支檔案本身無改動)
工具 Claude Code 官方 claude-security plugin 0.11.0
參數 mode scan/effort low/focus attack-surface/scope 9 檔
run ID wf_7cfdaa3f-a72
報告目錄 CLAUDE-SECURITY-20260925-032730/(不入版控)
耗時 約 9 分鐘(559 秒)
研究員 2 派出/2 交回(全範圍 1+密碼金鑰專掃 1),failed 0
候選/面板票 0/0
驗證章 verified
runner 自行發現 3 條(低 2、資訊 1),皆未經面板