package handlers_test import ( "context" "crypto/tls" "net/http" "net/http/httptest" "regexp" "testing" "github.com/go-chi/chi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/session" ) // The only two schemes a rendered entrypoint URL may carry, // whatever the request claimed. const ( schemeHTTPS = "https" schemeHTTP = "http" ) // entrypointURLPattern captures the entrypoint URL the source // detail page renders, which is the operator-visible product of // BaseURL. Asserting on the extracted string rather than on a // substring of the page proves the raw header value cannot reach // the scheme by any route. var entrypointURLPattern = regexp.MustCompile( `]*>([^<]*)`, ) // baseURLFixture is one started app plus the webhook whose // entrypoint URL the BaseURL cases read. type baseURLFixture struct { handlers *handlers.Handlers session *session.Session webhook string path string } // newBaseURLFixture starts the app and seeds a webhook with one // entrypoint. func newBaseURLFixture(t *testing.T) *baseURLFixture { t.Helper() var ( h *handlers.Handlers sess *session.Session db *database.Database ) app := newTestApp(t, &h, &sess, &db) app.RequireStart() t.Cleanup(app.RequireStop) wh := seedWebhook(t, db) seedEntrypoint(t, db, wh.ID) return &baseURLFixture{ handlers: h, session: sess, webhook: wh.ID, path: "ep-" + wh.ID, } } // entrypointURL renders the source detail page for the fixture's // webhook over a request the caller shapes, and returns the // entrypoint URL as an operator would copy it. func (f *baseURLFixture) entrypointURL( t *testing.T, host string, shape func(*http.Request), ) string { t.Helper() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/source/"+f.webhook, nil, ) req.Host = host shape(req) for _, c := range authenticatedCookies( t, f.session, deleteTestUserID, deleteTestUsername, ) { req.AddCookie(c) } rctx := chi.NewRouteContext() rctx.URLParams.Add(paramSourceID, f.webhook) req = req.WithContext( context.WithValue( req.Context(), chi.RouteCtxKey, rctx, ), ) w := httptest.NewRecorder() f.handlers.HandleSourceDetail().ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) match := entrypointURLPattern.FindStringSubmatch(w.Body.String()) require.Len( t, match, 2, "the page must render exactly one entrypoint URL", ) return match[1] } // forwardedProto returns a request shaper setting // X-Forwarded-Proto, or leaving the request alone for "". func forwardedProto(value string) func(*http.Request) { return func(r *http.Request) { if value == "" { return } r.Header.Set("X-Forwarded-Proto", value) } } // baseURLCase is one X-Forwarded-Proto spelling and the scheme // the rendered entrypoint URL owes it. type baseURLCase struct { name string header string scheme string why string } // baseURLCases enumerate the spellings a proxy really emits. The // scheme is only ever http or https: the header value itself is // never a scheme, however it is spelled. func baseURLCases() []baseURLCase { return []baseURLCase{ { name: "lowercase", header: schemeHTTPS, scheme: schemeHTTPS, why: "the ordinary spelling", }, { name: "uppercase", header: "HTTPS", scheme: schemeHTTPS, why: "the token is case-insensitive; the scheme " + "in a copyable URL is not", }, { name: "chain with plaintext inner hop", header: "https, http", scheme: schemeHTTPS, why: "a chained proxy appends its hop; the " + "leftmost element faces the client", }, { name: "chain of two TLS hops", header: "https,https", scheme: schemeHTTPS, why: "appended chain with no space after the comma", }, { name: "trailing space", header: "https ", scheme: schemeHTTPS, why: "whitespace is not part of the token", }, { name: "plaintext", header: schemeHTTP, scheme: schemeHTTP, why: "the negative control: the proxy reports plaintext", }, { name: "no header", header: "", scheme: schemeHTTP, why: "a plaintext request asserting nothing is http", }, { name: "garbage token", header: "javascript:alert(1)//", scheme: schemeHTTP, why: "anything that is not https is not TLS, and " + "the token never becomes the scheme", }, } } // TestSourceDetailBaseURL_ForwardedProtoSpellings is the // regression test for the entrypoint URL an operator pastes into // the sending system: a header spelling that used to land in the // scheme verbatim produced a URL no sender could deliver to. func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) { t.Parallel() const host = "hooks.example.com" fixture := newBaseURLFixture(t) for _, tc := range baseURLCases() { t.Run(tc.name, func(t *testing.T) { t.Parallel() assert.Equal( t, tc.scheme+"://"+host+"/webhook/"+fixture.path, fixture.entrypointURL( t, host, forwardedProto(tc.header), ), "X-Forwarded-Proto %q: %s", tc.header, tc.why, ) }) } } // TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader pins the // precedence the old code had backwards: it let any present // header overwrite what the connection itself proved, so a // direct-TLS request behind a proxy reporting http rendered an // http URL. func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader( t *testing.T, ) { t.Parallel() const host = "hooks.example.com" fixture := newBaseURLFixture(t) got := fixture.entrypointURL(t, host, func(r *http.Request) { r.TLS = &tls.ConnectionState{} r.Header.Set("X-Forwarded-Proto", "http") }) assert.Equal( t, "https://"+host+"/webhook/"+fixture.path, got, "a connection this process terminated with TLS "+ "outranks a header claiming plaintext", ) } // TestSourceDetailBaseURL_KeepsHostAuthority pins the host half // of the URL: it is taken from the request unchanged, so the // deployments that do not sit on port 443 still get a URL that // works. Constraining the host would break exactly these. func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) { t.Parallel() fixture := newBaseURLFixture(t) hosts := []string{ "hooks.example.com:8443", "[2001:db8::1]:8443", "internal-host", } for _, host := range hosts { t.Run(host, func(t *testing.T) { t.Parallel() assert.Equal( t, "https://"+host+"/webhook/"+fixture.path, fixture.entrypointURL( t, host, forwardedProto("HTTPS"), ), "the authority must survive verbatim, port and all", ) }) } }