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:
@@ -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