Add optional inbound webhook signature verification (closes #67)
All checks were successful
check / check (push) Successful in 4m23s

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.
This commit is contained in:
2026-08-20 04:24:27 +00:00
parent a13e5b7ded
commit c0f8427259
14 changed files with 1876 additions and 24 deletions

View File

@@ -0,0 +1,325 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"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"
)
// submitEntrypointSecret posts the signature configuration form for
// an entrypoint and returns the recorder.
func submitEntrypointSecret(
t *testing.T,
h *handlers.Handlers,
cookies []*http.Cookie,
webhookID, entrypointID, scheme, secret string,
) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{}
form.Set("signature_scheme", scheme)
form.Set("secret", secret)
req := formRequest(
"/source/"+webhookID+"/entrypoints/"+
entrypointID+"/secret",
cookies,
form,
map[string]string{
paramSourceID: webhookID,
entrypointIDParam: entrypointID,
},
)
w := httptest.NewRecorder()
h.HandleEntrypointSecret().ServeHTTP(w, req)
return w
}
// reloadEntrypoint reads an entrypoint back from the database,
// including the columns the model keeps out of JSON.
func reloadEntrypoint(
t *testing.T,
db *database.Database,
id string,
) database.Entrypoint {
t.Helper()
var ep database.Entrypoint
require.NoError(
t, db.DB().Where("id = ?", id).First(&ep).Error,
)
return ep
}
// TestEntrypointSecretSetRotateAndRemove walks the whole lifecycle
// the UI has to support: turning verification on, rotating the secret
// to a new value, and turning it back off.
func TestEntrypointSecretSetRotateAndRemove(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
wh := seedWebhook(t, db)
ep := seedSignedEntrypoint(
t, db, wh.ID, database.SignatureSchemeNone, "",
)
// Set.
w := submitEntrypointSecret(
t, h, cookies, wh.ID, ep.ID, "github", inboundSecret,
)
require.Equal(t, http.StatusSeeOther, w.Code)
stored := reloadEntrypoint(t, db, ep.ID)
assert.Equal(
t, database.SignatureSchemeGitHub, stored.SignatureScheme,
)
assert.Equal(t, inboundSecret, stored.SignatureSecret)
assert.True(t, stored.SignatureConfigured())
// Rotate: a new secret and a different scheme in one submission.
// The new value is submitted with surrounding whitespace, the way
// a secret pasted out of a password manager arrives; storing that
// verbatim would make every later request fail verification with
// nothing visible on either side to explain it.
const rotated = "QQROTATEDSECRETQQ"
w = submitEntrypointSecret(
t, h, cookies, wh.ID, ep.ID, "gitlab", " "+rotated+"\t",
)
require.Equal(t, http.StatusSeeOther, w.Code)
stored = reloadEntrypoint(t, db, ep.ID)
assert.Equal(
t, database.SignatureSchemeGitLab, stored.SignatureScheme,
)
assert.Equal(t, rotated, stored.SignatureSecret)
// Remove. The secret has to go with the scheme: a stored
// credential nothing reads is one more copy to leak.
w = submitEntrypointSecret(t, h, cookies, wh.ID, ep.ID, "", "")
require.Equal(t, http.StatusSeeOther, w.Code)
stored = reloadEntrypoint(t, db, ep.ID)
assert.Equal(
t, database.SignatureSchemeNone, stored.SignatureScheme,
)
assert.Empty(t, stored.SignatureSecret)
assert.False(t, stored.SignatureConfigured())
}
// TestEntrypointSecretRejectsBadInput proves the form cannot create a
// row the receiver would later have to refuse. Both rejections leave
// the stored configuration untouched rather than half-applied.
func TestEntrypointSecretRejectsBadInput(t *testing.T) {
t.Parallel()
cases := []struct {
name string
scheme string
secret string
}{
{
name: "unsupported scheme",
scheme: "stripe",
secret: inboundSecret,
},
{
name: "scheme with no secret",
scheme: "github",
secret: "",
},
{
// Whitespace is stripped, so a secret of spaces is an
// empty one.
name: "scheme with blank secret",
scheme: "github",
secret: " ",
},
}
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
for _, tc := range cases {
wh := seedWebhook(t, db)
ep := seedSignedEntrypoint(
t, db, wh.ID,
database.SignatureSchemeGitLab, inboundSecret,
)
w := submitEntrypointSecret(
t, h, cookies, wh.ID, ep.ID, tc.scheme, tc.secret,
)
assert.Equal(
t, http.StatusBadRequest, w.Code, "case %s", tc.name,
)
stored := reloadEntrypoint(t, db, ep.ID)
assert.Equal(
t,
database.SignatureSchemeGitLab,
stored.SignatureScheme,
"case %s", tc.name,
)
assert.Equal(
t, inboundSecret, stored.SignatureSecret,
"case %s", tc.name,
)
}
}
// TestEntrypointSecretRequiresOwnership proves the configuration
// endpoint is bound by the same ownership check as the rest of the
// webhook's pages: another user's entrypoint is a 404, and the secret
// is not touched.
func TestEntrypointSecretRequiresOwnership(t *testing.T) {
t.Parallel()
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)
ep := seedSignedEntrypoint(
t, db, wh.ID,
database.SignatureSchemeGitLab, inboundSecret,
)
stranger := authenticatedCookies(
t, sess, "someone-else", "someoneelse",
)
w := submitEntrypointSecret(
t, h, stranger, wh.ID, ep.ID, "github", "hijacked",
)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(
t,
inboundSecret,
reloadEntrypoint(t, db, ep.ID).SignatureSecret,
)
}
// TestHandleSourceDetail_MasksEntrypointSecret is the regression test
// for the credential on the entrypoint: the page has to say that
// verification is configured and which header carries it, without the
// secret itself ever reaching the rendered HTML.
func TestHandleSourceDetail_MasksEntrypointSecret(t *testing.T) {
t.Parallel()
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)
seedSignedEntrypoint(
t, db, wh.ID,
database.SignatureSchemeGitHub, inboundSecret,
)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.NotContains(t, body, inboundSecret)
assert.Contains(t, body, "GitHub")
assert.Contains(t, body, "X-Hub-Signature-256")
}
// TestEntrypointViewsDropTheSecret pins the projection itself, so the
// barrier survives a template rewrite that stops rendering the field
// the page test above looks at.
func TestEntrypointViewsDropTheSecret(t *testing.T) {
t.Parallel()
views := handlers.NewEntrypointViews([]database.Entrypoint{
{
Path: "p1",
Active: true,
SignatureScheme: database.SignatureSchemeGitHub,
SignatureSecret: inboundSecret,
},
{
Path: "p2",
},
{
// A scheme this build does not know: described as
// unavailable, never echoed back.
Path: "p3",
SignatureScheme: database.SignatureScheme("stripe"),
SignatureSecret: inboundSecret,
},
})
require.Len(t, views, 3)
assert.True(t, views[0].Configured)
assert.Equal(t, "GitHub", views[0].SchemeLabel)
assert.Equal(t, "X-Hub-Signature-256", views[0].SchemeHeader)
assert.False(t, views[1].Configured)
assert.Equal(t, "not verified", views[1].SchemeLabel)
assert.Empty(t, views[1].SchemeHeader)
assert.True(t, views[2].Configured)
assert.Equal(t, "(unavailable)", views[2].SchemeLabel)
// The struct has no field that could carry the secret, so this
// fails to compile rather than fails at runtime if one is added
// and populated. The assertion covers the labels it derives.
for _, v := range views {
assert.NotContains(t, v.SchemeLabel, inboundSecret)
assert.NotContains(t, v.SchemeHeader, inboundSecret)
assert.NotContains(t, string(v.Scheme), inboundSecret)
}
}

