Derive cookie Secure and CSRF strictness from the request transport (closes #269)
All checks were successful
check / check (push) Successful in 2m56s
All checks were successful
check / check (push) Successful in 2m56s
This commit was merged in pull request #276.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
"sneak.berlin/go/webhooker/internal/reqtls"
|
||||
)
|
||||
|
||||
// CSRFToken retrieves the CSRF token from the request context.
|
||||
@@ -13,13 +14,6 @@ func CSRFToken(r *http.Request) string {
|
||||
return csrf.Token(r)
|
||||
}
|
||||
|
||||
// isClientTLS reports whether the client-facing connection uses TLS.
|
||||
// It checks for a direct TLS connection (r.TLS) or a TLS-terminating
|
||||
// reverse proxy that sets the standard X-Forwarded-Proto header.
|
||||
func isClientTLS(r *http.Request) bool {
|
||||
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
|
||||
// CSRF returns middleware that provides CSRF protection using the
|
||||
// gorilla/csrf library. The middleware uses the session authentication
|
||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||
@@ -27,9 +21,10 @@ func isClientTLS(r *http.Request) bool {
|
||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||
// token receive a 403 Forbidden response.
|
||||
//
|
||||
// The middleware detects the client-facing transport protocol per-request
|
||||
// using r.TLS and the X-Forwarded-Proto header. This allows correct
|
||||
// behavior in all deployment scenarios:
|
||||
// The middleware detects the client-facing transport protocol
|
||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||
// cookie also uses. This allows correct behavior in all deployment
|
||||
// scenarios:
|
||||
//
|
||||
// - Direct HTTPS: strict Referer/Origin checks, Secure cookies.
|
||||
// - Behind a TLS-terminating reverse proxy: strict checks (the
|
||||
@@ -83,7 +78,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
httpCSRF := httpProtect(next)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isClientTLS(r) {
|
||||
if reqtls.IsTLS(r) {
|
||||
// Client is on TLS (directly or via reverse proxy).
|
||||
// Use Secure cookies and strict Origin/Referer checks.
|
||||
tlsCSRF.ServeHTTP(w, r)
|
||||
|
||||
@@ -297,55 +297,176 @@ func TestCSRFToken_NoMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
|
||||
func TestIsClientTLS_DirectTLS(t *testing.T) {
|
||||
// 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()
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
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, middleware.IsClientTLS(r),
|
||||
"should detect direct TLS connection",
|
||||
t,
|
||||
csrfTookStrictPath(t, config.EnvironmentDev, true, ""),
|
||||
"a request that arrived over TLS takes the strict path",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsClientTLS_XForwardedProto(t *testing.T) {
|
||||
func TestCSRF_PlaintextTakesRelaxedPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
assert.True(
|
||||
t, middleware.IsClientTLS(r),
|
||||
"should detect TLS via X-Forwarded-Proto",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsClientTLS_PlaintextHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
assert.False(
|
||||
t, middleware.IsClientTLS(r),
|
||||
"should detect plaintext HTTP",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsClientTLS_XForwardedProtoHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Forwarded-Proto", "http")
|
||||
|
||||
assert.False(
|
||||
t, middleware.IsClientTLS(r),
|
||||
"should detect plaintext when X-Forwarded-Proto is http",
|
||||
t,
|
||||
csrfTookStrictPath(t, config.EnvironmentProd, false, ""),
|
||||
"no TLS and no proxy header is plaintext, in any environment",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,11 +56,6 @@ func ClientKeyForTest(m *Middleware, r *http.Request) string {
|
||||
return m.clientKey(r)
|
||||
}
|
||||
|
||||
// IsClientTLS exposes isClientTLS for testing.
|
||||
func IsClientTLS(r *http.Request) bool {
|
||||
return isClientTLS(r)
|
||||
}
|
||||
|
||||
// LoginRateLimitConst exposes the loginRateLimit constant: the
|
||||
// number of FAILED login attempts one client may make against one
|
||||
// submitted username per interval.
|
||||
|
||||
Reference in New Issue
Block a user