寄信功能十分鐘就會動,難的是不讓別人也能用
本站新增「寄一份盤點結果給我」:應用端一把金鑰都不放,信交給內部閘道送,對外路由一律 token 加限流。外加一個把整組環境變數清空換來的教訓。
- 應用端不碰 SMTP:所有信透過內部寄信閘道送,應用只持有一組「寄件代號」和一個自己 API 的存取 token,兩者都放在部署平台的環境變數,repo 裡只有 .env.example。
- 對外的 /api/mail/send 一律要 token,再加每 IP 限流;需要公開的(例如把表單結果寄給填寫者本人)另開一個內容固定、只能寄給填寫者的路由,限流更嚴。
- 改單一環境變數要用單鍵 API,不要用會整組覆寫的 CLI;寫入前先快照、寫後比對數量。這條是 8 月底用兩個服務的變數全數清空換來的。
加一個寄信按鈕,難的不是寄信
最近我在整理本站的自我盤點頁,想加一個「寄一份結果給我」的按鈕:讀者勾完 26 題、填 email,收到一封有 ✓/✗ 和原因的信。功能本身很小,小到十分鐘就寫得完。
但它牽涉三件我們在三起事件裡都付過學費的事:憑證放哪裡、誰能呼叫、被濫用時誰先知道。寫完之後回頭看,真正花時間的全在這三題上——寄信是十分鐘,防止它變成別人的垃圾信發射器才是整個下午。
先講清楚這篇的性質:沒有入侵、沒有外洩,這是一份自家實作紀錄。時間軸上唯一真正的事故發生在三週前,而且跟寄信一點關係都沒有。
- 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 標頭) | 應用端向閘道表明身分以寄信 | 明文環境變數 | 部署平台環境變數+本機 .env.local(gitignored);repo 只有 .env.example 的空值 | 冒用我們名義寄信;閘道 log 可追到代號 | 未外洩;每個應用一組代號 |
| 本站 /api/mail/send | API token(X-Mail-Token) | 內部工具與 agent 呼叫本站寄信 | 32 字元以上隨機字串,明文環境變數 | 同上 | 用本站名義寄任意內容給任意人,每小時 60 封 | 未外洩;對話與 log 只顯示末 5 碼 |
| Google SMTP 應用程式密碼 | 密碼 | 只有閘道持有,實際發信 | 閘道服務的環境變數 | 不在任何應用端 | 整個寄件信箱 | 本文範圍外;應用端永遠碰不到 |
三列裡最重要的是第三列的「放在哪」:不在任何應用端。這一格決定了上面兩列外洩時是麻煩,還是災難。
沒有告警,三個坑都是做的時候自己撞上的
這篇沒有偵測故事。三個值得寫下來的問題,都是實作過程中撞出來的:整組覆寫會清空環境變數、新的服務帳戶沒有個人雲端硬碟配額、線上回 404 是因為還沒重新部署。
把它們拆成證據,前兩件有紀錄可查,另外兩件一個是推論、一個是缺口。
證據等級最低的那兩條,剛好就是這套設計最脆弱的地方:限流的邊界在哪,我們其實是用猜的。
這個端點還沒被打過,但他要什麼很清楚
下面兩個階段都沒有發生在本站,所以兩格都標問號。它們不是憑空想的:第一步那份路徑清單,跟 Crusader 那支殭屍網路掃的 542 條路徑是同一種東西。
Crusader 掃的那 542 條路徑,前幾名就是 .env、.git、各種設定檔,目標是 AWS 金鑰、SMTP 憑證、Google 金鑰。這篇的整個設計,就是讓那份掃描清單在我們這裡一無所獲:repo 裡沒有密碼、應用端沒有 SMTP、頁尾圖上傳也沒有 Google 金鑰。剩下的兩個明文變數——寄件代號與 API token——外洩時損害有上限、有 log、能單鍵輪替。
五個步驟,每一步都在回答「外洩了會怎樣」
動手前先問三件事:憑證放哪裡、誰能呼叫、被濫用時誰先知道。下面五步就是這三個問題的答案,順序不能顛倒。
第一步:應用端不碰 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)。第二個路由是公開的,所以它不接受任何自訂內容:主旨、內文、頁尾都由伺服器組,使用者能決定的只有「寄到哪個信箱」,而且一小時最多三次。
// 寄件代號與驗證值都從部署平台的環境變數讀,程式碼與 repo 裡沒有任何值
const sender = readSenderFromEnv();
if (!sender) 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) verified: 16 -> 16 variables, none lost 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 是預期中的——環境變數要重新部署才生效,程式碼也要。知道每一種回應代表什麼,比測到 200 更重要。
哪裡做對,哪裡做錯
做對的四件事有一個共同點:都是在「假設它會外洩」的前提下決定的。應用端從第一版就不持有 SMTP 密碼,repo 裡只有 .env.example;公開路由不接受任何自訂內容,使用者能決定的只有寄到哪個信箱;寄件代號每個應用一組,閘道 log 分得出是誰在寄;改環境變數先快照、後比對,這次寫入 8 個、讀回 16 個,原本的一個沒少。
做錯與待補的四件,共同點也很一致:我們知道邊界在哪裡,但沒有量過它。每 IP 限流放在單一實例的記憶體裡,多實例部署時會失效;閘道本身每天能寄多少、被封鎖的門檻在哪,到現在沒有量測過;頁尾圖的公開權限設不了,最後要用自己的帳號補一筆,代理的邊界沒有事先確認。最後一件最值得記:單鍵寫入這條規矩,是 8 月底清空兩個服務的變數之後才有的——它不是設計出來的,是撞出來的。
如何避免
今天就能做完的有三件,全部唯讀或無害:grep repo 歷史有沒有 SMTP 密碼或 API key,命中就當已外洩處理;對自己的寄信端點不帶 token 打一次,看它回 200 還是 401;把部署平台上寄信相關變數的數量記下來,當作下次改動的對照基準。
這幾週要把做法固定成規矩:任何對外寄信端點都要 token,公開的那一個只能寄固定內容給填寫者本人;改環境變數一律走單鍵 API,先快照、逐鍵寫入、讀回比對數量;寄件代號每個應用一組、不共用,閘道 log 才分得出來源。
這一季之後要補的是上面那些「沒量過」的邊界:限流改成集中式,因為多實例部署時記憶體版的每 IP 限流一定會失效;量出閘道端的每日總量上限與被封鎖門檻,不要等第一次退信才知道;並把「應用端永遠不持有 SMTP 密碼」寫成預設值,新服務一律接閘道,不各自拿一把應用程式密碼。
這篇提到的項目要花多少錢
| 項目 | 用量 | 單價 | 期間 | 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寄信功能本身十分鐘,防止它變成別人的垃圾信發射器才是工作。
- 02同一個功能,應用端持有 SMTP 密碼和只持有寄件代號,外洩後果差一個數量級。
- 03要公開的端點,就不讓使用者決定「收件人是誰」以外的任何東西,而且限流更嚴。
- 04改單一環境變數不要用會整組覆寫的 CLI。先快照、寫入、讀回比對數量,三拍缺一不可。
- 05進過 git 歷史的密碼拿不回來。處理方式是輪替,不是刪檔。
- 06知道限流的邊界比有沒有限流更重要:單實例的記憶體限流,多實例下會失效。
- 07線上回 404 是預期的:環境變數和程式碼都要重新部署才生效。
- 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-count-baseline-o2n.txt」放在寫入腳本旁邊。下次改任何一個變數後,數量只能多不能少。 - 查 .env.example 是不是只有 key 沒有 value
grep -E '=\S+' .env.example看到什麼代表什麼:有輸出就代表有值被 commit 進去了,檢查是不是真的密碼。
來源
每篇新文章一封:三點摘要、關鍵字定義、今天能做的一件事。
延伸閱讀
Node.js 一次修了 10 個漏洞,升級的關鍵不是版本號,是重新部署
2026 年 10 月 21 日,Node.js 同時發布 22.23.4、24.22.1、26.12.1,最重的是 Brotli 解壓縮器的越界寫入,官方標題直接寫「可遠端執行程式碼」。三條線都有修,Node 20 已經停止支援、不會修。這篇拆開哪些伺服器真的在射程內、為什麼在 package.json 寫了 24.x 還不等於已經修好,以及我們自己做了什麼、還沒做什麼。
六台主機共用一把沒上鎖的鑰匙,而通往它們的門其實一直開著
維運設計是『所有人先進跳板機、再進其他主機』,方向沒有錯。錯的是網路層從沒強制這條路,而那把讓跳板機免密碼開全叢集的 root 私鑰,沒有 passphrase、裸躺在磁碟上。這篇是作者分析:跳板機與 SSH 金鑰信任最常見的幾個誤區,以及它們為什麼看起來很安全。
我們寫了一道關卡防自己,第二次部署它就亮了紅燈
沒有攻擊、沒有外洩、沒有帳單,這是一份設定稽核紀錄:一支只該讀寫試算表的 GCP:Service Account,在專案層是 Owner。拿掉只要一行,拿掉前要先查什麼?