The Security Middleware Suite: CORS / CSRF / JWT / Rate Limit / Binder

Gortex handles body caps, CORS, CSRF, JWT, rate limiting, and log redaction at the framework layer. Keeping those controls together avoids configuring a different security baseline in every service.

Correction (2 November 2026, 18:45 Taipei): the Binder’s 1 MiB is now described as a limit on what decoding reads, and the multipart 32 MiB as a memory threshold; the JWT example now uses the actual source, and accepting HS384/HS512 is no longer called a forgery risk; pinning HS256 now follows the one-key-one-algorithm rule; rate limiting now says forwarding headers are ignored by default without TrustedProxies, so users behind a proxy share one bucket; the logger section now states that the body is truncated before redaction, and the claim that headers and query strings are redacted has been removed. Suggested by Codex (gpt-6-astra), checked by Claude.

Prerequisite: net/http and the Middleware Pattern

Binder: body caps

The most direct untrusted input is the request body. The Binder caps a JSON body at 1 MiB (DefaultMaxJSONBodyBytes = 1 << 20) and enforces it with http.MaxBytesReader as it reads, so decoding fails once it has read past the limit rather than buffering the body into memory first:

const DefaultMaxJSONBodyBytes int64 = 1 << 20 // 1 MiB
limited := http.MaxBytesReader(c.Response(), originalBody, limit)
// decode `limited`; reading past the limit fails here instead of OOMing

Note that the Binder calls Decode once, so the limit applies to what decoding reads: it stops after the first JSON value, and more than 1 MiB of trailing whitespace after it does not raise an error. To guarantee a cap on the whole body, on that same limited reader, confirm that only whitespace and EOF follow the value.

The multipart 32 MiB (DefaultMaxMultipartBytes) is a memory threshold: anything beyond it spills to temporary files, and the request is not rejected. Capping the total upload needs an extra http.MaxBytesReader or a limit upstream. Bounding untrusted input at the boundary is the most basic move there is, but it only stops oversize-body DoS once the total is capped too.

CORS / CSRF

CORS: CORSHandlerWithConfig sets Access-Control-Allow-Origin / -Methods / -Credentials and friends from a CORSConfig, and adds Vary. Note the default AllowOrigins is ["*"] — handy in development, but tighten it to a whitelist for production. Changing that default once at the framework layer for every service is exactly the dependency consolidation from Why Build a Kitchen-Sink Framework.

CSRF: a double-submit token. A random token (32 bytes by default) goes in a cookie, and the client must echo it back in a header; a mismatch is ErrCSRFTokenMismatch, a missing one is ErrCSRFTokenMissing, both 403. The cookie’s SameSite defaults to Lax, and a Skipper can bypass safe routes.

JWT: 32-byte, HS256, typ

The JWT layer has three deliberate hard rules:

  1. secret ≥ 32 bytes: NewJWTService rejects any secret shorter than MinJWTSecretBytes (32, matching the SHA-256 output length) with ErrJWTSecretTooShort, and config.Validate enforces it at config-load time. A short key weakens the HMAC.
  2. HS256 only: on verify, keyFuncHS256 checks token.Method == jwt.SigningMethodHS256, rejecting even HS384/HS512.
func (s *JWTService) keyFuncHS256(token *jwt.Token) (any, error) {
    if token.Method != jwt.SigningMethodHS256 { // accept HS256 only
        return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
    }
    return []byte(s.secretKey), nil
}

Why it is done this way: asserting on *jwt.SigningMethodHMAC alone would also accept HS384/HS512. Those still recompute the MAC with the same secret, so changing alg alone cannot forge a signature; the classic algorithm-confusion attacks are accepting none or using an RSA public key as an HMAC secret. RFC 8725 requires each key to be used with exactly one algorithm, and pinning HS256 follows that rule. 3. typ claim: tokens carry a typ (access / refresh). Passing a refresh token where an access token is expected returns ErrInvalidTokenType, and legacy tokens with no typ are rejected outright.

Rate limiting and TrustedProxies

The rate limiter is a per-IP token bucket with a background goroutine pruning expired entries, and it emits X-RateLimit-Limit / -Remaining / -Reset, plus Retry-After when a request is throttled.

But per-IP limiting depends on getting the right IP. By default gortex ignores forwarding headers, so a forged X-Forwarded-For cannot move a client into a new bucket; the key is always RemoteAddr with the port stripped, which is correct for a direct deployment. Behind a reverse proxy, though, RemoteAddr is always the proxy, and every user shares one bucket. That is when you set TrustedProxies (a set of CIDRs): X-Forwarded-For / X-Real-IP are honoured only for requests from a trusted proxy, and the proxy itself must strip or overwrite any forwarding headers the client sent. A custom KeyFunc bypasses this logic and needs its own review.

Log redaction

Logs are where passwords and tokens accidentally end up. When LogRequestBody is on (it is off by default), Gortex’s logger uses a BodyRedactor to mask sensitive fields in the request body (defaulting to DefaultBodyRedactor). There is no redaction for headers or query strings; the default fields are method, path, status, latency, IP and User-Agent, and the query string is not logged.

There is a current limitation: the logger truncates the body to 1 KB (BodyLogLimit, default 1024) before redaction, and a truncated body that no longer parses as JSON is logged as raw text, so a sensitive field inside that prefix is logged in plain text. Until this is fixed, keep LogRequestBody off.

There’s a limitation worth stating plainly: LogResponseBody is a no-op — the source comment says it’s deprecated and has no effect; the framework doesn’t support logging response bodies. 4xx / 5xx are logged at Warn / Error using the real status code from the tracked responseWriter in The Zero-Allocation Secret: Context Pool and smartParams. Being clear about what is and isn’t logged beats pretending to do everything.

Notes

  • The Binder uses http.MaxBytesReader to limit JSON decoding to 1 MiB; the multipart 32 MiB is a memory threshold, and anything beyond it spills to temporary files, so the total needs its own cap.
  • CORS allows configurable origins (default *, tighten for production); CSRF uses a double-submit token (32 bytes, SameSite Lax by default), with missing / mismatched → 403.
  • JWT: secret ≥ 32 bytes, HS256-only (one key, one algorithm), a typ claim splitting access / refresh, and legacy no-typ tokens rejected.
  • Rate limiting emits X-RateLimit-* / Retry-After; forwarding headers are ignored by default, so behind a reverse proxy you need TrustedProxies or every user shares the proxy’s bucket.
  • With LogRequestBody on, sensitive request-body fields are redacted, but the body is truncated to 1 KB first and a truncated body that fails to parse is logged in plain text, so keep it off until fixed; headers and query strings are not redacted; LogResponseBody is a no-op, and 4xx / 5xx use the tracked writer’s real status at Warn / Error.

Source: yshengliao/gortex.


Ideas and technical judgement by Sheng; drafted with Claude · Go 1.25 (gortex go.mod) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.