Files
webhooker/internal/middleware/csrf_test.go
T
sneak 86224d4982
check / check (push) Successful in 4m2s
Default WEBHOOKER_ENVIRONMENT to prod
An unset WEBHOOKER_ENVIRONMENT now resolves to prod rather than dev, so
an operator who forgets the variable is not silently permissive. The
only behaviour dev still changes is the CORS middleware, which answers
every origin with Access-Control-Allow-Origin: *; that is now off
unless dev is set explicitly. Cookie Secure and CSRF strictness are
decided per request from the transport and are unaffected.

Updated resolveEnvironment and its comment, the README configuration
table and prose, and tests covering the default and the explicit dev.
Comments that justified behaviour by the old dev default (the
TRUSTED_PROXIES warning, a CSRF test) were corrected; that warning
still fires in every environment when TRUSTED_PROXIES is empty.

Model: opus-4-8
2026-09-21 13:04:56 +00:00

615 lines
14 KiB
Go

package middleware_test
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware"
)
// csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf"
// csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies.
func csrfGetToken(
t *testing.T,
csrfMW func(http.Handler) http.Handler,
getReq *http.Request,
) (string, []*http.Cookie) {
t.Helper()
var token string
getHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
token = middleware.CSRFToken(r)
},
))
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
require.NotEmpty(t, cookies, "CSRF cookie should be set")
require.NotEmpty(t, token, "CSRF token should be set")
return token, cookies
}
// csrfPostWithToken performs a POST request with the given CSRF
// token and cookies through the middleware. Returns whether the
// handler was called and the response code.
func csrfPostWithToken(
t *testing.T,
csrfMW func(http.Handler) http.Handler,
postReq *http.Request,
token string,
cookies []*http.Cookie,
) (bool, int) {
t.Helper()
var called bool
postHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
form := url.Values{"csrf_token": {token}}
postReq.Body = http.NoBody
postReq.Body = nil
// Rebuild the request with the form body
rebuilt := httptest.NewRequestWithContext(
context.Background(),
postReq.Method, postReq.URL.String(),
strings.NewReader(form.Encode()),
)
rebuilt.Header = postReq.Header.Clone()
rebuilt.TLS = postReq.TLS
rebuilt.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range cookies {
rebuilt.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, rebuilt)
return called, postW.Code
}
func TestCSRF_GETSetsToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var gotToken string
handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r)
},
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/form", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.NotEmpty(
t, gotToken,
"CSRF token should be set in context on GET",
)
}
func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/form", nil,
)
token, cookies := csrfGetToken(t, csrfMW, getReq)
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/form", nil,
)
called, _ := csrfPostWithToken(
t, csrfMW, postReq, token, cookies,
)
assert.True(
t, called,
"handler should be called with valid CSRF token",
)
}
// csrfPOSTWithoutTokenTest is a shared helper for testing POST
// requests without a CSRF token in both dev and prod modes.
func csrfPOSTWithoutTokenTest(
t *testing.T,
env string,
msg string,
) {
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
getReq := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
// POST without CSRF token
var called bool
postHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/form", nil,
)
postReq.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.False(t, called, msg)
assert.Equal(t, http.StatusForbidden, postW.Code)
}
func TestCSRF_POSTWithoutToken(t *testing.T) {
t.Parallel()
csrfPOSTWithoutTokenTest(
t,
config.EnvironmentDev,
"handler should NOT be called without CSRF token",
)
}
func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
getReq := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
// POST with wrong CSRF token
var called bool
postHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
form := url.Values{"csrf_token": {"invalid-token-value"}}
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/form",
strings.NewReader(form.Encode()),
)
postReq.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.False(
t, called,
"handler should NOT be called with invalid CSRF token",
)
assert.Equal(t, http.StatusForbidden, postW.Code)
}
func TestCSRF_GETDoesNotValidate(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var called bool
handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/form", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(
t, called,
"GET requests should pass through CSRF middleware",
)
}
func TestCSRFToken_NoMiddleware(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
assert.Empty(
t, middleware.CSRFToken(req),
"CSRFToken should return empty string when "+
"middleware has not run",
)
}
// --- TLS Detection Tests ---
//
// The predicate itself is tested in internal/reqtls. What is tested
// here is the consequence that actually matters: which of the two
// gorilla/csrf instances a request is routed to.
//
// The two are told apart behaviourally rather than by inspection. On
// the STRICT (TLS) instance, a state-changing request carrying no
// Origin header must supply a Referer -- gorilla/csrf rejects it with
// ErrNoReferer before it ever looks at the token, to defend a
// TLS site against an HTTP machine-in-the-middle injecting a form. On
// the RELAXED (plaintext) instance that check is skipped and a valid
// token is enough. So: valid token, no Origin, no Referer, and the
// outcome names the instance.
//
// Landing on the relaxed instance for a genuinely-HTTPS deployment is
// the defect: an exact == "https" comparison did exactly that for the
// uppercase and comma-appended spellings below.
// csrfTookStrictPath reports whether the CSRF middleware routed a
// request with the given transport to the strict instance. It also
// asserts the CSRF cookie's Secure attribute agrees, since the two are
// set by the same choice and must never disagree.
func csrfTookStrictPath(
t *testing.T,
env string,
directTLS bool,
fwdProto string,
) bool {
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
newReq := func(method string) *http.Request {
r := httptest.NewRequestWithContext(
context.Background(), method,
"http://example.com/form", nil,
)
if directTLS {
r.TLS = &tls.ConnectionState{}
}
if fwdProto != "" {
r.Header.Set("X-Forwarded-Proto", fwdProto)
}
return r
}
token, cookies := csrfGetToken(t, csrfMW, newReq(http.MethodGet))
// Deliberately no Origin and no Referer: that is what makes the
// two instances distinguishable.
called, code := csrfPostWithToken(
t, csrfMW, newReq(http.MethodPost), token, cookies,
)
strict := !called
if strict {
assert.Equal(
t, http.StatusForbidden, code,
"the strict instance rejects a Referer-less POST",
)
}
for _, c := range cookies {
if c.Name == csrfCookieName {
assert.Equal(
t, strict, c.Secure,
"the CSRF cookie's Secure attribute and the "+
"chosen instance come from one decision "+
"and must agree",
)
}
}
return strict
}
// TestCSRF_ForwardedProtoSpellingsTakeStrictPath runs the header
// spellings a real proxy emits through the middleware. The environment
// is set to dev -- the permissive setting -- to pin that the routing is
// a per-request transport decision and owes nothing to configuration.
func TestCSRF_ForwardedProtoSpellingsTakeStrictPath(t *testing.T) {
t.Parallel()
cases := []struct {
name string
header string
strict bool
why string
}{
{
name: "lowercase",
header: "https",
strict: true,
why: "the ordinary spelling",
},
{
name: "uppercase",
header: "HTTPS",
strict: true,
why: "the header value is a case-insensitive token",
},
{
name: "chain with plaintext inner hop",
header: "https, http",
strict: true,
why: "a chained proxy appends its hop; the leftmost " +
"element is the browser's connection",
},
{
name: "chain of two TLS hops",
header: "https,https",
strict: true,
why: "appended chain with no space after the comma",
},
{
name: "trailing space",
header: "https ",
strict: true,
why: "whitespace is not part of the token",
},
{
name: "plaintext",
header: "http",
strict: false,
why: "the negative control: the proxy reports a " +
"plaintext client connection",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tc.strict,
csrfTookStrictPath(
t, config.EnvironmentDev, false, tc.header,
),
"X-Forwarded-Proto %q: %s", tc.header, tc.why,
)
})
}
}
// TestCSRF_DirectTLSTakesStrictPath covers the no-proxy TLS
// deployment, and TestCSRF_PlaintextTakesRelaxedPath the no-proxy
// plaintext one -- the local development case that must keep working.
func TestCSRF_DirectTLSTakesStrictPath(t *testing.T) {
t.Parallel()
assert.True(
t,
csrfTookStrictPath(t, config.EnvironmentDev, true, ""),
"a request that arrived over TLS takes the strict path",
)
}
func TestCSRF_PlaintextTakesRelaxedPath(t *testing.T) {
t.Parallel()
assert.False(
t,
csrfTookStrictPath(t, config.EnvironmentProd, false, ""),
"no TLS and no proxy header is plaintext, in any environment",
)
}
// --- Production Mode: POST over plaintext HTTP ---
func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t *testing.T,
) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/form", nil,
)
token, cookies := csrfGetToken(t, csrfMW, getReq)
// Verify cookie is NOT Secure (plaintext HTTP in prod)
for _, c := range cookies {
if c.Name == csrfCookieName {
assert.False(t, c.Secure,
"CSRF cookie should not be Secure "+
"over plaintext HTTP")
}
}
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/form", nil,
)
called, code := csrfPostWithToken(
t, csrfMW, postReq, token, cookies,
)
assert.True(t, called,
"handler should be called -- prod mode over "+
"plaintext HTTP must work")
assert.NotEqual(t, http.StatusForbidden, code,
"should not return 403")
}
// --- Production Mode: POST with X-Forwarded-Proto ---
func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t *testing.T,
) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "http://example.com/form", nil,
)
getReq.Header.Set("X-Forwarded-Proto", "https")
token, cookies := csrfGetToken(t, csrfMW, getReq)
// Verify cookie IS Secure (X-Forwarded-Proto: https)
for _, c := range cookies {
if c.Name == csrfCookieName {
assert.True(t, c.Secure,
"CSRF cookie should be Secure behind "+
"TLS proxy")
}
}
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "http://example.com/form", nil,
)
postReq.Header.Set("X-Forwarded-Proto", "https")
postReq.Header.Set("Origin", "https://example.com")
called, code := csrfPostWithToken(
t, csrfMW, postReq, token, cookies,
)
assert.True(t, called,
"handler should be called -- prod mode behind "+
"TLS proxy must work")
assert.NotEqual(t, http.StatusForbidden, code,
"should not return 403")
}
// --- Production Mode: direct TLS ---
func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t *testing.T,
) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "https://example.com/form", nil,
)
getReq.TLS = &tls.ConnectionState{}
token, cookies := csrfGetToken(t, csrfMW, getReq)
// Verify cookie IS Secure (direct TLS)
for _, c := range cookies {
if c.Name == csrfCookieName {
assert.True(t, c.Secure,
"CSRF cookie should be Secure over "+
"direct TLS")
}
}
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "https://example.com/form", nil,
)
postReq.TLS = &tls.ConnectionState{}
postReq.Header.Set("Origin", "https://example.com")
called, code := csrfPostWithToken(
t, csrfMW, postReq, token, cookies,
)
assert.True(t, called,
"handler should be called -- direct TLS must work")
assert.NotEqual(t, http.StatusForbidden, code,
"should not return 403")
}
// --- Production Mode: POST without token still rejects ---
func TestCSRF_ProdMode_PlaintextHTTP_POSTWithoutToken(
t *testing.T,
) {
t.Parallel()
csrfPOSTWithoutTokenTest(
t,
config.EnvironmentProd,
"handler should NOT be called without CSRF token "+
"even in prod+plaintext",
)
}