Decide request TLS in one place, per request (closes #269)
All checks were successful
check / check (push) Successful in 3m28s
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.
This commit is contained in:
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user