Derive the entrypoint URL scheme from the shared TLS predicate (closes #272)
All checks were successful
check / check (push) Successful in 3m44s

This commit was merged in pull request #286.
This commit is contained in:
2026-08-24 04:04:04 +02:00
parent 8d64259283
commit 48cf93ec7e
2 changed files with 293 additions and 9 deletions

View File

@@ -0,0 +1,283 @@
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(
`<code id="entrypoint-url-[^"]*"[^>]*>([^<]*)</code>`,
)
// 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",
)
})
}
}

View File

@@ -13,6 +13,7 @@ import (
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/reqtls"
)
// WebhookListItem holds data for the webhook list view.
@@ -427,16 +428,16 @@ func (h *Handlers) renderSourceDetail(
}
}
host := r.Host
scheme := "https"
if r.TLS == nil {
scheme = "http"
scheme := "http"
if reqtls.IsTLS(r) {
scheme = "https"
}
if fwdProto := r.Header.Get("X-Forwarded-Proto"); fwdProto != "" {
scheme = fwdProto
}
// The host is the client's Host header, unvalidated. It is
// inert only because source_detail.html renders BaseURL as
// text inside a <code> element; putting it in an href or any
// other URL context needs it constrained first.
baseURL := scheme + "://" + r.Host
// The template calls Webhook methods, which take pointer
// receivers; html/template cannot address a value stored in a map.
@@ -448,7 +449,7 @@ func (h *Handlers) renderSourceDetail(
"Entrypoints": NewEntrypointViews(entrypoints),
"Targets": delivery.NewTargetViews(targets),
"Events": events,
"BaseURL": scheme + "://" + host,
"BaseURL": baseURL,
}
h.renderTemplate(w, r, "source_detail.html", data)