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:
59
internal/reqtls/reqtls.go
Normal file
59
internal/reqtls/reqtls.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Package reqtls answers one question, in one place, for the whole
|
||||
// application: did this request reach the service over TLS?
|
||||
//
|
||||
// It exists because that question used to be answered independently in
|
||||
// several packages, by hand, and the answers disagreed. The session
|
||||
// cookie's Secure attribute was decided at startup from the configured
|
||||
// environment while the CSRF cookie's was decided per-request, so a
|
||||
// deployment behind a TLS proxy in the default environment emitted one
|
||||
// Secure cookie and one non-Secure cookie on the same response.
|
||||
// Everything kept working, which is exactly why nobody noticed.
|
||||
//
|
||||
// Any code that needs a scheme or a Secure flag must call IsTLS rather
|
||||
// than reading the request itself.
|
||||
package reqtls
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forwardedProtoHeader is the de-facto standard header by which a
|
||||
// TLS-terminating reverse proxy reports the protocol the CLIENT used.
|
||||
const forwardedProtoHeader = "X-Forwarded-Proto"
|
||||
|
||||
// IsTLS reports whether the client-facing connection uses TLS: either
|
||||
// the request arrived over TLS directly, or a reverse proxy terminated
|
||||
// TLS and said so in X-Forwarded-Proto.
|
||||
//
|
||||
// The header is only as trustworthy as whatever sits in front of the
|
||||
// listener. A proxy that overwrites it -- which is what the deployment
|
||||
// documentation requires -- makes it authoritative; a listener exposed
|
||||
// directly to clients lets any client assert it. That is the same
|
||||
// exposure every X-Forwarded-* consumer carries.
|
||||
func IsTLS(r *http.Request) bool {
|
||||
return r.TLS != nil || forwardedProto(r) == "https"
|
||||
}
|
||||
|
||||
// forwardedProto reduces X-Forwarded-Proto to a bare, comparable
|
||||
// protocol token, or "" when the header is absent or blank.
|
||||
//
|
||||
// Two shapes that real infrastructure emits do not survive an exact
|
||||
// comparison against "https", and both name a TLS client connection:
|
||||
//
|
||||
// - "HTTPS", because the header value is a case-insensitive token and
|
||||
// nothing obliges a proxy to emit it lowercased.
|
||||
// - "https, http", because a proxy chained behind another proxy
|
||||
// APPENDS its own hop instead of replacing the value. As with
|
||||
// X-Forwarded-For, the leftmost element is the one nearest the
|
||||
// client, so it is the element that describes the browser's
|
||||
// connection -- the only hop a cookie's Secure attribute is about.
|
||||
//
|
||||
// Landing on the plaintext path for either of those spellings is not a
|
||||
// cosmetic error: it stops gorilla/csrf enforcing the strict Referer
|
||||
// check on a site that genuinely is HTTPS.
|
||||
func forwardedProto(r *http.Request) string {
|
||||
first, _, _ := strings.Cut(r.Header.Get(forwardedProtoHeader), ",")
|
||||
|
||||
return strings.ToLower(strings.TrimSpace(first))
|
||||
}
|
||||
209
internal/reqtls/reqtls_test.go
Normal file
209
internal/reqtls/reqtls_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
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",
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user