fix: detect TLS per-request in CSRF middleware to fix login #54

Merged
sneak merged 1 commits from fix/csrf-login-tls-detection into main 2026-03-18 04:30:58 +01:00
Collaborator

Problem

After the security hardening in PR #42, login fails with Forbidden - invalid CSRF token in production deployments.

The CSRF middleware tied its PlaintextHTTPRequest wrapping and cookie Secure flag to the IsDev() environment check. This meant production mode always assumed HTTPS via gorilla/csrf's strict mode, which broke login in common deployment scenarios:

  1. Production behind a TLS-terminating reverse proxy: gorilla/csrf assumed HTTPS but r.TLS was nil (the Go server receives HTTP from the proxy). Origin/Referer scheme mismatches caused referer not supplied or origin invalid errors.

  2. Production over direct HTTP (testing/staging with prod config): the Secure cookie flag prevented the browser from sending the CSRF cookie back over HTTP, causing CSRF token invalid errors.

Root Cause

gorilla/csrf v1.7.3 defaults to HTTPS-strict mode unless PlaintextHTTPRequest() is called. In strict mode it:

  • Forces requestURL.Scheme = "https" for Origin/Referer comparisons
  • Requires a Referer header on POST and rejects http:// Referer schemes
  • The csrf.Secure(true) option makes the browser refuse to send the CSRF cookie over HTTP

The old code only called PlaintextHTTPRequest() in dev mode, leaving prod mode permanently stuck in HTTPS-strict mode regardless of the actual transport.

Fix

Detect the actual transport protocol per-request using:

  • r.TLS != nil — direct TLS connection to the Go server
  • X-Forwarded-Proto: https header — TLS-terminating reverse proxy

Two gorilla/csrf middleware instances are maintained (one with Secure: true, one with Secure: false) since csrf.Secure() is a creation-time option. Both use the same signing key, so cookies are interchangeable.

Scenario Cookie Secure Origin/Referer Mode
Direct TLS (r.TLS != nil) Secure Strict (HTTPS scheme)
Behind TLS proxy (X-Forwarded-Proto: https) Secure Strict (HTTPS scheme)
Plaintext HTTP Non-Secure Relaxed (PlaintextHTTPRequest)

CSRF token validation (cookie + form double-submit) is always enforced regardless of mode.

Testing

  • Added TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken — prod mode over plaintext HTTP
  • Added TestCSRF_ProdMode_BehindProxy_POSTWithValidToken — prod mode behind TLS proxy
  • Added TestCSRF_ProdMode_DirectTLS_POSTWithValidToken — prod mode with direct TLS
  • Added TestCSRF_ProdMode_PlaintextHTTP_POSTWithoutToken — token still required
  • Added TestIsClientTLS_* — TLS detection unit tests
  • All existing CSRF tests pass unchanged
  • docker build . passes (includes make check)
  • Manual verification: built and ran the container in both dev and prod modes, confirmed login succeeds in both

Closes #53

## Problem After the security hardening in PR #42, login fails with `Forbidden - invalid CSRF token` in production deployments. The CSRF middleware tied its `PlaintextHTTPRequest` wrapping and cookie `Secure` flag to the `IsDev()` environment check. This meant production mode always assumed HTTPS via gorilla/csrf's strict mode, which broke login in common deployment scenarios: 1. **Production behind a TLS-terminating reverse proxy**: gorilla/csrf assumed HTTPS but `r.TLS` was nil (the Go server receives HTTP from the proxy). Origin/Referer scheme mismatches caused `referer not supplied` or `origin invalid` errors. 2. **Production over direct HTTP** (testing/staging with prod config): the `Secure` cookie flag prevented the browser from sending the CSRF cookie back over HTTP, causing `CSRF token invalid` errors. ## Root Cause gorilla/csrf v1.7.3 defaults to HTTPS-strict mode unless `PlaintextHTTPRequest()` is called. In strict mode it: - Forces `requestURL.Scheme = "https"` for Origin/Referer comparisons - Requires a `Referer` header on POST and rejects `http://` Referer schemes - The `csrf.Secure(true)` option makes the browser refuse to send the CSRF cookie over HTTP The old code only called `PlaintextHTTPRequest()` in dev mode, leaving prod mode permanently stuck in HTTPS-strict mode regardless of the actual transport. ## Fix Detect the actual transport protocol **per-request** using: - `r.TLS != nil` — direct TLS connection to the Go server - `X-Forwarded-Proto: https` header — TLS-terminating reverse proxy Two gorilla/csrf middleware instances are maintained (one with `Secure: true`, one with `Secure: false`) since `csrf.Secure()` is a creation-time option. Both use the same signing key, so cookies are interchangeable. | Scenario | Cookie Secure | Origin/Referer Mode | |---|---|---| | Direct TLS (`r.TLS != nil`) | ✅ Secure | Strict (HTTPS scheme) | | Behind TLS proxy (`X-Forwarded-Proto: https`) | ✅ Secure | Strict (HTTPS scheme) | | Plaintext HTTP | ❌ Non-Secure | Relaxed (PlaintextHTTPRequest) | CSRF token validation (cookie + form double-submit) is always enforced regardless of mode. ## Testing - Added `TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken` — prod mode over plaintext HTTP - Added `TestCSRF_ProdMode_BehindProxy_POSTWithValidToken` — prod mode behind TLS proxy - Added `TestCSRF_ProdMode_DirectTLS_POSTWithValidToken` — prod mode with direct TLS - Added `TestCSRF_ProdMode_PlaintextHTTP_POSTWithoutToken` — token still required - Added `TestIsClientTLS_*` — TLS detection unit tests - All existing CSRF tests pass unchanged - `docker build .` passes (includes `make check`) - Manual verification: built and ran the container in both `dev` and `prod` modes, confirmed login succeeds in both Closes https://git.eeqj.de/sneak/webhooker/issues/53
clawbot added 1 commit 2026-03-17 13:29:37 +01:00
fix: detect TLS per-request in CSRF middleware to fix login
All checks were successful
check / check (push) Successful in 2m0s
52ae9a1f1c
The CSRF middleware previously tied its PlaintextHTTPRequest wrapping
and cookie Secure flag to the IsDev() environment check. This meant
production mode always assumed HTTPS, which broke login in two common
deployment scenarios:

