All checks were successful
check / check (push) Successful in 3m28s
Two places decided whether a request was TLS, by two different means, and they disagreed. The session cookie's Secure attribute was fixed at startup from !Config.IsDev(). "dev" is the environment when WEBHOOKER_ENVIRONMENT is unset, so a deployment terminating TLS at a proxy without also setting the environment shipped the authentication cookie with no Secure attribute -- on the same response as a CSRF cookie that had one. It failed silently: everything kept working, so nothing prompted anyone to look. The CSRF middleware's per-request check compared X-Forwarded-Proto with == "https" exactly, so "HTTPS", "https, http" and "https,https" all took the plaintext path. Uppercase is legal for a case-insensitive token and the comma forms are what a proxy chained behind another proxy emits by appending rather than replacing. On that path gorilla/csrf stops enforcing the strict Referer check on a site that genuinely is HTTPS. Both now go through internal/reqtls.IsTLS, which folds case and takes the leftmost comma-separated element -- the hop nearest the client, and so the one a cookie's Secure attribute is about. A third package is needed because internal/middleware already imports internal/session, so session cannot import middleware back. Per-request beat a startup warning for the session cookie because it turned out to need no restructuring: gorilla/sessions gives every session its own copy of the store's Options and renders the cookie from that copy, and every session-cookie write here already goes through Session.Save or Session.Regenerate, both of which hold the request. The store's template Secure becomes true so that a write path added later which forgets to track the transport fails visibly instead of silently dropping Secure. The flag tracks the transport in both directions rather than latching on. Secure over plaintext is discarded by the browser without an error, which would make a plain-HTTP local run impossible to log into -- and would also void the deletion cookies in Destroy and Regenerate, leaving a session the user just tried to end still live. A third site that makes this decision, internal/handlers' BaseURL construction, assigns the raw header straight into the URL scheme. It is left alone here and filed separately.
616 lines
14 KiB
Go
616 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 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",
|
|
)
|
|
}
|