Some checks failed
check / check (push) Failing after 2m31s
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.
343 lines
8.5 KiB
Go
343 lines
8.5 KiB
Go
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",
|
|
},
|
|
{
|
|
// Half a configuration. The receiver 500s every request
|
|
// to this row, so the UI must not call it unverified.
|
|
Path: "p2a",
|
|
SignatureScheme: database.SignatureSchemeGitLab,
|
|
},
|
|
{
|
|
// The other half.
|
|
Path: "p2b",
|
|
SignatureSecret: inboundSecret,
|
|
},
|
|
{
|
|
// 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, 5)
|
|
|
|
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)
|
|
|
|
for _, v := range []handlers.EntrypointView{views[2], views[3]} {
|
|
assert.False(t, v.Configured)
|
|
assert.Equal(t, "misconfigured", v.SchemeLabel)
|
|
assert.Empty(t, v.SchemeHeader)
|
|
}
|
|
|
|
assert.True(t, views[4].Configured)
|
|
assert.Equal(t, "(unavailable)", views[4].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)
|
|
}
|
|
}
|