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.
284 lines
8.7 KiB
Go
284 lines
8.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
|
|
|
|
// HeaderIsDigest reports that Header carries a value derived from
|
|
// the request rather than the shared secret itself, and so may be
|
|
// kept when the request is stored and forwarded.
|
|
//
|
|
// The polarity is deliberate: false — the zero value — means the
|
|
// header is the credential and must be stripped. A scheme added
|
|
// later is therefore stripped unless whoever adds it positively
|
|
// declares the header safe to keep.
|
|
HeaderIsDigest bool
|
|
}
|
|
|
|
// 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,
|
|
// An HMAC over the body, not the key. Keeping it lets an
|
|
// operator see what the sender sent.
|
|
HeaderIsDigest: true,
|
|
},
|
|
{
|
|
Scheme: database.SignatureSchemeGitLab,
|
|
Label: "GitLab",
|
|
Header: HeaderGitLab,
|
|
// X-Gitlab-Token is the shared secret in plaintext.
|
|
HeaderIsDigest: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SanitizeHeaders returns a copy of an accepted request's headers
|
|
// with the entrypoint's credential removed.
|
|
//
|
|
// Under a scheme whose header is the shared secret itself — GitLab's
|
|
// X-Gitlab-Token — every downstream use of the inbound headers is a
|
|
// disclosure of the credential: they are persisted verbatim in the
|
|
// per-webhook event store and forwarded to every delivery target, so
|
|
// a target operator or anyone who reads the event database could
|
|
// forge signed requests to the very entrypoint the secret protects.
|
|
// Stripping happens here, once, above the first write, rather than
|
|
// at each egress, so a new consumer of Event.Headers cannot reopen
|
|
// the leak by forgetting to filter.
|
|
//
|
|
// header is never modified; the caller's request keeps its headers
|
|
// intact for anything that still needs the original.
|
|
//
|
|
// An entrypoint with no scheme, or one whose stored scheme this
|
|
// build does not know, is returned unchanged: there is no configured
|
|
// credential to remove, and the unknown case is refused by Verify
|
|
// before a request reaches storage.
|
|
func SanitizeHeaders(
|
|
entrypoint *database.Entrypoint,
|
|
header http.Header,
|
|
) http.Header {
|
|
clone := header.Clone()
|
|
if clone == nil {
|
|
return header
|
|
}
|
|
|
|
info, ok := Info(entrypoint.SignatureScheme)
|
|
if !ok || info.HeaderIsDigest {
|
|
return clone
|
|
}
|
|
|
|
clone.Del(info.Header)
|
|
|
|
return clone
|
|
}
|
|
|
|
// 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
|
|
}
|