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 dev -- the DEFAULT when WEBHOOKER_ENVIRONMENT is unset -- 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", ) }