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:
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.
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
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
## 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
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
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.
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 main2026-03-18 04:30:58 +01:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
After the security hardening in PR #42, login fails with
Forbidden - invalid CSRF tokenin production deployments.The CSRF middleware tied its
PlaintextHTTPRequestwrapping and cookieSecureflag to theIsDev()environment check. This meant production mode always assumed HTTPS via gorilla/csrf's strict mode, which broke login in common deployment scenarios:Production behind a TLS-terminating reverse proxy: gorilla/csrf assumed HTTPS but
r.TLSwas nil (the Go server receives HTTP from the proxy). Origin/Referer scheme mismatches causedreferer not suppliedororigin invaliderrors.Production over direct HTTP (testing/staging with prod config): the
Securecookie flag prevented the browser from sending the CSRF cookie back over HTTP, causingCSRF token invaliderrors.Root Cause
gorilla/csrf v1.7.3 defaults to HTTPS-strict mode unless
PlaintextHTTPRequest()is called. In strict mode it:requestURL.Scheme = "https"for Origin/Referer comparisonsRefererheader on POST and rejectshttp://Referer schemescsrf.Secure(true)option makes the browser refuse to send the CSRF cookie over HTTPThe 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 serverX-Forwarded-Proto: httpsheader — TLS-terminating reverse proxyTwo gorilla/csrf middleware instances are maintained (one with
Secure: true, one withSecure: false) sincecsrf.Secure()is a creation-time option. Both use the same signing key, so cookies are interchangeable.r.TLS != nil)X-Forwarded-Proto: https)CSRF token validation (cookie + form double-submit) is always enforced regardless of mode.
Testing
TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken— prod mode over plaintext HTTPTestCSRF_ProdMode_BehindProxy_POSTWithValidToken— prod mode behind TLS proxyTestCSRF_ProdMode_DirectTLS_POSTWithValidToken— prod mode with direct TLSTestCSRF_ProdMode_PlaintextHTTP_POSTWithoutToken— token still requiredTestIsClientTLS_*— TLS detection unit testsdocker build .passes (includesmake check)devandprodmodes, confirmed login succeeds in bothCloses #53
Code Review — PR #54: fix CSRF login
Policy Compliance
.golangci.ymlunmodifiedMakefileunmodifiedDockerfileunmodified52ae9a1)No policy violations found.
Requirements Checklist
r.TLSnil)isClientTLS()checksr.TLSandX-Forwarded-ProtohttpCSRFwithPlaintextHTTPRequestr.TLS != nilroutes totlsCSRFtlsCSRFandhttpCSRFare full gorilla/csrf instances; no bypass pathTestCSRF_ProdMode_PlaintextHTTP_POSTWithoutTokenverifies 403Security Review
TLS detection (
isClientTLS): Checksr.TLS != nilfirst (authoritative for direct connections), then falls back toX-Forwarded-Proto: https.r.TLStakes priority, so an attacker cannot downgrade a direct TLS connection. TrustingX-Forwarded-Protowithout explicit config is standard Go practice (Go's ownhttputil.ReverseProxydoes the same). README correctly documents that reverse proxies must set the header.Spoofing
X-Forwarded-Proto: httpson plain HTTP: Would selecttlsCSRF(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 rejecthttp://Referer. This is strictly MORE restrictive — self-defeating, not a bypass.Spoofing
X-Forwarded-Proto: httpbehind 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:baseOptsis a literal with len=cap=4. Bothappendcalls allocate new backing arrays. No aliasing bug.No CSRF bypass introduced: Every request goes through either
tlsCSRF.ServeHTTPorhttpCSRF.ServeHTTP. There is no code path that skips token validation.Build Result
docker build .— PASS (all layers green,make checkincludes 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
Secureoption. 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.Does traefik send x-forwarded-proto?
Yes — Traefik sends
X-Forwarded-Protoby 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.