All checks were successful
check / check (push) Successful in 3m46s
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.
210 lines
4.7 KiB
Go
210 lines
4.7 KiB
Go
package reqtls_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"sneak.berlin/go/webhooker/internal/reqtls"
|
|
)
|
|
|
|
// newReq builds a plaintext request with no forwarding headers.
|
|
func newReq(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
|
|
return httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil,
|
|
)
|
|
}
|
|
|
|
func TestIsTLS_DirectTLS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
r := newReq(t)
|
|
r.TLS = &tls.ConnectionState{}
|
|
|
|
assert.True(
|
|
t, reqtls.IsTLS(r),
|
|
"a request that arrived over TLS is TLS",
|
|
)
|
|
}
|
|
|
|
func TestIsTLS_PlaintextNoHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.False(
|
|
t, reqtls.IsTLS(newReq(t)),
|
|
"no TLS connection and no header means plaintext",
|
|
)
|
|
}
|
|
|
|
// protoCase is one X-Forwarded-Proto spelling and the answer IsTLS
|
|
// owes it.
|
|
type protoCase struct {
|
|
name string
|
|
header string
|
|
want bool
|
|
why string
|
|
}
|
|
|
|
// protoCases enumerates the header values real infrastructure emits.
|
|
func protoCases() []protoCase {
|
|
return append(protoTLSCases(), protoPlaintextCases()...)
|
|
}
|
|
|
|
// protoTLSCases are the spellings that name a TLS client connection.
|
|
// Every one but the first is a spelling an exact == "https"
|
|
// comparison used to miss, silently downgrading a genuinely-HTTPS
|
|
// deployment to the plaintext path.
|
|
func protoTLSCases() []protoCase {
|
|
return []protoCase{
|
|
{
|
|
name: "lowercase",
|
|
header: "https",
|
|
want: true,
|
|
why: "the ordinary spelling",
|
|
},
|
|
{
|
|
name: "uppercase",
|
|
header: "HTTPS",
|
|
want: true,
|
|
why: "the value is a case-insensitive token; " +
|
|
"nothing obliges a proxy to lowercase it",
|
|
},
|
|
{
|
|
name: "mixed case",
|
|
header: "HttpS",
|
|
want: true,
|
|
why: "case folding must be total, not just the two extremes",
|
|
},
|
|
{
|
|
name: "chain with plaintext inner hop",
|
|
header: "https, http",
|
|
want: true,
|
|
why: "a chained proxy appends its hop; the leftmost " +
|
|
"element is the client-facing one",
|
|
},
|
|
{
|
|
name: "chain of two TLS hops",
|
|
header: "https,https",
|
|
want: true,
|
|
why: "appended chain with no space after the comma",
|
|
},
|
|
{
|
|
name: "trailing space",
|
|
header: "https ",
|
|
want: true,
|
|
why: "surrounding whitespace is not part of the token",
|
|
},
|
|
{
|
|
name: "leading space",
|
|
header: " https",
|
|
want: true,
|
|
why: "surrounding whitespace is not part of the token",
|
|
},
|
|
{
|
|
name: "uppercase chain",
|
|
header: "HTTPS, HTTP",
|
|
want: true,
|
|
why: "case folding and chain splitting must compose",
|
|
},
|
|
}
|
|
}
|
|
|
|
// protoPlaintextCases are the values that must NOT be read as TLS.
|
|
func protoPlaintextCases() []protoCase {
|
|
return []protoCase{
|
|
{
|
|
name: "plaintext",
|
|
header: "http",
|
|
want: false,
|
|
why: "the negative control: the proxy reports plaintext",
|
|
},
|
|
{
|
|
name: "plaintext chain with TLS inner hop",
|
|
header: "http, https",
|
|
want: false,
|
|
why: "the client-facing hop is plaintext even though " +
|
|
"an inner hop used TLS",
|
|
},
|
|
{
|
|
name: "empty",
|
|
header: "",
|
|
want: false,
|
|
why: "an empty header asserts nothing",
|
|
},
|
|
{
|
|
name: "whitespace only",
|
|
header: " ",
|
|
want: false,
|
|
why: "a blank header asserts nothing",
|
|
},
|
|
{
|
|
name: "unrelated token",
|
|
header: "ftp",
|
|
want: false,
|
|
why: "only https means TLS",
|
|
},
|
|
{
|
|
name: "https as a substring",
|
|
header: "nothttps",
|
|
want: false,
|
|
why: "matching must be on the whole token, not a substring",
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestIsTLS_ForwardedProtoSpellings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range protoCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
r := newReq(t)
|
|
r.Header.Set("X-Forwarded-Proto", tc.header)
|
|
|
|
assert.Equal(
|
|
t, tc.want, reqtls.IsTLS(r),
|
|
"X-Forwarded-Proto %q: %s", tc.header, tc.why,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsTLS_DirectTLSBeatsPlaintextHeader pins the precedence: a
|
|
// connection this process itself terminated with TLS is a fact, and a
|
|
// header claiming otherwise does not override it.
|
|
func TestIsTLS_DirectTLSBeatsPlaintextHeader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
r := newReq(t)
|
|
r.TLS = &tls.ConnectionState{}
|
|
r.Header.Set("X-Forwarded-Proto", "http")
|
|
|
|
assert.True(
|
|
t, reqtls.IsTLS(r),
|
|
"an actual TLS connection outranks a header claiming plaintext",
|
|
)
|
|
}
|
|
|
|
// TestIsTLS_FirstHeaderValueWins covers a proxy that adds a second
|
|
// header line rather than appending to the existing one. net/http
|
|
// keeps them as separate values; the first is the client-facing hop,
|
|
// matching how the comma-separated form is read.
|
|
func TestIsTLS_FirstHeaderValueWins(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
r := newReq(t)
|
|
r.Header.Add("X-Forwarded-Proto", "https")
|
|
r.Header.Add("X-Forwarded-Proto", "http")
|
|
|
|
assert.True(
|
|
t, reqtls.IsTLS(r),
|
|
"the first header line is the client-facing hop",
|
|
)
|
|
}
|