檢查日期 2026-09-25|對應卡片 CM-2155(母卡 CM-2152)|檢查範圍 9 個檔案 930 行|工具 run ID
wf_7cfdaa3f-a72
沒有能讓外人搶先建管理員、或在系統開通後重新打開精靈的洞。 卡片最擔心的三件事——設定碼能不能猜、用過會不會失效、把使用者刪掉精靈會不會重開——逐條開檔核對後都不成立,程式裡有明確的防範。
掃描工具這一棒零發現(沒有提出任何候選,所以三人面板沒投票)。我依卡片逐支開檔、再手做惡意輸入實測,另外找到三件低度問題,都不需要緊急處理:
é),伺服器會回 500 錯誤而不是「設定碼不正確」——擋得住,只是擋的姿勢難看,而且會在錯誤收集站留一筆假警報。一般的功能都要先登入,這一棒的 9 支程式不用登入就打得到,攻擊面在所有功能的最前面:
install.sh 會在終端機印出一組一次性設定碼(32 個英數字元的隨機碼),客戶把它貼進瀏覽器的精靈畫面,證明「我人在這台機器旁邊、看得到安裝畫面」,精靈才讓他建立第一個公司(租戶)跟這家公司的管理員帳號。建好之後,這條通道要永久關閉。/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 | 版本網址登記 |
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度+為什麼 | 來源 |
|---|---|---|---|---|---|---|
| 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 頁沒有對應條目,查不到修正卡。
工具報的:零條。
無。工具派了兩位研究員(一位讀全部範圍、一位專找寫死在程式裡的密碼金鑰),兩位都交回空清單,所以三人面板沒有東西可投。
白話:「常數時間比對」是指不管你猜對幾個字,系統回應的快慢都一樣。如果用一般的字串比對,猜對的字越多、回應越慢,攻擊者量時間就能一個字一個字試出來。
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)→ 才開始建。已開通時:
兩條路都到不了「建帳號」那一行。既有測試 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,確認刪除走的是改狀態。
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 裡的密碼欄位也有遮罩。✅
common/setup/setup_token.py:92(hmac.compare_digest(candidate.strip(), expected));兩個呼叫點 api/setup/routes/setup_route.py:70、:97é、中文這類字元會直接報錯。這支程式沒接住這個錯誤,於是一路冒到全站的錯誤處理,回給對方 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 對位元組不挑字元。app/setup/service/setup_wizard_service.py:37-41(註解);實際的判斷在 :167(先查未開通)與 :249(建帳號)之間users 表只有 uid 有唯一約束,帳號名稱只有一般索引(不擋重複),email 什麼都沒有(DEV 與出貨基線 scripts/init/02-schema.sql 都一樣)。帳號重複的檢查只在程式層做(先查有沒有同名、再寫入),這個檢查本身也有同樣的並發窗口。api/version/routes/client_config_route.py:40GLITCHTIP_API_TOKEN,不在這裡)。SENTRY_DSN(出貨預設沒設)。第一層:工具正式報告(經三人面板驗證)
verified(stamp CLAUDE-SECURITY-REVISION-3f6290400be3-dirty.json)第二層:runner 自行開檔核對(未經三人面板投票)
install.sh 的設定碼產生段、license_readonly_mw.py 豁免名單、request_context_mw.py 身分歸零、app_mw.py 標頭遮罩、jedi-iam 的使用者刪除路徑與建帳號重複檢查、jedi-common 的全站錯誤處理。verify_token 與 Flask 測試客戶端重現;既有測試 test/test_setup_wizard_one_shot.py 34 項全過。BEGIN READ ONLY … ROLLBACK):開通狀態、各公司使用者數、users 表的約束與索引。| 項目 | 值 |
|---|---|
| 掃描目標 | 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),皆未經面板 |