替一個 Zeabur 上的 repo 加寄信 API, 而且 repo 裡一把金鑰都不放
昨天替本站加了「寄一份盤點結果給我」的功能。從產生頁尾圖、寫環境變數到 curl 測試,整段流程走完發現:寄信功能本身十分鐘,防止它變成別人的垃圾信發射器才是重點。這篇把做法與踩到的坑寫下來。
為什麼現在很紅:每個小團隊的網站遲早要寄信:驗證碼、表單結果、通知。多數人的第一版是把 SMTP 密碼貼進 repo,然後那把密碼就在 git 歷史裡待到被撿走。
- 應用端不碰 SMTP:所有信透過內部寄信閘道送,應用只持有一組「寄件代號」和一個自己 API 的存取 token,兩者都放在部署平台的環境變數,repo 裡只有 .env.example。
- 對外的 /api/mail/send 一律要 token,再加每 IP 限流;需要公開的(例如把表單結果寄給填寫者本人)另開一個內容固定、只能寄給填寫者的路由,限流更嚴。
- 改單一環境變數要用單鍵 API,不要用會整組覆寫的 CLI;寫入前先快照、寫後比對數量。這條是 8 月底用兩個服務的變數全數清空換來的。
本篇目錄
發生了什麼
最近我在整理本站的自我盤點頁,想加一個「寄一份結果給我」的按鈕:讀者勾完 26 題,填 email,收到一封有 ✓/✗ 和原因的信。功能本身很小,但它牽涉三件我們在三起事件裡都付過學費的事:憑證放哪裡、誰能呼叫、被濫用時誰先知道。這篇文章我就把整段流程走一遍,順便講清楚每一步為什麼這樣做。
第一步:應用端不碰 SMTP
我們內部有一個寄信閘道,是一支獨立的小服務,只有它持有 Google 的應用程式密碼。應用端要寄信,是對閘道發一個 HTTP 請求,帶兩個標頭表明「我是誰」,body 是收件人、主旨、HTML 內容、寄件者顯示名稱、頁尾圖與連結。
這樣做的好處在憑證的存在形式:應用端的 repo 與環境變數裡沒有任何 SMTP 密碼,只有一組寄件代號。代號外洩,最壞的情況是有人冒用我們的名義寄信,而且閘道的 log 會標出是哪個代號寄的;SMTP 密碼外洩,最壞的情況是整個信箱被接管。
寄件代號每個應用一組,不共用。本站用自己的代號,會計系統用它自己的。閘道靠代號分辨來源,共用代號等於把 log 弄瞎。
第二步:兩個路由,兩種閘門
| 路由 | 誰能呼叫 | 內容 | 限流 |
|---|---|---|---|
| POST /api/mail/send | 帶 X-Mail-Token 的內部工具與 agent | 自訂收件人、主旨、HTML | 每 IP 每小時 60 封 |
| POST /api/mail/checklist | 任何人 | 固定格式的盤點結果,只寄給填寫者填的那一個信箱 | 每 IP 每小時 5 封、每信箱 3 封 |
第一個路由沒有 token 一律 401,token 是 32 字元以上的隨機值,只存在部署平台的環境變數與本機的 .env.local(已 gitignore)。第二個路由是公開的,所以它不接受任何自訂內容:主旨、內文、頁尾都由伺服器組,使用者能決定的只有「寄到哪個信箱」,而且一小時最多三次。
const senderK = clean(process.env.UPMAIL_SENDER_K); // 寄件代號
const senderV = clean(process.env.UPMAIL_SENDER_V); // 代號對應的驗證值
if (!senderK || !senderV) throw new Error("尚未設定寄送服務");
// 收件人最多 10 個、主旨 200 字、內容 200KB;超過就 400,不進閘道第三步:環境變數用單鍵寫,先快照再比對
這一步是整篇最重要的坑。8 月底我們在另一個專案用 CLI 的 variable update -k KEY=VALUE 改一個密碼,結果兩個服務的所有環境變數被清空,連指定的那個 key 都沒留下。原因是那個指令底層是「用這組資料取代整組變數」,主控台又沒有變數歷史可回復。
所以現在改變數一律走平台的單鍵 API(建立、更新、刪除各一個 mutation),流程固定三拍:
- 先把該服務全部變數讀出來存成快照檔
- 逐鍵寫入,已存在就單鍵更新、不存在就單鍵建立
- 寫完再讀一次,比對 key 數量沒少、新值讀回一致
我們把這段寫成腳本,這次替本站寫入 8 個變數(閘道位址、寄件代號與驗證值、頁尾圖、頁尾連結、寄件者名稱、API token、站台網址),讀回 16 個,原本的 8 個一個沒少。
snapshot: …/blog-20260917-212524.json (16 variables) UPMAIL_SENDER_K: updated verified: 16 -> 16 variables, none lost; MAIL_API_TOKEN …6bb6a NOTE: redeploy the service for new variables to take effect
第四步:頁尾圖放共用雲端硬碟,走無金鑰代理上傳
信件頁尾要一張圖,圖要有一個「任何人有連結可讀」的網址。我們不想為了一張圖在 repo 放 Google 金鑰,所以走另一支無金鑰的 Drive 代理上傳到共用雲端硬碟。
這裡踩到兩個坑:新的服務帳戶沒有個人雲端硬碟的儲存配額,只能傳到共用雲端硬碟;而共用雲端硬碟預設不允許非成員存取,代理的服務帳戶設不了「任何人可讀」,最後要用自己的帳號補一筆權限。過渡期先沿用另一個專案的頁尾圖,功能不受影響。
第五步:curl 測試,三種錯誤各代表什麼
curl -sS -X POST "https://<你的網域>/api/mail/send" \
-H "Content-Type: application/json" -H "X-Mail-Token: ***" \
-d '{"to":"[email protected]","subject":"測試","content":"<p>hello</p>"}'
# 200 {"success":true} → 閘道憑證、token、路由都正常
# 401 → token 不一致(本機 .env.local 與平台不同)
# 404 → 服務還沒重新部署帶新路由
# 502 → 閘道拒絕:寄件代號或驗證值錯本機用 .env.local 起站測,第一封信 200;沒帶 token 401;正式網域 404,因為線上還是舊 build。這個 404 是預期中的:環境變數要重新部署才生效,程式碼也要。
- 2026-08-29CLI 改單一變數,清空兩個服務全部環境變數事故紀錄;主控台手動補回 23 個變數。
- 2026-09-17 21:0x寫 lib/mail.ts 與兩個路由,本機測試 200/401本站 commit 與 curl 輸出。
- 2026-09-17 21:1x單鍵寫入 8 個變數,讀回 16 個無遺失腳本比對輸出。
- 2026-09-17 21:5x頁尾圖上傳共用雲端硬碟成功,但設不了公開權限代理回應 public:false。
這次外洩了什麼
| 服務 | 憑證類型 | 用途 | 存在形式 | 放在哪 | 權限範圍 | 狀態 |
|---|---|---|---|---|---|---|
| 內部寄信閘道 | 寄件代號+驗證值(兩個 HTTP 標頭) | 應用端向閘道表明身分以寄信 | 明文環境變數(UPMAIL_SENDER_K/V) | 部署平台環境變數+本機 .env.local(gitignored);repo 只有 .env.example 的空值 | 冒用我們名義寄信;閘道 log 可追到代號 | 未外洩;每個應用一組代號 |
| 本站 /api/mail/send | API token(X-Mail-Token) | 內部工具與 agent 呼叫本站寄信 | 32 字元以上隨機字串,明文環境變數 | 同上 | 用本站名義寄任意內容給任意人,每小時 60 封 | 未外洩;對話與 log 只顯示末 5 碼 |
| Google SMTP 應用程式密碼 | 密碼 | 只有閘道持有,實際發信 | 閘道服務的環境變數 | 不在任何應用端 | 整個寄件信箱 | 本文範圍外;應用端永遠碰不到 |
證據等級
跟我們的防暴筆記對照
Crusader 防暴筆記裡那支殭屍網路掃的 542 條路徑,前幾名就是 .env、.git、各種設定檔,目標是 AWS 金鑰、SMTP 憑證、Google 金鑰。這篇的整個設計就是讓那份掃描清單在我們這裡一無所獲:repo 裡沒有密碼、應用端沒有 SMTP、頁尾圖上傳沒有 Google 金鑰。剩下的兩個明文變數(寄件代號、API token)外洩時損害有上限、有 log、能單鍵輪替。
攻擊者 vs 防守者
- 先掃 /api/mail、/api/send、/mail/send 這類路徑,找沒有驗證的。
- 找到就拿來寄釣魚信:你的網域、你的信譽、他的連結。
- 順便 git clone 你的公開 repo 翻歷史,SMTP 密碼通常在第一個 commit。
這篇提到的項目要花多少錢
| 項目 | 用量 | 單價 | 期間 | US$ | NT$ | 舉證 |
|---|---|---|---|---|---|---|
| 寄信閘道(既有小服務) | 本站每天數封 | 共用,邊際成本趨近 0 | 每月 | 0.00 | 0 | 既有服務,未增加執行個體 |
| 本站 Cloud Run/容器 | 兩個路由,無新依賴 | 隨既有服務計費 | 每月 | 0.00 | 0 | 無新增服務 |
| 開發與測試 估 | 約 2 小時(含腳本化) | 顧問時薪 NT$3,000–4,500 | 一次 | 190280.00 | 6,088,960 | 工時估算 |
| 若端點沒有 token 被拿去寄釣魚信 | 網域信譽、被列黑名單 | — | — | — | — | 無法事前量化 |
| 合計(僅可證明列) | 0.00 | 0 | ||||
重點學習
- 01憑證的存在形式同一個功能,應用端持有 SMTP 密碼和只持有寄件代號,外洩後果差一個數量級。
- 02X-Mail-Token 閘門沒有 token 的寄信端點就是免費的釣魚信發射器。
- 03固定內容的公開路由要公開就不讓使用者決定內容與收件人以外的任何東西,並且限流更嚴。
- 04每 IP 限流(記憶體版)單實例夠用;多實例要改 Redis。知道它的邊界比有沒有更重要。
- 05單鍵更新環境變數整組覆寫會清空其他變數,8 月底的事故。先快照、寫入、讀回比對數量。
- 06.env.examplerepo 裡唯一該出現的憑證檔:只有 key 沒有 value。
- 07寄件代號一應用一組閘道 log 靠它分辨來源;共用代號等於把 log 弄瞎。
- 08共用雲端硬碟的儲存配額新服務帳戶沒有個人雲端硬碟配額,上傳只能到共用雲端硬碟。
- 09public:false共用雲端硬碟預設不讓非成員存取,代理設不了「任何人可讀」,要用自己的帳號補。
- 10404 是預期的環境變數和程式碼都要重新部署才生效;測試順序是本機 200 → 線上 404 → 部署後 200。
今天就可以來試試
全部唯讀,不改任何設定;做完你會知道自己有沒有同樣的破口。
- 翻 repo 歷史找 SMTP 密碼與 API key
git log --all -p -S'SMTP_PASS' --name-only | head git log --all -p -iS'password' -- '*.env*' | head看到什麼代表什麼:任何命中都當已外洩:進過 git 歷史就拿不回來,要輪替不是刪檔。 - 找你網站上沒有驗證的寄信端點
grep -rn "mail\|smtp\|sendgrid\|nodemailer" --include='*.ts' --include='*.js' --include='*.php' -l . | grep -v node_modules看到什麼代表什麼:每個檔案問一句:呼叫它需要什麼?答案是「什麼都不用」的就是今天要補閘門的。 - 對自己的寄信端點不帶 token 打一次
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://<你的網域>/api/mail/send -H 'Content-Type: application/json' -d '{"to":"[email protected]","subject":"s","content":"c"}'看到什麼代表什麼:401 或 404 都可以;200 代表任何人都能用你的名義寄信。 - 確認部署平台上寄信相關變數的數量
(用平台的讀取 API 或主控台列出變數)看到什麼代表什麼:先把數量記下來。下次改任何一個變數後,數量只能多不能少。 - 查 .env.example 是不是只有 key 沒有 value
grep -E '=\S+' .env.example看到什麼代表什麼:有輸出就代表有值被 commit 進去了,檢查是不是真的密碼。
來源
本文為公開報導與官方公告的整理與拆解,非第一手鑑識;證據等級依來源可信度標註。
M2 身分與金鑰 · M0 駭客經濟學 · 想在自己的環境做一次同樣的盤點,從 自我盤點 開始。
每篇新文章一封:三點摘要、攻擊者/防守者視角、今天能做的一件事。或先 加入會員 記錄你完成的任務。