---
title: U3 檢查結果：首次安裝精靈＋版本資訊（登入前就打得到）
---

# U3 檢查結果：首次安裝精靈＋版本資訊（登入前就打得到）

> 檢查日期 2026-09-25｜對應卡片 CM-2155（母卡 CM-2152）｜檢查範圍 9 個檔案 930 行｜工具 run ID `wf_7cfdaa3f-a72`

## 🔴 一句話結論

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

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

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

## 這一棒在檢查什麼

一般的功能都要先登入，這一棒的 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 | 版本網址登記 |

## 掃到什麼（總覽）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| 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`）→ 才開始建。已開通時：

- 設定碼檔已清空 → 在第一步就 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 裡的密碼欄位也有遮罩。✅

## 各條詳細

### 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 設定），部署文件補一句。

## 這份結果可信到什麼程度

**第一層：工具正式報告（經三人面板驗證）**

- 驗證章：`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 的並發窗口是讀碼＋查約束推論，沒有實際並發打兩個請求。

## 執行概況

| 項目 | 值 |
|---|---|
| 掃描目標 | 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），皆未經面板 |
