Files
webhooker/internal/delivery/target_http_secret_test.go
sneak 88e283f728
Some checks failed
check / check (push) Failing after 2m31s
Add optional inbound webhook signature verification (closes #67)
A receiver URL was a bare v4 UUID and nothing else: anyone who learned
it could store events and, because inbound headers are forwarded to
targets almost verbatim, choose what the downstream service received.

Entrypoints gain an optional scheme/secret pair. GitHub's
X-Hub-Signature-256 (HMAC-SHA256 hex over the raw body) and GitLab's
X-Gitlab-Token (plain shared token) are supported; both compare with
hmac.Equal. With nothing configured an entrypoint behaves exactly as
before, which is also where every pre-existing row lands after
AutoMigrate adds the columns.

Verification runs after the capped body read and before the first
write, so a rejected request leaves no event row, no delivery row and
no delivery task. A configuration the receiver cannot apply — unknown
scheme, or one half of the pair missing — is refused with a 500 rather
than falling back to unverified.

The secret is credential-bearing and is stored in the clear because
HMAC needs the key itself. It is excluded from JSON, kept out of
templates by a new handlers.EntrypointView projection, and absent from
every log line including the rejection path. The UI sets and rotates
it through one form that never renders the stored value.

Under the GitLab scheme the signature header is the secret rather than
a digest over the request, so an accepted request's headers are cloned
and the configured scheme's credential header dropped before they are
serialized onto the event. Stored headers are persisted verbatim in
the per-webhook database and replayed onto every outbound delivery, so
keeping the token would put it in every backup and hand every target
operator the means to forge signed requests to the entrypoint it
authenticates. Stripping sits once above the first write rather than
at each egress, and is driven by the scheme's own description with
stripping as the default: a scheme added later is covered unless it
declares its header a digest, as GitHub's HMAC over the body does.

An entrypoint holding one half of the pair now renders as
misconfigured rather than as unverified, and the scheme selector
follows the stored scheme so such a row no longer marks two options
selected.
2026-08-20 05:26:07 +00:00

143 lines
3.8 KiB
Go

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),
)
}