package delivery_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "sync" "testing" "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 header these tests configure stands in for the credential // headers net/http forwards across a host change: it withholds // Authorization and Cookie, and nothing else. const ( probeHeaderName = "X-Api-Key" probeHeaderValue = "QQNEVERONTHEWIREQQ" ) // redirectProbe records what the last hop of a redirect chain // actually received. type redirectProbe struct { mu sync.Mutex seen string hits int } func (p *redirectProbe) serve( w http.ResponseWriter, r *http.Request, ) { p.mu.Lock() p.seen = r.Header.Get(probeHeaderName) p.hits++ p.mu.Unlock() w.WriteHeader(http.StatusOK) } func (p *redirectProbe) result() (string, int) { p.mu.Lock() defer p.mu.Unlock() return p.seen, p.hits } // deliverWithConfiguredHeader runs one real delivery of a new task // through the engine to targetURL, with probeHeaderName set on the // target, and returns the delivery status the engine recorded. func deliverWithConfiguredHeader( 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() 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. The chain is // still followed, so the delivery is recorded from the final hop. func TestDelivery_CrossOriginRedirectDropsConfiguredHeader( 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 := deliverWithConfiguredHeader(t, origin.URL) seen, hits := probe.result() assert.Equal(t, 1, hits, "the redirect must still be followed", ) assert.Empty(t, seen, "a configured credential 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. func TestDelivery_SameOriginRedirectKeepsConfiguredHeader( 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 := deliverWithConfiguredHeader(t, srv.URL+"/hook") seen, hits := probe.result() assert.Equal(t, 1, hits) assert.Equal(t, probeHeaderValue, seen, "a redirect within the configured origin must keep "+ "the configured 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}, } 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, ), ) }) } }