1. Production behind a TLS-terminating reverse proxy: gorilla/csrf
   assumed HTTPS but r.TLS was nil, causing Origin/Referer scheme
   mismatches and 'referer not supplied' errors.

2. Production over direct HTTP (testing/development with prod config):
   the Secure cookie flag prevented the browser from sending the CSRF
   cookie back on POST, causing 'CSRF token invalid' errors.

The fix detects the actual transport protocol per-request using r.TLS
(direct TLS) and the X-Forwarded-Proto header (reverse proxy). Two
gorilla/csrf instances are maintained — one with Secure cookies for TLS
and one without for plaintext — since the csrf.Secure option is set at
creation time. Both instances share the same signing key, so cookies
are interchangeable between them.

Behavior after fix:
- Direct TLS: Secure cookies, strict Origin/Referer checks
- Behind TLS proxy (X-Forwarded-Proto: https): same as direct TLS
- Plaintext HTTP: non-Secure cookies, relaxed Origin/Referer checks
  (csrf.PlaintextHTTPRequest), token validation still enforced

Closes #53
clawbot added the needs-review label 2026-03-17 13:30:44 +01:00
clawbot reviewed 2026-03-17 13:35:47 +01:00
clawbot left a comment
Author
Collaborator

Code Review — PR #54: fix CSRF login

Policy Compliance

Check Status
.golangci.yml unmodified
Makefile unmodified
Dockerfile unmodified
No new unpinned external dependencies (no new deps)
Tests use real assertions, not weakened
README updated
Single clean commit (52ae9a1)

No policy violations found.

Requirements Checklist

Requirement Status
Fixes login in prod behind TLS-terminating reverse proxy (r.TLS nil) — per-request isClientTLS() checks r.TLS and X-Forwarded-Proto
Fixes login in prod over direct HTTP — falls through to httpCSRF with PlaintextHTTPRequest
Fixes login in prod with direct TLS r.TLS != nil routes to tlsCSRF
CSRF tokens still enforced in ALL scenarios — both tlsCSRF and httpCSRF are full gorilla/csrf instances; no bypass path
Dev mode still works — existing dev-mode tests pass unchanged
Prod mode without token rejects TestCSRF_ProdMode_PlaintextHTTP_POSTWithoutToken verifies 403

Security Review

