chundev
日期:2026-05-23
設計「使用者從瀏覽器上傳 TLS 憑證 → host 端用 systemd 跑 install 腳本」這種 feature 時,validation 必須分 5 層、每層假設前一層被繞過:① 瀏覽器檔案預檢、② server PEM 解析預覽、③ tRPC mutation 入口再驗、④ host 端 openssl modulus 配對驗(唯一能驗私鑰的層)、⑤ 安裝後 nginx 健檢 + 自動 rollback。每層擋的是不同類型的問題,不能合併。
需求:客戶 IT 從瀏覽器上傳 server 憑證 → host 端的 root daemon(例如 systemd timer 啟動的安裝腳本)寫進 nginx 的 cert 目錄、重啟 nginx。
兩個關鍵限制決定了 trust 模型:
openssl genrsa 產的,永不離開 host。瀏覽器只經手 CSR(公開物)跟簽好的憑證(公開物)。光靠單一驗證點防不住。下面拆 5 層。
| 層 | 在哪 | 檢查什麼 | 不通過 → |
|---|---|---|---|
| ① 瀏覽器檔案預檢 | client side, FileReader |
檔案 ≤ 1MB、含 -----BEGIN CERTIFICATE----- |
UI toast 報錯、state 不變、永不送 server |
| ② server PEM 預覽解析 | tRPC inspect query |
crypto.X509Certificate 解析、未過期、chain checkIssued 完整 |
預覽卡顯紅字錯誤、「安裝」按鈕保持 disabled(gated on inspect.data.valid) |
| ③ mutation 入口再驗(不信前端) | tRPC installCert mutation 第一行 |
重做 ② 的完整檢查;對選填的 chain string 也檢查 PEM header | throw TRPCError({code:"BAD_REQUEST"})、根本不寫 spool、不建 audit row |
| ④ openssl 權威配對(驗私鑰) | host install script [1/5] 步 |
openssl x509 -modulus \| md5 vs openssl rsa -modulus \| md5——cert 公鑰的 modulus 必須等於 host 端 private key 的 modulus |
print ERROR: cert 與 key 不配對、exit 1、完全不碰 nginx cert 目錄 |
| ⑤ 安裝後健檢 + 自動 rollback | host install script [5/5] 步 |
寫入後 docker compose restart nginx + curl -sk https://localhost/;2xx/3xx/401/404 = ✅,其他 = ❌ |
自動 cp .bak-<ts> 回原檔、再 restart、再 curl 確認——還原到安裝前的可用狀態 |
② 在瀏覽器的 UI 邏輯(disable 按鈕、顯紅字)對「正常使用者」有效,但對攻擊者完全無效:
disabled 拿掉POST /api/trpc/installCert,繞過整個前端所以 server-side mutation handler 必須假設「client 可能是惡意的」,把 inspect 在 mutation 入口再跑一次。這是「不信前端」的標準防禦——任何接受外部請求的 server endpoint 都要這樣做,不限於 cert 場景。
它們驗的是不同事實:
| 驗的事 | 在哪能驗 | 為什麼 |
|---|---|---|
| 「PEM 內容合法、沒過期、issuer/leaf chain 連得起來」 | server 端 (用 Node crypto) |
不需要私鑰 |
| 「這張 cert 是這台 host 先前產的 CSR 簽出來的」 | host 端 (用 openssl modulus) |
必須對比 cert 公鑰跟 host 端 server.key 的公鑰;只有 host 看得到 server.key |
舉幾個邊界例子:
私鑰從來不離開 host 是設計核心,這也順帶決定了 server 沒法做配對驗——如果 server 端能驗配對,代表 server 端拿得到私鑰,那麼任何 server-side compromise(SQL injection、RCE)都會洩漏私鑰。「server 不該有私鑰」與「server 沒法做配對」是同一個決定的兩面。
①②③④ 全通過、cert 寫入 nginx 目錄、docker compose restart nginx——nginx 還是有可能起不來。原因例如:
如果 nginx 起不來、又沒 rollback,操作者(沒 host 權限的客戶 IT)會看到網站掛掉、自己救不回來。所以 install 腳本最後一步是:寫入後 restart → curl healthcheck → 不健康就把剛備份的 .bak 還原 + 再 restart + 再 curl。寫法很短:
# [3/5] 一定先備份
BACKED_UP=0
[ -f "$CERT_DIR/fullchain.pem" ] && {
cp "$CERT_DIR/fullchain.pem" "$CERT_DIR/fullchain.pem.bak-$TS"
BACKED_UP=1
}
# [4/5] 寫入新 cert
cp "$NEW_CERT" "$CERT_DIR/fullchain.pem"
# [5/5] 重啟 + 健檢,失敗自動 rollback
CODE=000
if cc_restart_nginx; then sleep 2; CODE=$(cc_healthcheck); fi
if cc_healthcheck_ok "$CODE"; then exit 0; fi
echo "❌ nginx 健檢失敗 (HTTP $CODE) — 自動回退..." >&2
if [ "$BACKED_UP" -eq 1 ]; then
cp "$CERT_DIR/fullchain.pem.bak-$TS" "$CERT_DIR/fullchain.pem"
cc_restart_nginx; sleep 2
fi
exit 1
關鍵設計:cc_restart_nginx 包進 if,不讓 set -e 在 restart 失敗時直接 abort——abort 就跳過後面的 rollback 了,那是最糟結果(cert 換了、nginx 掛了、沒人救)。
set -e + 自動 rollback 互不友善——bash set -e 在子命令失敗時直接 abort,會跳過後面的 rollback 邏輯;可能失敗的步驟(restart、healthcheck)要包進 if cmd; then ...; fi,讓 exit code 流入 rollback 判斷而不是觸發 abort。openssl x509 -modulus man page — cert 公鑰 modulus 抓法;配 openssl rsa -modulus 做配對驗的標準寫法crypto.X509Certificate Node.js docs — 不依賴 openssl 純 Node 解析 cert 的 API(適合 ② / ③)set -e 跟 conditional — set -e 在 if / || / && context 不會 abort 的細節,寫 rollback-safe shell 必懂