包包baobaolin.com
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,到了客戶端都變成同一句話。

為什麼它可以活很久

三件事讓這個設定不容易被發現:

  1. 狀態碼是對的。因為 error_page 沒有加 =200,nginx 保留了原本的狀態碼,只換掉 body。所有以狀態碼為準的健康檢查、APM、alert 規則都正常運作。
  2. body 的形狀是對的。它是合法 JSON,有 status、有 message,前端的 parser 不會拋例外,只會安靜地顯示錯誤的內容。
  3. 成功路徑完全不受影響。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;

502503504 代表後端根本沒回應——這時候 nginx 生一個統一頁面是合理的,因為沒有東西被蓋掉。 4xx500 是後端有話要說的情況,不該攔。

改的時候三件事按順序做:

  1. sudo cp site.conf /root/nginx-backup/site.conf.$(date +%s)——備份放在 sites-enabled/ 外面
  2. sudo nginx -t——語法過了才繼續
  3. sudo systemctl reload nginx——用 reload 不用 restart,現有連線不會斷

然後把最開始那個 request 原封不動再打一次,確認拿到的是後端真正的訊息。

為什麼有人會這樣設

這個設定不是亂寫的,背後的意圖看得出來:不要把後端的錯誤細節暴露給外面。stack trace、SQL 語句、內部路徑,這些確實不該出現在公開 API 的回應裡。

問題在於它把遮蔽做在錯的那一層。nginx 看不到語意,它只看得到狀態碼,所以它沒有能力區分「不該外流的 stack trace」和「使用者需要看到的 email 已被使用」。要遮就遮在應用層——後端自己決定哪些錯誤訊息可以對外,把細節留在 log 裡,對外回一個穩定的錯誤碼。

放在 nginx,你得到的不是安全,是一個沒有人知道存在的靜音開關

如果只記得一件事

「狀態碼對」跟「回應對」是兩件事。驗證一個 API 的錯誤路徑,要把失敗的請求也打一次,並且比對 body 的內容,不只是它的格式。一個只驗成功路徑的測試,永遠不會發現這種設定存在。

修訂紀錄

  1. 首次發布