TLS detection (isClientTLS): Checks r.TLS != nil first (authoritative for direct connections), then falls back to X-Forwarded-Proto: https. r.TLS takes priority, so an attacker cannot downgrade a direct TLS connection. Trusting X-Forwarded-Proto without explicit config is standard Go practice (Go's own httputil.ReverseProxy does the same). README correctly documents that reverse proxies must set the header.

Spoofing X-Forwarded-Proto: https on plain HTTP: Would select tlsCSRF (Secure cookies + strict Origin/Referer checks). The Secure cookie wouldn't be sent back by the browser over HTTP, and the strict Referer check would reject http:// Referer. This is strictly MORE restrictive — self-defeating, not a bypass.

Spoofing X-Forwarded-Proto: http behind TLS proxy: Would require MITM on internal proxy-to-app traffic. If an attacker can do that, CSRF is the least of your concerns.

Dual csrf instances with shared key: Both instances use the same HMAC signing key and default cookie name (_gorilla_csrf). Cookies are interchangeable by design. This doesn't weaken security — the double-submit pattern still requires the masked token in the form field, which is tied to the cookie value.

append(baseOpts, ...) slice aliasing: baseOpts is a literal with len=cap=4. Both append calls allocate new backing arrays. No aliasing bug.

No CSRF bypass introduced: Every request goes through either tlsCSRF.ServeHTTP or httpCSRF.ServeHTTP. There is no code path that skips token validation.

Build Result

docker build .PASS (all layers green, make check includes fmt-check + lint + test + build).

Verdict: PASS

The fix correctly resolves the CSRF login issue (#53) by decoupling TLS detection from the environment setting and making it per-request. The dual-middleware approach is the right solution given gorilla/csrf's creation-time Secure option. Test coverage is thorough — 4 TLS detection unit tests + 4 integration tests covering all deployment scenarios (prod HTTP, prod behind proxy, prod direct TLS, prod rejection without token). No security regressions, no policy violations.

## Code Review — PR #54: fix CSRF login ### Policy Compliance | Check | Status | |---|---| | `.golangci.yml` unmodified | ✅ | | `Makefile` unmodified | ✅ | | `Dockerfile` unmodified | ✅ | | No new unpinned external dependencies | ✅ (no new deps) | | Tests use real assertions, not weakened | ✅ | | README updated | ✅ | | Single clean commit | ✅ (`52ae9a1`) | No policy violations found. ### Requirements Checklist | Requirement | Status | |---|---| | Fixes login in prod behind TLS-terminating reverse proxy (`r.TLS` nil) | ✅ — per-request `isClientTLS()` checks `r.TLS` and `X-Forwarded-Proto` | | Fixes login in prod over direct HTTP | ✅ — falls through to `httpCSRF` with `PlaintextHTTPRequest` | | Fixes login in prod with direct TLS | ✅ — `r.TLS != nil` routes to `tlsCSRF` | | CSRF tokens still enforced in ALL scenarios | ✅ — both `tlsCSRF` and `httpCSRF` are full gorilla/csrf instances; no bypass path | | Dev mode still works | ✅ — existing dev-mode tests pass unchanged | | Prod mode without token rejects | ✅ — `TestCSRF_ProdMode_PlaintextHTTP_POSTWithoutToken` verifies 403 | ### Security Review **TLS detection (`isClientTLS`)**: Checks `r.TLS != nil` first (authoritative for direct connections), then falls back to `X-Forwarded-Proto: https`. `r.TLS` takes priority, so an attacker cannot downgrade a direct TLS connection. Trusting `X-Forwarded-Proto` without explicit config is standard Go practice (Go's own `httputil.ReverseProxy` does the same). README correctly documents that reverse proxies must set the header. **Spoofing `X-Forwarded-Proto: https` on plain HTTP**: Would select `tlsCSRF` (Secure cookies + strict Origin/Referer checks). The Secure cookie wouldn't be sent back by the browser over HTTP, and the strict Referer check would reject `http://` Referer. This is strictly MORE restrictive — self-defeating, not a bypass. **Spoofing `X-Forwarded-Proto: http` behind TLS proxy**: Would require MITM on internal proxy-to-app traffic. If an attacker can do that, CSRF is the least of your concerns. **Dual csrf instances with shared key**: Both instances use the same HMAC signing key and default cookie name (`_gorilla_csrf`). Cookies are interchangeable by design. This doesn't weaken security — the double-submit pattern still requires the masked token in the form field, which is tied to the cookie value. **`append(baseOpts, ...)` slice aliasing**: `baseOpts` is a literal with len=cap=4. Both `append` calls allocate new backing arrays. No aliasing bug. **No CSRF bypass introduced**: Every request goes through either `tlsCSRF.ServeHTTP` or `httpCSRF.ServeHTTP`. There is no code path that skips token validation. ### Build Result `docker build .` — **PASS** (all layers green, `make check` includes fmt-check + lint + test + build). ### Verdict: **PASS** ✅ The fix correctly resolves the CSRF login issue ([#53](https://git.eeqj.de/sneak/webhooker/issues/53)) by decoupling TLS detection from the environment setting and making it per-request. The dual-middleware approach is the right solution given gorilla/csrf's creation-time `Secure` option. Test coverage is thorough — 4 TLS detection unit tests + 4 integration tests covering all deployment scenarios (prod HTTP, prod behind proxy, prod direct TLS, prod rejection without token). No security regressions, no policy violations.
clawbot added merge-ready and removed needs-review labels 2026-03-17 13:36:50 +01:00
sneak was assigned by clawbot 2026-03-17 13:36:50 +01:00
Owner

Does traefik send x-forwarded-proto?

Does traefik send x-forwarded-proto?
Author
Collaborator

Yes — Traefik sends X-Forwarded-Proto by default on all proxied requests. It's part of the standard set of forwarding headers Traefik adds (X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Port). No extra configuration needed. This fix will work correctly behind Traefik out of the box.

Yes — Traefik sends `X-Forwarded-Proto` by default on all proxied requests. It's part of the standard set of forwarding headers Traefik adds (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Port`). No extra configuration needed. This fix will work correctly behind Traefik out of the box.
sneak merged commit d771fe14df into main 2026-03-18 04:30:58 +01:00
sneak deleted branch fix/csrf-login-tls-detection 2026-03-18 04:30:58 +01:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#54