// 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 }