View File

@@ -0,0 +1,80 @@
package handlers
import (
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/signature"
)
// signatureUnavailable is what an entrypoint's scheme renders as when
// the stored value is not one this build supports. The stored string
// is never echoed as a fallback: it is operator-supplied and the row
// is already in a state the receiver refuses, so the UI says so
// rather than inventing a description for it.
const signatureUnavailable = "(unavailable)"
// signatureNotVerified is the label for an entrypoint that performs
// no inbound verification.
const signatureNotVerified = "not verified"
// EntrypointView is the display-safe projection of an entrypoint for
// the UI. It deliberately has no secret field, so no template —
// present or future — can render the shared secret, in the same way
// delivery.TargetView keeps a target's stored credential away from
// one.
type EntrypointView struct {
ID string
Path string
Description string
Active bool
// Configured reports whether inbound requests to this entrypoint
// are verified.
Configured bool
// Scheme is the stored scheme, carried so the form can preselect
// it. It names an algorithm, not a secret.
Scheme database.SignatureScheme
// SchemeLabel and SchemeHeader describe the configured scheme for
// display: the sender's name, and the header its signature
// arrives in.
SchemeLabel string
SchemeHeader string
}
// NewEntrypointViews projects entrypoints for rendering, dropping the
// shared secret on the way.
func NewEntrypointViews(
entrypoints []database.Entrypoint,
) []EntrypointView {
views := make([]EntrypointView, 0, len(entrypoints))
for i := range entrypoints {
e := &entrypoints[i]
view := EntrypointView{
ID: e.ID,
Path: e.Path,
Description: e.Description,
Active: e.Active,
Configured: e.SignatureConfigured(),
Scheme: e.SignatureScheme,
SchemeLabel: signatureNotVerified,
SchemeHeader: "",
}
if view.Configured {
view.SchemeLabel = signatureUnavailable
info, ok := signature.Info(e.SignatureScheme)
if ok {
view.SchemeLabel = info.Label
view.SchemeHeader = info.Header
}
}
views = append(views, view)
}
return views
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/signature"
)
// WebhookListItem holds data for the webhook list view.
@@ -414,13 +415,16 @@ func (h *Handlers) renderSourceDetail(
// receivers; html/template cannot address a value stored in a map.
data := map[string]any{
tmplKeyWebhook: &webhook,
"Entrypoints": entrypoints,
// Targets are projected to a display-safe view: the
// stored config blob holds credentials and must never
// Entrypoints and targets are both projected to
// display-safe views: an entrypoint carries the shared
// secret its senders sign with and a target's stored
// config blob holds a credential, and neither must ever
// reach a template.
"Targets": delivery.NewTargetViews(targets),
"Events": events,
"BaseURL": scheme + "://" + host,
"Entrypoints": NewEntrypointViews(entrypoints),
"Targets": delivery.NewTargetViews(targets),
"SignatureSchemes": signature.Schemes(),
"Events": events,
"BaseURL": scheme + "://" + host,
}
h.renderTemplate(w, r, "source_detail.html", data)
@@ -972,6 +976,145 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
}
}
// HandleEntrypointSecret sets, rotates or removes the shared secret
// an entrypoint verifies inbound requests with.
//
// Setting and rotating are the same operation: the form always takes
// the secret afresh and the stored value is never sent to the browser
// to be edited, so there is no path by which the page can display a
// credential it holds. Rotation is therefore "submit the new secret",
// and the operator already has that value — both supported senders
// require them to enter the same string on the sender's side, so
// there is no generated value for webhooker to reveal once.
func (h *Handlers) HandleEntrypointSecret() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
if !ok {
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
var entrypoint database.Entrypoint
err = h.db.DB().Where(
"id = ? AND webhook_id = ?",
chi.URLParam(r, "entrypointID"), webhook.ID,
).First(&entrypoint).Error
if err != nil {
http.NotFound(w, r)
return
}
h.applyEntrypointSecret(w, r, &entrypoint)
}
}
// applyEntrypointSecret validates the submitted scheme and secret and
// stores them.
//
// A scheme this build does not support is a 400, never a stored value
// the receiver would later have to interpret: the receiver fails such
// a row closed, so letting one be created would take the entrypoint
// offline through a form that reported success.
func (h *Handlers) applyEntrypointSecret(
w http.ResponseWriter,
r *http.Request,
entrypoint *database.Entrypoint,
) {
// PostFormValue, not FormValue: a credential must come from the
// body. FormValue falls back to the query string, and the request
// line — unlike the body — is what logs, proxies, Referer headers
// and error trackers record.
scheme := database.SignatureScheme(
r.PostFormValue("signature_scheme"),
)
// Surrounding whitespace is stripped, because a secret pasted from
// a password manager routinely carries some and the resulting
// mismatch is undiagnosable from the sender's side. A secret whose
// own first or last character is a space cannot be stored; the
// README says so.
secret := strings.TrimSpace(r.PostFormValue("secret"))
if !signature.Supported(scheme) {
http.Error(
w, "Invalid signature scheme",
http.StatusBadRequest,
)
return
}
if scheme == database.SignatureSchemeNone {
// Turning verification off drops the secret with it: a stored
// credential nothing reads is one more copy to leak, and
// Verify refuses that pairing in any case.
secret = ""
} else if secret == "" {
http.Error(
w,
"A shared secret is required for this signature scheme.",
http.StatusBadRequest,
)
return
}
h.storeEntrypointSecret(w, r, entrypoint, scheme, secret)
}
// storeEntrypointSecret writes a validated scheme and secret to an
// entrypoint and returns the operator to the webhook page.
func (h *Handlers) storeEntrypointSecret(
w http.ResponseWriter,
r *http.Request,
entrypoint *database.Entrypoint,
scheme database.SignatureScheme,
secret string,
) {
// Updates with a map rather than a struct: a struct update skips
// zero values, and the empty pair is exactly what has to be
// written when verification is being turned off.
err := h.db.DB().Model(entrypoint).Updates(map[string]any{
"signature_scheme": scheme,
"signature_secret": secret,
}).Error
if err != nil {
// The error is logged by serverError; GORM's error text
// carries the statement, not the bound values, so the secret
// does not travel with it.
h.serverError(
w, "failed to update entrypoint signature", err,
)
return
}
h.log.Info(
"entrypoint signature configuration updated",
"entrypoint_id", entrypoint.ID,
"webhook_id", entrypoint.WebhookID,
"scheme", string(scheme),
)
http.Redirect(
w, r,
"/source/"+entrypoint.WebhookID,
http.StatusSeeOther,
)
}
// HandleTargetCreate handles adding a new target to a webhook.
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View File

