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.
230 lines
6.7 KiB
Go
230 lines
6.7 KiB
Go
// Package signature verifies that an inbound webhook request really
|
|
// came from the sender an entrypoint was configured for.
|
|
//
|
|
// Verification is optional and per entrypoint. An entrypoint with no
|
|
// scheme configured is not verified at all, which is what every
|
|
// entrypoint was before this package existed. An entrypoint whose
|
|
// configuration is present but incoherent is failed closed, never
|
|
// treated as unverified: the whole point of the feature is that
|
|
// turning it on cannot silently turn itself back off.
|
|
package signature
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// Header names each supported scheme reads its signature from.
|
|
const (
|
|
// HeaderGitHub is GitHub's HMAC-SHA256 signature header. GitHub
|
|
// also sends the older SHA-1 X-Hub-Signature; it is not accepted.
|
|
HeaderGitHub = "X-Hub-Signature-256"
|
|
|
|
// HeaderGitLab is GitLab's plain shared-token header.
|
|
HeaderGitLab = "X-Gitlab-Token"
|
|
)
|
|
|
|
// githubPrefix is the algorithm label GitHub puts in front of the hex
|
|
// digest. It is required, not optional: accepting a bare digest too
|
|
// would mean accepting a spelling no supported sender produces.
|
|
const githubPrefix = "sha256="
|
|
|
|
// ErrConfig marks a failure caused by the entrypoint's stored
|
|
// configuration rather than by the request. A caller must fail these
|
|
// closed — refuse the request — because the alternative is an
|
|
// entrypoint the operator believes is verified silently accepting
|
|
// anything.
|
|
var ErrConfig = errors.New("entrypoint signature configuration invalid")
|
|
|
|
// ErrUnauthorized marks a request that failed verification. A caller
|
|
// answers these 401.
|
|
var ErrUnauthorized = errors.New("inbound signature verification failed")
|
|
|
|
// Configuration failures. None of these carry any part of the secret.
|
|
var (
|
|
errSchemeUnknown = fmt.Errorf(
|
|
"%w: unsupported scheme", ErrConfig,
|
|
)
|
|
errSecretMissing = fmt.Errorf(
|
|
"%w: scheme set with no secret", ErrConfig,
|
|
)
|
|
errSchemeMissing = fmt.Errorf(
|
|
"%w: secret set with no scheme", ErrConfig,
|
|
)
|
|
)
|
|
|
|
// Request failures. These are logged, so none of them carries the
|
|
// value the client sent: under the GitLab scheme that value is a
|
|
// guess at the token, and under either scheme a misconfigured sender
|
|
// could be presenting the real one.
|
|
var (
|
|
errHeaderMissing = fmt.Errorf(
|
|
"%w: signature header absent", ErrUnauthorized,
|
|
)
|
|
errHeaderMalformed = fmt.Errorf(
|
|
"%w: signature header malformed", ErrUnauthorized,
|
|
)
|
|
errSignatureMismatch = fmt.Errorf(
|
|
"%w: signature does not match", ErrUnauthorized,
|
|
)
|
|
)
|
|
|
|
// SchemeInfo describes one supported scheme for the UI.
|
|
type SchemeInfo struct {
|
|
Scheme database.SignatureScheme
|
|
Label string
|
|
Header string
|
|
}
|
|
|
|
// Schemes returns the supported schemes in the order the UI offers
|
|
// them. It returns a fresh slice per call so no caller can edit the
|
|
// set out from under another.
|
|
func Schemes() []SchemeInfo {
|
|
return []SchemeInfo{
|
|
{
|
|
Scheme: database.SignatureSchemeGitHub,
|
|
Label: "GitHub",
|
|
Header: HeaderGitHub,
|
|
},
|
|
{
|
|
Scheme: database.SignatureSchemeGitLab,
|
|
Label: "GitLab",
|
|
Header: HeaderGitLab,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Info returns the description of a supported scheme. It reports
|
|
// false for the empty scheme and for anything unrecognised, which is
|
|
// what a row hand-edited in the database could hold.
|
|
func Info(scheme database.SignatureScheme) (SchemeInfo, bool) {
|
|
for _, s := range Schemes() {
|
|
if s.Scheme == scheme {
|
|
return s, true
|
|
}
|
|
}
|
|
|
|
return SchemeInfo{}, false
|
|
}
|
|
|
|
// Supported reports whether a scheme may be stored on an entrypoint.
|
|
// The empty scheme is supported: it means no verification.
|
|
func Supported(scheme database.SignatureScheme) bool {
|
|
if scheme == database.SignatureSchemeNone {
|
|
return true
|
|
}
|
|
|
|
_, ok := Info(scheme)
|
|
|
|
return ok
|
|
}
|
|
|
|
// Verify checks an inbound request against an entrypoint's
|
|
// configuration and returns nil when the request may be accepted.
|
|
//
|
|
// body must be the raw bytes exactly as received, before any parsing
|
|
// or normalisation: the sender computed its digest over those bytes,
|
|
// so anything that re-encodes them produces a different digest and a
|
|
// spurious rejection. The caller is also responsible for bounding
|
|
// that read; this package hashes what it is handed.
|
|
//
|
|
// Every non-nil error is either ErrConfig or ErrUnauthorized, so a
|
|
// caller can tell "the server is misconfigured" from "the client did
|
|
// not authenticate" with errors.Is.
|
|
func Verify(
|
|
entrypoint *database.Entrypoint,
|
|
header http.Header,
|
|
body []byte,
|
|
) error {
|
|
scheme := entrypoint.SignatureScheme
|
|
secret := entrypoint.SignatureSecret
|
|
|
|
if scheme == database.SignatureSchemeNone {
|
|
// A secret with no scheme names no header and no algorithm,
|
|
// so there is nothing to check it with. Accepting the request
|
|
// would make a half-applied configuration indistinguishable
|
|
// from no configuration at all.
|
|
if secret != "" {
|
|
return errSchemeMissing
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if secret == "" {
|
|
return errSecretMissing
|
|
}
|
|
|
|
switch scheme {
|
|
case database.SignatureSchemeGitHub:
|
|
return verifyGitHub(secret, header.Get(HeaderGitHub), body)
|
|
case database.SignatureSchemeGitLab:
|
|
return verifyGitLab(secret, header.Get(HeaderGitLab))
|
|
case database.SignatureSchemeNone:
|
|
// Handled above; restated so the switch stays exhaustive and
|
|
// adding a scheme has to be decided here.
|
|
return nil
|
|
default:
|
|
return errSchemeUnknown
|
|
}
|
|
}
|
|
|
|
// verifyGitHub checks a GitHub-style X-Hub-Signature-256: the string
|
|
// "sha256=" followed by the hex HMAC-SHA256 of the raw body under the
|
|
// shared secret.
|
|
func verifyGitHub(secret, provided string, body []byte) error {
|
|
if provided == "" {
|
|
return errHeaderMissing
|
|
}
|
|
|
|
encoded, ok := strings.CutPrefix(provided, githubPrefix)
|
|
if !ok {
|
|
return errHeaderMalformed
|
|
}
|
|
|
|
got, err := hex.DecodeString(encoded)
|
|
if err != nil {
|
|
return errHeaderMalformed
|
|
}
|
|
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
|
|
// hash.Hash.Write is documented never to return an error.
|
|
_, _ = mac.Write(body)
|
|
|
|
// hmac.Equal, never ==: string comparison stops at the first
|
|
// differing byte, which tells a client how much of a forged
|
|
// digest it got right and turns forgery into a per-byte search.
|
|
if !hmac.Equal(mac.Sum(nil), got) {
|
|
return errSignatureMismatch
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// verifyGitLab checks a GitLab-style X-Gitlab-Token, which is the
|
|
// shared secret itself rather than a digest over the body.
|
|
//
|
|
// The comparison is constant time in the same way as the HMAC one.
|
|
// hmac.Equal returns early for unequal lengths, so the length of the
|
|
// token is not hidden; its contents are, and length alone does not
|
|
// let a client search for the value.
|
|
func verifyGitLab(secret, provided string) error {
|
|
if provided == "" {
|
|
return errHeaderMissing
|
|
}
|
|
|
|
if !hmac.Equal([]byte(provided), []byte(secret)) {
|
|
return errSignatureMismatch
|
|
}
|
|
|
|
return nil
|
|
}
|