package delivery_test import ( "context" "encoding/json" "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/signature" ) // gitlabDeliverySecret is the shared secret the entrypoint in these // tests is configured with. No outbound request may contain it. const gitlabDeliverySecret = "QQDELIVERYSECRETQQ" // receivedEventHeaders builds the Event.Headers value the receiver // stores for an inbound request, by running the request's headers // through the same sanitizer the receive path uses. Going through // signature.SanitizeHeaders rather than a literal is the point of // the test: it joins the two egresses at the field they share, so a // regression at either end shows up here. func receivedEventHeaders( t *testing.T, scheme database.SignatureScheme, inbound http.Header, ) string { t.Helper() ep := &database.Entrypoint{ SignatureScheme: scheme, SignatureSecret: gitlabDeliverySecret, } encoded, err := json.Marshal( signature.SanitizeHeaders(ep, inbound), ) require.NoError(t, err) return string(encoded) } // TestApplyRequestHeadersDropsInboundCredential proves a delivery to // an HTTP target does not carry the GitLab shared secret. // // isForwardableHeader is a blocklist of hop-by-hop names, so it // forwards X-Gitlab-Token like any other header; what keeps the // secret out of the outbound request is that the receiver never // stored it. Handing a target operator the token would hand them the // ability to forge requests to the entrypoint it authenticates, // which is the one control the receiver has. func TestApplyRequestHeadersDropsInboundCredential(t *testing.T) { t.Parallel() inbound := http.Header{} inbound.Set(signature.HeaderGitLab, gitlabDeliverySecret) inbound.Set("X-Gitlab-Event", "Push Hook") event := &database.Event{ Headers: receivedEventHeaders( t, database.SignatureSchemeGitLab, inbound, ), ContentType: "application/json", } req, err := http.NewRequestWithContext( context.Background(), http.MethodPost, "https://target.example.com/hook", http.NoBody, ) require.NoError(t, err) delivery.ExportApplyRequestHeaders( req, event, &delivery.HTTPTargetConfig{}, ) assert.Empty( t, req.Header.Values(signature.HeaderGitLab), "the shared secret header must not reach a target", ) // Header.Values canonicalises, so a differently-cased spelling // would be caught above; this catches the value arriving under // some other name. for name, values := range req.Header { for _, v := range values { assert.NotContains( t, v, gitlabDeliverySecret, "secret present in outbound header %s", name, ) } } // The rest of the sender's headers still arrive. A fix that // dropped everything would pass the assertions above while // breaking delivery. assert.Equal( t, "Push Hook", req.Header.Get("X-Gitlab-Event"), ) } // TestApplyRequestHeadersKeepsGitHubDigest proves the stripping is // scoped to headers that carry the secret itself. GitHub's // X-Hub-Signature-256 is an HMAC over the body, so a target can be // shown it without being handed the key. func TestApplyRequestHeadersKeepsGitHubDigest(t *testing.T) { t.Parallel() const digest = "sha256=deadbeef" inbound := http.Header{} inbound.Set(signature.HeaderGitHub, digest) event := &database.Event{ Headers: receivedEventHeaders( t, database.SignatureSchemeGitHub, inbound, ), } req, err := http.NewRequestWithContext( context.Background(), http.MethodPost, "https://target.example.com/hook", http.NoBody, ) require.NoError(t, err) delivery.ExportApplyRequestHeaders( req, event, &delivery.HTTPTargetConfig{}, ) assert.Equal( t, digest, req.Header.Get(signature.HeaderGitHub), ) }