@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
"sneak.berlin/go/webhooker/internal/signature"
)
// Template data keys the page templates read. The handlers package has
@@ -268,12 +269,16 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
body := renderPage(t, h, sess, "source_detail.html", map[string]any{
dataKeyWebhook: webhook,
"Entrypoints": []database.Entrypoint{entrypoint},
// The handler passes delivery.NewTargetViews(targets), never
// raw targets, so the test data has to have that same shape.
"Targets": delivery.NewTargetViews(nil),
"Events": []database.Event{},
"BaseURL": "https://hooks.example.com",
// The handler passes projected views, never raw rows — an
// entrypoint carries its shared secret and a target its
// stored credential — so the test data has that same shape.
"Entrypoints": handlers.NewEntrypointViews(
[]database.Entrypoint{entrypoint},
),
"Targets": delivery.NewTargetViews(nil),
"SignatureSchemes": signature.Schemes(),
"Events": []database.Event{},
"BaseURL": "https://hooks.example.com",
})
assert.Contains(

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
@@ -10,6 +11,7 @@ import (
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/logfield"
"sneak.berlin/go/webhooker/internal/signature"
)
const (
@@ -69,8 +71,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
}
}
// processWebhookRequest reads the body, serializes headers,
// loads targets, and delivers the event.
// processWebhookRequest reads the body, verifies the sender,
// serializes headers, loads targets, and delivers the event.
func (h *Handlers) processWebhookRequest(
w http.ResponseWriter,
r *http.Request,
@@ -81,6 +83,17 @@ func (h *Handlers) processWebhookRequest(
return
}
// Before anything is written. An unverified request must leave no
// event row, no delivery row and no delivery task behind, so this
// sits above every write rather than inside the transaction that
// performs them. It has to sit below the body read because the
// signature is computed over the body; readWebhookBody is what
// bounds that read, so an unauthenticated sender still cannot make
// the process hold more than the 1 MB cap.
if !h.verifyInboundSignature(w, entrypoint, r.Header, body) {
return
}
headersJSON, err := json.Marshal(r.Header)
if err != nil {
h.serverError(w, "failed to serialize headers", err)
@@ -100,6 +113,63 @@ func (h *Handlers) processWebhookRequest(
)
}
// verifyInboundSignature authenticates the request against the
// entrypoint's configured secret, reporting false once it has written
// the response.
//
// An entrypoint with no secret configured is not checked and this
// returns true, which is the unchanged behaviour every existing
// entrypoint keeps.
//
// A configuration that cannot be applied — an unknown scheme, or one
// half of the pair missing — is a 500, not a 401: the request may well
// be authentic, and calling it unauthorized would tell a legitimate
// sender to go fix its own signing. Either way it is refused. Failing
// open here would mean an entrypoint the operator has protected
// quietly accepting anything.
func (h *Handlers) verifyInboundSignature(
w http.ResponseWriter,
entrypoint database.Entrypoint,
header http.Header,
body []byte,
) bool {
err := signature.Verify(&entrypoint, header, body)
if err == nil {
return true
}
if errors.Is(err, signature.ErrConfig) {
h.log.Error(
"entrypoint signature configuration cannot be applied",
"entrypoint_id", entrypoint.ID,
"webhook_id", entrypoint.WebhookID,
"error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return false
}
// Every field here is bounded and none is client-chosen: the ids
// are ours, the scheme is one of a fixed set, and the error is a
// static string carrying no part of the secret or of what the
// client presented. Reaching this line also requires a real
// entrypoint UUID, so it is not a line a stranger can drive.
h.log.Warn(
"inbound signature verification failed",
"entrypoint_id", entrypoint.ID,
"webhook_id", entrypoint.WebhookID,
"scheme", string(entrypoint.SignatureScheme),
"error", err,
)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
}
// loadActiveTargets returns all active targets for a webhook.
func (h *Handlers) loadActiveTargets(
webhookID string,

View File

@@ -0,0 +1,362 @@
package handlers_test
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/signature"
)
const (
// inboundSecret is the shared secret the signed-receiver tests
// configure on their entrypoint. It doubles as a marker: no log
// line and no rendered page may contain it.
inboundSecret = "QQINBOUNDSECRETQQ"
// inboundBody is the payload the sender signs.
inboundBody = `{"zen":"Non-blocking is better than blocking."}`
// entrypointIDParam is the chi URL parameter naming an entrypoint.
entrypointIDParam = "entrypointID"
)
// hubSignature returns the X-Hub-Signature-256 value a GitHub sender
// holding secret would send for inboundBody.
func hubSignature(secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(inboundBody))
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
// seedSignedEntrypoint inserts an active entrypoint for a webhook
// with the given signature configuration and returns it.
func seedSignedEntrypoint(
t *testing.T,
db *database.Database,
webhookID string,
scheme database.SignatureScheme,
secret string,
) *database.Entrypoint {
t.Helper()
ep := &database.Entrypoint{
WebhookID: webhookID,
Path: "path-" + webhookID,
Description: "signed",
Active: true,
SignatureScheme: scheme,
SignatureSecret: secret,
}
require.NoError(
t,
db.DB().Omit(clause.Associations).Create(ep).Error,
)
return ep
}
// postToEntrypoint drives the real receiver handler at an
// entrypoint's path with one optional header set.
func postToEntrypoint(
t *testing.T,
h *handlers.Handlers,
path, body, headerName, headerValue string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/webhook/"+path,
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
if headerName != "" {
req.Header.Set(headerName, headerValue)
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("uuid", path)
req = req.WithContext(
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
)
w := httptest.NewRecorder()
h.HandleWebhook().ServeHTTP(w, req)
return w
}
// storedEvents counts the event rows a webhook's per-webhook database
// holds. A database that was never opened holds none, which is the
// state a rejected request has to leave behind.
func storedEvents(
t *testing.T,
mgr *database.WebhookDBManager,
webhookID string,
) int64 {
t.Helper()
if !mgr.DBExists(webhookID) {
return 0
}
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
var count int64
require.NoError(
t,
db.Model(&database.Event{}).
Where("webhook_id = ?", webhookID).
Count(&count).Error,
)
return count
}
// signedReceiverCase is one inbound request against an entrypoint
// with a given stored signature configuration.
type signedReceiverCase struct {
name string
scheme database.SignatureScheme
secret string
headerName string
headerValue string
body string
wantStatus int
}
// signedReceiverCases covers each supported scheme with a valid
// signature, an invalid one and none at all, plus the two states that
// are not "a client got it wrong": an entrypoint with nothing
// configured, and one whose stored configuration cannot be applied.
func signedReceiverCases() []signedReceiverCase {
return append(
schemeReceiverCases(), unverifiedReceiverCases()...,
)
}
// schemeReceiverCases covers the two supported schemes.
func schemeReceiverCases() []signedReceiverCase {
return []signedReceiverCase{
{
name: "github valid",
scheme: database.SignatureSchemeGitHub,
secret: inboundSecret,
headerName: signature.HeaderGitHub,
headerValue: hubSignature(inboundSecret),
body: inboundBody,
wantStatus: http.StatusOK,
},
{
name: "github wrong secret",
scheme: database.SignatureSchemeGitHub,
secret: inboundSecret,
headerName: signature.HeaderGitHub,
headerValue: hubSignature("wrong"),
body: inboundBody,
wantStatus: http.StatusUnauthorized,
},
{
// A digest that was valid for a different body: the
// check is over the bytes as received.
name: "github body tampered",
scheme: database.SignatureSchemeGitHub,
secret: inboundSecret,
headerName: signature.HeaderGitHub,
headerValue: hubSignature(inboundSecret),
body: inboundBody + " ",
wantStatus: http.StatusUnauthorized,
},
{
name: "github unsigned",
scheme: database.SignatureSchemeGitHub,
secret: inboundSecret,
body: inboundBody,
wantStatus: http.StatusUnauthorized,
},
{
name: "gitlab valid",
scheme: database.SignatureSchemeGitLab,
secret: inboundSecret,
headerName: signature.HeaderGitLab,
headerValue: inboundSecret,
body: inboundBody,
wantStatus: http.StatusOK,
},
{
name: "gitlab wrong token",
scheme: database.SignatureSchemeGitLab,
secret: inboundSecret,
headerName: signature.HeaderGitLab,
headerValue: "wrong",
body: inboundBody,
wantStatus: http.StatusUnauthorized,
},
{
name: "gitlab unsigned",
scheme: database.SignatureSchemeGitLab,
secret: inboundSecret,
body: inboundBody,
wantStatus: http.StatusUnauthorized,
},
}
}
// unverifiedReceiverCases covers the two entrypoint states that are
// not about a client getting its signature wrong: nothing configured
// at all, and a configuration the receiver cannot apply.
func unverifiedReceiverCases() []signedReceiverCase {
return []signedReceiverCase{
{
// The pass-through case. An entrypoint with nothing
// configured is what every deployment already has, and
// it must keep accepting unsigned requests so that an
// upgrade does not lock an operator out of their own
// receivers.
name: "unconfigured accepts unsigned",
scheme: database.SignatureSchemeNone,
body: inboundBody,
wantStatus: http.StatusOK,
},
{
// A stray signature header changes nothing when nothing
// is configured to check it.
name: "unconfigured ignores a stray header",
scheme: database.SignatureSchemeNone,
headerName: signature.HeaderGitHub,
headerValue: "sha256=deadbeef",
body: inboundBody,
wantStatus: http.StatusOK,
},
{
// A scheme this build cannot apply, reachable only by
// editing the database: refused, not waved through as
// unverified.
name: "unknown scheme fails closed",
scheme: database.SignatureScheme("stripe"),
secret: inboundSecret,
headerName: signature.HeaderGitHub,
headerValue: hubSignature(inboundSecret),
body: inboundBody,
wantStatus: http.StatusInternalServerError,
},
}
}
// TestReceiverVerifiesConfiguredEntrypoints is the load-bearing test
// for the feature: for each supported scheme a correctly signed
// request is accepted and stored, and an incorrectly signed or
// unsigned one is answered 401 having stored nothing.
//
// The event count is the half that matters most. A rejection that
// still wrote a row would leave the receiver a place for a stranger
// who knows a URL to deposit content, which is exactly what the
// signature is there to prevent.
//
// The cases share one application and take a webhook each, rather
// than each standing up its own: every newTestApp seeds an admin user
// and so pays an Argon2id hash at 64 MB, and this package's test
// budget is not large enough to spend one per table row.
func TestReceiverVerifiesConfiguredEntrypoints(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
for _, tc := range signedReceiverCases() {
wh := seedWebhook(t, db)
ep := seedSignedEntrypoint(
t, db, wh.ID, tc.scheme, tc.secret,
)
w := postToEntrypoint(
t, h, ep.Path, tc.body,
tc.headerName, tc.headerValue,
)
assert.Equal(t, tc.wantStatus, w.Code, "case %s", tc.name)
want := int64(0)
if tc.wantStatus == http.StatusOK {
want = 1
}
assert.Equal(
t, want, storedEvents(t, mgr, wh.ID),
"case %s: stored event rows after a %d response",
tc.name, w.Code,
)
}
}
// TestReceiverLogsNoSecret proves the rejection path does not write
// the shared secret, or what the client presented, into the log. A
// GitLab token arrives as the credential itself, so echoing the
// header value would put a live secret in the log of every deployment
// whose sender is briefly misconfigured.
func TestReceiverLogsNoSecret(t *testing.T) {
t.Parallel()
const presented = "QQPRESENTEDVALUEQQ"
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
var buf bytes.Buffer
h.SetLogForTest(slog.New(slog.NewJSONHandler(&buf, nil)))
wh := seedWebhook(t, db)
ep := seedSignedEntrypoint(
t, db, wh.ID,
database.SignatureSchemeGitLab, inboundSecret,
)
w := postToEntrypoint(
t, h, ep.Path, inboundBody,
signature.HeaderGitLab, presented,
)
require.Equal(t, http.StatusUnauthorized, w.Code)
// The rejection is recorded at all — a silent 401 leaves an
// operator no way to see a sender failing to authenticate.
assert.Contains(t, buf.String(), "verification failed")
assert.NotContains(t, buf.String(), inboundSecret)
assert.NotContains(t, buf.String(), presented)
}