Some checks failed
check / check (push) Superseded by a newer commit; never tested
The receiver had no inbound authentication of any kind: /webhook/{uuid}
was mounted behind a rate limiter alone, so the only thing protecting an
entrypoint was the secrecy of a v4 UUID in a URL path. Inbound headers are
forwarded almost verbatim to the target, so anyone who learned the URL
also chose the headers the downstream service received.
Adds an optional per-entrypoint secret with two schemes: github
(X-Hub-Signature-256, HMAC-SHA256 hex over the raw body) and gitlab
(X-Gitlab-Token, a plain shared token). Comparison is constant-time, the
HMAC is computed over the raw body before any parsing, and rejection
happens before persistence -- an unauthenticated request creates no event
row. An entrypoint with no secret behaves exactly as before, including
every row that predates this change.
The scheme's credential header is stripped from the header map before it
is marshalled into Event.Headers, so the GitLab token reaches neither the
event store nor any delivery target. SchemeInfo.HeaderIsDigest defaults to
false meaning strip, so a scheme added later is protected unless its
header is positively declared a digest.
139 lines
3.5 KiB
Go
139 lines
3.5 KiB
Go
package database_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// keptField is a non-secret value planted alongside each secret, so
|
|
// the assertions below cannot pass by the model marshalling to nothing.
|
|
const keptField = "keepme"
|
|
|
|
// marshalModel encodes a model the way a future JSON handler would.
|
|
func marshalModel(t *testing.T, v any) string {
|
|
t.Helper()
|
|
|
|
encoded, err := json.Marshal(v)
|
|
require.NoError(t, err)
|
|
|
|
return string(encoded)
|
|
}
|
|
|
|
// TestModelsDoNotMarshalTheirSecrets pins the barrier for the JSON
|
|
// path. The /api/v1 route group exists and is empty; delivery's
|
|
// TargetView masks the credential for the HTML path only, so without
|
|
// these tags the first handler that marshals a model serialises the
|
|
// secret with it. Each field below is a live credential:
|
|
//
|
|
// - Target.Config holds an incoming-webhook URL whose path segments
|
|
// are the bearer token.
|
|
// - APIKey.Key is a bearer token outright.
|
|
// - Setting.Value holds the session encryption key.
|
|
// - User.Password holds the Argon2 hash, and was already tagged.
|
|
// - Entrypoint.SignatureSecret is the secret its senders sign with,
|
|
// stored in the clear because HMAC verification needs the key.
|
|
func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const marker = "QQMODELMARKERQQ"
|
|
|
|
cases := []struct {
|
|
name string
|
|
model any
|
|
}{
|
|
{
|
|
name: "target config",
|
|
model: database.Target{
|
|
Name: keptField,
|
|
Type: database.TargetTypeSlack,
|
|
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
|
},
|
|
},
|
|
{
|
|
name: "api key",
|
|
model: database.APIKey{
|
|
Description: keptField,
|
|
Key: marker,
|
|
},
|
|
},
|
|
{
|
|
name: "setting value",
|
|
model: database.Setting{
|
|
Key: keptField,
|
|
Value: marker,
|
|
},
|
|
},
|
|
{
|
|
name: "user password hash",
|
|
model: database.User{
|
|
Username: keptField,
|
|
Password: marker,
|
|
},
|
|
},
|
|
{
|
|
name: "entrypoint signature secret",
|
|
model: database.Entrypoint{
|
|
Description: keptField,
|
|
SignatureScheme: database.SignatureSchemeGitHub,
|
|
SignatureSecret: marker,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
encoded := marshalModel(t, tc.model)
|
|
|
|
assert.NotContains(t, encoded, marker)
|
|
assert.Contains(t, encoded, keptField)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWebhookMarshalsNoTargetConfig covers the nested case: a webhook
|
|
// marshalled with its targets preloaded must not carry the credential
|
|
// through the association either.
|
|
func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const marker = "QQNESTEDMARKERQQ"
|
|
|
|
encoded := marshalModel(t, database.Webhook{
|
|
Name: keptField,
|
|
Targets: []database.Target{{
|
|
Name: "slack",
|
|
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
|
}},
|
|
})
|
|
|
|
assert.NotContains(t, encoded, marker)
|
|
assert.Contains(t, encoded, keptField)
|
|
}
|
|
|
|
// TestWebhookMarshalsNoEntrypointSecret covers the same nested case
|
|
// for the entrypoint's inbound signature secret, which reaches a
|
|
// marshalled webhook through the Entrypoints association.
|
|
func TestWebhookMarshalsNoEntrypointSecret(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const marker = "QQENTRYPOINTMARKERQQ"
|
|
|
|
encoded := marshalModel(t, database.Webhook{
|
|
Name: keptField,
|
|
Entrypoints: []database.Entrypoint{{
|
|
Path: "some-uuid",
|
|
SignatureScheme: database.SignatureSchemeGitLab,
|
|
SignatureSecret: marker,
|
|
}},
|
|
})
|
|
|
|
assert.NotContains(t, encoded, marker)
|
|
assert.Contains(t, encoded, keptField)
|
|
}
|