- date
- entry
- 004
- topic
- infrastructure
- rev
- —
nginx 的 error_page 會把錯誤 body 換掉:一個回 500、body 卻寫著 ok 的 API
這次狀態碼是對的,說謊的是 body。前端拿到 500 配一句「Request processed」,所有靠讀訊息的錯誤處理同時失效——而做這件事的,是 nginx 設定裡的兩行。
一支手機 app 的註冊功能壞了。App 端的 log 抄下來是這樣(網域與 email 已改):
[API Request] POST https://api.example.com/api/v1/customer-accounts/register
[API Params] {company_name: ..., email: someone@example.com, ...}
[ErrorUtils] Status: 500
[ErrorUtils] Response: {"status": "ok", "message": "Request processed"}
這兩行合在一起是矛盾的:狀態碼說失敗,body 說成功。前端的錯誤處理是照 body 的
message 顯示給使用者的,所以使用者看到的畫面是一句沒有意義的英文,而不是「這個 email 已被註冊」。
後端 log 裡什麼都正常——它確實回了一個有內容的 500。body 是在後端與手機之間被換掉的。
做這件事的是兩行 nginx 設定
SSH 進那台機器、打開對應 domain 的 site config,兇手在最下面:
proxy_intercept_errors on;
error_page 400 401 403 404 500 502 503 504 /custom_error.html;
location = /custom_error.html {
internal;
return 200 "{\"status\": \"ok\", \"message\": \"Request processed\"}";
}
proxy_intercept_errors on 的意思是:上游回的錯誤狀態碼,交給 nginx 自己處理。
單獨用它沒事,nginx 會回自己的預設錯誤頁。危險的是配上第二行——把八個狀態碼全部導到同一個
location,而那個 location 回的是一段寫死的 JSON。
於是後端無論回 {"error":"email already registered"} 還是一段 GORM 的
duplicate key value violates unique constraint,到了客戶端都變成同一句話。
為什麼它可以活很久
三件事讓這個設定不容易被發現:
-
狀態碼是對的。因為
error_page沒有加=200,nginx 保留了原本的狀態碼,只換掉 body。所有以狀態碼為準的健康檢查、APM、alert 規則都正常運作。 -
body 的形狀是對的。它是合法 JSON,有
status、有message,前端的 parser 不會拋例外,只會安靜地顯示錯誤的內容。 -
成功路徑完全不受影響。2xx 不會進
error_page,所以任何「跑一次看看」的驗證都會通過。只有失敗的請求會撒謊,而失敗的請求平常沒人在看。
會被監控攔下來的壞,都不是真正貴的那種。
這跟 CloudFront 把 404 蓋成 200 首頁 是同一個家族的問題:中間層基於某個當下合理的理由改寫了回應,而改寫的痕跡不會出現在任何一邊的 log 裡。
怎麼在五分鐘內確認是哪一層做的
從外往內剝,每剝一層就重打同一個請求。先看流量有沒有經過 CDN:
dig +short api.example.com
# 回 CDN 的 IP → 有邊緣層,往下多剝一層
# 回自家 IP → 直連 origin,跳過下一步
有 CDN 的話,用 --resolve 把網域釘到 origin IP,繞過邊緣直打來源站:
curl -ki --resolve "api.example.com:443:203.0.113.10" \
https://api.example.com/api/v1/customer-accounts/register \
-X POST -H 'Content-Type: application/json' -d '{}'
比對兩者。body 一樣 → 不是 CDN 做的,繼續往內。接著登入機器,直接打後端的 port,跳過 nginx:
curl -i http://localhost:8007/api/v1/customer-accounts/register \
-X POST -H 'Content-Type: application/json' -d '{}'
這一步是決定性的。如果 localhost 這裡拿到的是有內容的錯誤訊息,而從外面打拿到的是那句
Request processed,那麼改寫就發生在 nginx,範圍縮到一個檔案。
看 sites-enabled/ 的時候注意副檔名。用
cp foo.conf foo.conf.bak 留的備份,如果那個目錄的
include 是 * 而不是 *.conf,備份檔會被一起載入,變成兩份設定同時生效。備份要放到目錄外,或至少加時間戳並確認 include 的樣式。
改法
最小的改動是把那一行的狀態碼清單砍到只剩基礎設施層的錯誤:
error_page 502 503 504 /custom_error.html;
502/503/504 代表後端根本沒回應——這時候 nginx 生一個統一頁面是合理的,因為沒有東西被蓋掉。
4xx 和 500 是後端有話要說的情況,不該攔。
改的時候三件事按順序做:
sudo cp site.conf /root/nginx-backup/site.conf.$(date +%s)——備份放在sites-enabled/外面sudo nginx -t——語法過了才繼續sudo systemctl reload nginx——用 reload 不用 restart,現有連線不會斷
然後把最開始那個 request 原封不動再打一次,確認拿到的是後端真正的訊息。
為什麼有人會這樣設
這個設定不是亂寫的,背後的意圖看得出來:不要把後端的錯誤細節暴露給外面。stack trace、SQL 語句、內部路徑,這些確實不該出現在公開 API 的回應裡。
問題在於它把遮蔽做在錯的那一層。nginx 看不到語意,它只看得到狀態碼,所以它沒有能力區分「不該外流的 stack trace」和「使用者需要看到的 email 已被使用」。要遮就遮在應用層——後端自己決定哪些錯誤訊息可以對外,把細節留在 log 裡,對外回一個穩定的錯誤碼。
放在 nginx,你得到的不是安全,是一個沒有人知道存在的靜音開關。
如果只記得一件事
「狀態碼對」跟「回應對」是兩件事。驗證一個 API 的錯誤路徑,要把失敗的請求也打一次,並且比對 body 的內容,不只是它的格式。一個只驗成功路徑的測試,永遠不會發現這種設定存在。
修訂紀錄
- 首次發布