package delivery_test import ( "context" "encoding/json" "log/slog" "net/http" "net/http/httptest" "net/url" "sync" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) // The headers these tests drive stand in for the two classes the // off-origin rule covers: an operator-configured credential and an // inbound header the delivery path forwards. net/http withholds // Authorization and Cookie across a host change, and nothing else. const ( probeHeaderName = "X-Api-Key" probeHeaderValue = "QQNEVERONTHEWIREQQ" inboundHeaderName = "X-Hub-Signature" inboundHeaderValue = "sha1=QQINBOUNDQQ" ) // redirectProbe records what the last hop of a redirect chain // actually received. type redirectProbe struct { mu sync.Mutex seen http.Header hits int } func (p *redirectProbe) serve( w http.ResponseWriter, r *http.Request, ) { p.mu.Lock() p.seen = r.Header.Clone() p.hits++ p.mu.Unlock() w.WriteHeader(http.StatusOK) } func (p *redirectProbe) result() (http.Header, int) { p.mu.Lock() defer p.mu.Unlock() return p.seen, p.hits } // deliverWithProbeHeaders runs one real delivery of a new task // through the engine to targetURL, carrying both probe headers — // probeHeaderName configured on the target, inboundHeaderName // forwarded from the event — and returns the delivery status the // engine recorded. func deliverWithProbeHeaders( t *testing.T, targetURL string, ) database.DeliveryStatus { t.Helper() s := newISetup(t) event := iSeedEvent( t, s.WebhookDB, s.WebhookID, `{"hello":"world"}`, ) targetID := uuid.New().String() inbound, err := json.Marshal(map[string][]string{ inboundHeaderName: {inboundHeaderValue}, }) require.NoError(t, err) event.Headers = string(inbound) d := iSeedDelivery( t, s.WebhookDB, event.ID, targetID, database.DeliveryStatusPending, ) cfg, err := json.Marshal(delivery.HTTPTargetConfig{ URL: targetURL, Headers: map[string]string{ probeHeaderName: probeHeaderValue, }, }) require.NoError(t, err) body := event.Body task := iTask( d, event, s.WebhookID, targetID, "redirect-target", string(cfg), 0, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &task) var updated database.Delivery require.NoError(t, s.WebhookDB.First( &updated, "id = ?", d.ID, ).Error) return updated.Status } // A 302 to an origin the operator never configured must not carry // the credential they configured for the one they did, nor the // inbound header this delivery forwarded — one rule for both // classes. The chain is still followed, so the delivery is recorded // from the final hop. func TestDelivery_CrossOriginRedirectDropsOriginScopedHeaders( t *testing.T, ) { t.Parallel() var probe redirectProbe final := httptest.NewServer( http.HandlerFunc(probe.serve), ) defer final.Close() // httptest listens on loopback, so reach the second server // under loopback's other name: the hop then differs in // hostname as well as port and is cross-host by any reading. finalURL, err := url.Parse(final.URL) require.NoError(t, err) finalURL.Host = "localhost:" + finalURL.Port() finalURL.Path = "/moved" origin := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { http.Redirect( w, r, finalURL.String(), http.StatusFound, ) }, )) defer origin.Close() status := deliverWithProbeHeaders(t, origin.URL) seen, hits := probe.result() assert.Equal(t, 1, hits, "the redirect must still be followed", ) assert.Empty(t, seen.Get(probeHeaderName), "a configured credential header must not reach an "+ "origin the operator did not configure", ) assert.Empty(t, seen.Get(inboundHeaderName), "a forwarded inbound header must not reach an origin "+ "the operator did not configure", ) assert.Equal(t, database.DeliveryStatusDelivered, status, "the final hop's 200 is the delivery's result", ) } // Stripping must not fire within the configured origin, or every // destination that redirects its own path would lose its // credential and start answering 401 — and would lose the inbound // signature the receiver verifies. func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders( t *testing.T, ) { t.Parallel() var probe redirectProbe srv := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/moved" { probe.serve(w, r) return } http.Redirect( w, r, "/moved", http.StatusFound, ) }, )) defer srv.Close() status := deliverWithProbeHeaders(t, srv.URL+"/hook") seen, hits := probe.result() assert.Equal(t, 1, hits) assert.Equal(t, probeHeaderValue, seen.Get(probeHeaderName), "a redirect within the configured origin must keep "+ "the configured header", ) assert.Equal(t, inboundHeaderValue, seen.Get(inboundHeaderName), "a redirect within the configured origin must keep "+ "the forwarded inbound header", ) assert.Equal(t, database.DeliveryStatusDelivered, status, ) } // The origin comparison is deliberately stricter than the one // net/http applies to Authorization: the port counts and a // subdomain does not inherit. Only the default-port spellings of // one origin are the same origin. func TestSameDeliveryOrigin(t *testing.T) { t.Parallel() // The configured target URL every case redirects away from. // Destination paths differ only so that no literal repeats. const configured = "https://h/a" cases := map[string]struct { origin string dest string want bool }{ "other path": {configured, "https://h/b", true}, "default port spelled": {configured, "https://h:443/c", true}, "host in another case": {configured, "https://H/d", true}, "http default port": {"http://h:80/a", "http://h/e", true}, "upgrade to https": {"http://h/a", "https://h/f", true}, "downgrade to http": {configured, "http://h/g", false}, "another host": {configured, "https://i/h", false}, "a subdomain": {configured, "https://x.h/i", false}, "the parent domain": {"https://x.h/a", "https://h/j", false}, "another port": {configured, "https://h:8443/k", false}, // Hostname() unwraps an IPv6 literal's brackets, so a // bracketed host whose last group is the origin's port // renders identically to the origin unless the port is // re-joined with brackets. Each dest below differs from // its origin in address AND in port. "ipv6 port as final group": { "https://[2001:db8::1]:8080/a", "https://[2001:db8::1:8080]/l", false, }, "ipv6 loopback port as final group": { "https://[::1]:8080/a", "https://[::1:8080]/m", false, }, "ipv6 same origin": { "https://[2001:db8::1]:8080/a", "https://[2001:DB8::1]:8080/n", true, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { t.Parallel() origin, err := url.Parse(tc.origin) require.NoError(t, err) dest, err := url.Parse(tc.dest) require.NoError(t, err) assert.Equal(t, tc.want, delivery.ExportSameDeliveryOrigin( origin, dest, ), ) }) } } // Installing a CheckRedirect discards net/http's own redirect // limit, so the cap this policy restates is the only thing between // a self-redirecting destination and an unbounded chain. A // destination that always redirects must be cut off after exactly // maxDeliveryRedirects requests, with the sentinel surfacing to the // caller rather than a generic net/http error. func TestRedirectPolicy_StopsAtHopCap(t *testing.T) { t.Parallel() var hits atomic.Int64 srv := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { hits.Add(1) http.Redirect( w, r, "/loop", http.StatusFound, ) }, )) defer srv.Close() engine := delivery.NewTestEngine( slog.New(slog.DiscardHandler), &http.Client{Timeout: 10 * time.Second}, 1, ) client := engine.ExportClientForRequest( &delivery.HTTPTargetConfig{URL: srv.URL}, []string{probeHeaderName}, ) require.NotNil(t, client.CheckRedirect) req, err := http.NewRequestWithContext( context.Background(), http.MethodGet, srv.URL, http.NoBody, ) require.NoError(t, err) resp, doErr := client.Do(req) if resp != nil { _ = resp.Body.Close() } require.Error(t, doErr, "an endless redirect chain must not be followed forever", ) require.ErrorIs(t, doErr, delivery.ErrExportTooManyRedirects) assert.Equal(t, int64(delivery.ExportMaxDeliveryRedirects), hits.Load(), "the chain must stop after exactly %d hops", delivery.ExportMaxDeliveryRedirects, ) } // The set the redirect policy strips is whatever the delivery path // actually put on the wire, so a header added to the forward set is // covered without a second edit. A header the event never carried // is not in the set, and the delivery path's own two are deliberately // excluded: Content-Type describes the body, which a 307 carries // across hosts, and the inbound User-Agent every real sender supplies // is overwritten before the request goes out. func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) { t.Parallel() inbound, err := json.Marshal(map[string][]string{ inboundHeaderName: {inboundHeaderValue}, "Content-Type": {testContentType}, "User-Agent": {"curl/8.7.1"}, "Host": {"inbound.example.com"}, }) require.NoError(t, err) req, err := http.NewRequestWithContext( context.Background(), http.MethodPost, "https://target.example.com/hook", http.NoBody, ) require.NoError(t, err) names := delivery.ExportApplyRequestHeaders( req, &database.Event{ Headers: string(inbound), ContentType: testContentType, }, &delivery.HTTPTargetConfig{ Headers: map[string]string{ probeHeaderName: probeHeaderValue, }, }, ) assert.Equal(t, []string{probeHeaderName, inboundHeaderName}, names, "both header classes are reported, and only those: "+ "Host is never forwarded, Content-Type and "+ "User-Agent are the delivery path's own", ) }