安全中介層全家桶:CORS / CSRF / JWT / rate limit / binder

安全靠一整套 middleware,單一個擋不住。gortex 把 body 上限、CORS、CSRF、JWT、rate limit、log 遮蔽整合進框架層,業務端不必每次重配。這篇逐個拆。

更正(2026-11-02 18:45):Binder 的 1 MiB 改為限制解碼讀取量,multipart 的 32 MiB 改為記憶體門檻;JWT 範例改用實際原始碼,接受其他 HMAC 演算法不再寫成可偽造簽章的風險,鎖死 HS256 改為對齊 RFC 8725 一把 key 一種演算法;rate limit 改為未設 TrustedProxies 時預設忽略轉發標頭,在 proxy 後會共用同一個 bucket;logger 補上先截斷再遮蔽的限制,並刪去 header 與 query 也會遮蔽的說法。修正建議:Codex(gpt-6-astra),Claude 驗證。

前置閱讀:〈net/http 與 middleware 模式〉

Binder:body 上限

最直接的不可信輸入就是 request body。Binder 把 JSON body 卡在 1 MiB(DefaultMaxJSONBodyBytes = 1 << 20),而且用 http.MaxBytesReader 在讀的當下就擋,解碼讀到超過上限就報錯,不會先把整包灌進記憶體:

const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB
limited := http.MaxBytesReader(c.Response(), originalBody, limit)
// 解碼 limited;讀到超過 limit 會在這裡失敗,而不是 OOM

要注意 Binder 只呼叫一次 Decode,限制的是解碼讀進來的量:第一個 JSON 值讀完就停,後面就算還跟著超過 1 MiB 的空白也不會報錯。要保證整個 body 的上限,得在同一個受限 reader 上確認 JSON 值後面只剩空白與 EOF。

multipart 的 32 MiB(DefaultMaxMultipartBytes)是記憶體門檻,超過的部分寫進暫存檔,請求不會因此被拒;要限制上傳總量,得另外包 http.MaxBytesReader 或由上游限制。把不可信輸入卡在邊界是最基本的一招,總量上限補齊了才擋得住 oversize-body 的 DoS。

CORS / CSRF

CORS:CORSHandlerWithConfig 依 CORSConfig 設 Access-Control-Allow-Origin / -Methods / -Credentials 等標頭,並補 Vary。注意預設 AllowOrigins 是 ["*"],方便開發,上線該收緊成白名單。能在框架層改一次預設、所有服務一致,正是〈為什麼造一個 kitchen-sink 框架〉講的依賴集中管理。

CSRF:用 double-submit token。產一個隨機 token(預設 32 bytes)放 cookie,client 必須在 header 回傳同一個;對不上是 ErrCSRFTokenMismatch、沒帶是 ErrCSRFTokenMissing,都回 403。cookie 的 SameSite 預設 Lax,安全路由可用 Skipper 跳過。

JWT:32-byte、HS256、typ

JWT 這塊有三道刻意的硬規則:

  1. secret ≥ 32 bytes:NewJWTService 對短於 MinJWTSecretBytes(32,對齊 SHA-256 輸出長度)的 secret 直接回 ErrJWTSecretTooShort;config.Validate 在載入設定時就擋。短 key 會弱化 HMAC。
  2. HS256-only:驗證時 keyFuncHS256 檢查 token.Method == jwt.SigningMethodHS256,連 HS384/HS512 都拒。
func (s *JWTService) keyFuncHS256(token *jwt.Token) (any, error) {
    if token.Method != jwt.SigningMethodHS256 { // 只接受 HS256
        return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
    }
    return []byte(s.secretKey), nil
}

為什麼這樣做:只用 *jwt.SigningMethodHMAC 做斷言會連帶接受 HS384/HS512。這些演算法仍要用同一把 secret 重算 MAC,單改 alg 偽造不了簽章;經典的 algorithm-confusion 攻擊是接受 none,或把 RSA public key 當成 HMAC secret。RFC 8725 要求一把 key 只用一種演算法,鎖死 HS256 就是照這條做。 3. typ claim:token 帶 typ(access / refresh)。把 refresh token 拿去當 access 用會回 ErrInvalidTokenType;舊版沒有 typ 的 token 一律拒收。

Rate limit 與 TrustedProxies

rate limiter 用 token bucket 對每個 IP 做 rate limit,背景 goroutine 定期清過期 entry,並送出 X-RateLimit-Limit / -Remaining / -Reset,超限時加 Retry-After。

但「按 IP 做 rate limit」的前提是拿到對的 IP。gortex 預設不採信轉發標頭,client 塞假的 X-Forwarded-For 換不了 bucket,key 一律用 RemoteAddr(去掉 port),直連部署這樣就對了。服務放在 reverse proxy 後面時,RemoteAddr 全是 proxy 的 IP,所有使用者會共用同一個 bucket,這時要配 TrustedProxies(一組 CIDR):只有來自信任 proxy 的請求,X-Forwarded-For / X-Real-IP 才採信,proxy 也要清掉或覆寫 client 自帶的轉發標頭。自訂 KeyFunc 不走這套邏輯,要另外檢查。

log 遮蔽敏感資料

log 會不小心把密碼、token 印出來。gortex 的 logger 在開了 LogRequestBody(預設關閉)時,用 BodyRedactor 把請求 body 裡的敏感欄位遮掉(預設 DefaultBodyRedactor)。header 與 query 沒有遮蔽功能,預設記的欄位是 method、path、status、latency、IP 與 User-Agent,query 本身不記。

目前有個限制:logger 先把 body 截到 1 KB(BodyLogLimit 預設 1024)再遮蔽,截斷後的 body 若不再是合法 JSON,就原樣以文字寫進 log,落在這段前綴裡的敏感欄位會是明文。修好之前,建議 LogRequestBody 保持關閉。

這裡有個要直說的限制:LogResponseBody 是 no-op,原始碼註解明寫 deprecated、不生效,框架不支援記錄回應 body。4xx / 5xx 則是用〈零分配的秘密:Context pool 與 smartParams〉那個 tracked responseWriter 拿到的真實狀態碼,分別記在 Warn / Error。會記什麼、不會記什麼,講清楚比假裝全能好。

補充筆記

  • Binder 用 http.MaxBytesReader 限制 JSON 解碼最多讀 1 MiB;multipart 的 32 MiB 是記憶體門檻,超過寫進暫存檔,總量要另外限制。
  • CORS 可設允許來源(預設 *,上線應收緊);CSRF 用 double-submit token(預設 32 bytes、SameSite Lax),缺 / 錯 → 403。
  • JWT:secret ≥ 32 bytes、HS256-only(一把 key 只用一種演算法)、typ 分 access / refresh,舊的無 typ token 一律拒。
  • rate limit 發 X-RateLimit-* / Retry-After;預設不採信轉發標頭,放在 reverse proxy 後面要配 TrustedProxies,否則所有使用者共用 proxy IP 的 bucket。
  • 開 LogRequestBody 時遮蔽請求 body 的敏感欄位,但先截到 1 KB 再遮蔽,截斷後解析失敗的 body 會以明文記錄,修好前建議保持關閉;header 與 query 沒有遮蔽;LogResponseBody 是 no-op,4xx / 5xx 用 tracked writer 的真實狀態記 Warn / Error。

原始碼:yshengliao/gortex。


想法與技術判斷出自 Sheng,和 Claude 一起起草 · 環境 Go 1.25(gortex go.mod)· 本系列為事後回填整理 · 列入 20260613 blog 翻新計劃,新漆未乾。