diff --git a/docs/Hook-Examples.md b/docs/Hook-Examples.md index 7d079322..cff18181 100644 --- a/docs/Hook-Examples.md +++ b/docs/Hook-Examples.md @@ -25,7 +25,7 @@ although the examples on this page all use the JSON format. ## Incoming Github webhook -This example works on 2.8+ versions of Webhook - if you are on a previous series, change `payload-hmac-sha1` to `payload-hash-sha1`. +This example works on 2.9+ versions of Webhook - if you are on a previous series, change the `check-signature` block to an equivalent `match` block, see the [Hook-Rules](Hook-Rules.md#legacy-match-rules-for-signatures) page for full details. ```json [ @@ -53,11 +53,11 @@ This example works on 2.8+ versions of Webhook - if you are on a previous series "and": [ { - "match": + "check-signature": { - "type": "payload-hmac-sha1", + "algorithm": "sha1", "secret": "mysecret", - "parameter": + "signature": { "source": "header", "name": "X-Hub-Signature" @@ -185,11 +185,11 @@ Values in the request body can be accessed in the command or to the match rule b "and": [ { - "match": + "check-signature": { - "type": "payload-hmac-sha256", + "algorithm": "sha256", "secret": "mysecret", - "parameter": + "signature": { "source": "header", "name": "X-Gogs-Signature" diff --git a/docs/Hook-Rules.md b/docs/Hook-Rules.md index 84914ca2..ad3cdac5 100644 --- a/docs/Hook-Rules.md +++ b/docs/Hook-Rules.md @@ -9,11 +9,12 @@ * [Match](#match) * [Match value](#match-value) * [Match regex](#match-regex) + * [Match Whitelisted IP range](#match-whitelisted-ip-range) + * [Match scalr-signature](#match-scalr-signature) +* [Check signature](#check-signature) * [Match payload-hmac-sha1](#match-payload-hmac-sha1) * [Match payload-hmac-sha256](#match-payload-hmac-sha256) * [Match payload-hmac-sha512](#match-payload-hmac-sha512) - * [Match Whitelisted IP range](#match-whitelisted-ip-range) - * [Match scalr-signature](#match-scalr-signature) ## And *And rule* will evaluate to _true_, if and only if all of the sub rules evaluate to _true_. @@ -183,106 +184,120 @@ For the regex syntax, check out } ``` -### Match payload-hmac-sha1 -Validate the HMAC of the payload using the SHA1 hash and the given *secret*. +### Match Whitelisted IP range + +The IP can be IPv4- or IPv6-formatted, using [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_blocks). To match a single IP address only, use `/32`. + ```json { "match": { - "type": "payload-hmac-sha1", - "secret": "yoursecret", - "parameter": - { - "source": "header", - "name": "X-Hub-Signature" - } + "type": "ip-whitelist", + "ip-range": "192.168.0.1/24" } } ``` -Note that if multiple signatures were passed via a comma separated string, each -will be tried unless a match is found. For example: +Note this does not work if webhook is running behind a reverse proxy, as the "client IP" will either not be available at all (if webhook is using a Unix socket or named pipe) or it will be the address of the _proxy_, not of the real client. You will probably need to enforce client IP restrictions in the reverse proxy itself, before forwarding the requests to webhook. -``` -X-Hub-Signature: sha1=the-first-signature,sha1=the-second-signature -``` +### Match scalr-signature + +The trigger rule checks the scalr signature and also checks that the request was signed less than 5 minutes before it was received. +A unique signing key is generated for each webhook endpoint URL you register in Scalr. +Given the time check make sure that NTP is enabled on both your Scalr and webhook server to prevent any issues -### Match payload-hmac-sha256 -Validate the HMAC of the payload using the SHA256 hash and the given *secret*. ```json { "match": { - "type": "payload-hmac-sha256", - "secret": "yoursecret", - "parameter": - { - "source": "header", - "name": "X-Signature" - } + "type": "scalr-signature", + "secret": "Scalr-provided signing key" } } ``` -Note that if multiple signatures were passed via a comma separated string, each -will be tried unless a match is found. For example: +## Check Signature -``` -X-Hub-Signature: sha256=the-first-signature,sha256=the-second-signature -``` +Many webhook protocols involve the hook sender computing an [HMAC](https://en.wikipedia.org/wiki/HMAC) _signature_ over the request content using a shared secret key, and sending the expected signature value as part of the webhook call. The webhook recipient can then compute their own value for the signature using the same secret key and verify that value against the one supplied by the sender. Since the sender and receiver are (or at least _should be_) the only parties that have knowledge of the secret, a matching signature guarantees that the payload is valid and was created by the legitimate sender. + +The `"check-signature"` rule type is used to validate these kinds of signatures. In its simplest form you just specify the _algorithm_ (`sha1`, `sha256` or `sha512`), the _secret_, and where in the request to find the signature (typically a header or a query parameter). Webhook will compute the HMAC over the whole of the request body using the supplied secret, and compare the result to the one taken from the request -### Match payload-hmac-sha512 -Validate the HMAC of the payload using the SHA512 hash and the given *secret*. ```json { - "match": + "check-signature": { - "type": "payload-hmac-sha512", + "algorithm": "sha256", "secret": "yoursecret", - "parameter": + "signature": { "source": "header", - "name": "X-Signature" + "name": "X-Hub-Signature" } } } ``` Note that if multiple signatures were passed via a comma separated string, each -will be tried unless a match is found. For example: +will be tried unless a match is found, and any `algorithm=` prefix is stripped off +each signature value before comparison. This allows for cases where the sender includes +several signatures with different algorithms in the same header, e.g.: ``` -X-Hub-Signature: sha512=the-first-signature,sha512=the-second-signature +X-Hub-Signature: sha1=the-sha1-signature,sha256=the-sha256-signature ``` -### Match Whitelisted IP range +If the sender computes the signature over something other than just the request body then you can optionally provide a `"string-to-sign"` argument. Usually this will be a template that assembles the string-to-sign from different parts of the request (one of which could be the body). For example this would compute a signature over the values of the `X-Request-Id` header, `Date` header, and request body, separated by line breaks: -The IP can be IPv4- or IPv6-formatted, using [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_blocks). To match a single IP address only, use `/32`. +```yaml +check-signature: + algorithm: sha512 + secret: 5uper5eecret + signature: + source: header + name: X-Hook-Signature + string-to-sign: + source: template + name: | + {{- printf "%s\r\n" (.GetHeader "x-request-id") -}} + {{- printf "%s\r\n" (.GetHeader "date") -}} + {{- .BodyText -}} +``` + +Note that signature algorithms can be very particular about whether "line breaks" are unix style LF or Windows-style CR+LF. It is safest to be explicit, as in the above example, using `{{- -}}` blocks (that ignore the white space within the template itself either side of the block) and `printf` with `\n` or `\r\n`, to ensure the template generates the correct style of line endings whatever platform you created it on. + +### Legacy "match" rules for signatures +In previous versions of webhook signature verification was handled by a set of specific "match" rule types named `payload-hmac-` - the legacy format is still understood but you may wish to update your existing configurations to the new format. + +The legacy configuration ```json { "match": { - "type": "ip-whitelist", - "ip-range": "192.168.0.1/24" + "type": "payload-hmac-", + "secret": "secret", + "parameter": + { + "source": "header", + "name": "X-Signature" + } } } ``` -Note this does not work if webhook is running behind a reverse proxy, as the "client IP" will either not be available at all (if webhook is using a Unix socket or named pipe) or it will be the address of the _proxy_, not of the real client. You will probably need to enforce client IP restrictions in the reverse proxy itself, before forwarding the requests to webhook. - -### Match scalr-signature - -The trigger rule checks the scalr signature and also checks that the request was signed less than 5 minutes before it was received. -A unique signing key is generated for each webhook endpoint URL you register in Scalr. -Given the time check make sure that NTP is enabled on both your Scalr and webhook server to prevent any issues +is equivalent to the new style ```json { - "match": + "check-signature": { - "type": "scalr-signature", - "secret": "Scalr-provided signing key" + "algorithm": "", + "secret": "secret", + "signature": + { + "source": "header", + "name": "X-Signature" + } } } ``` diff --git a/docs/Referencing-Request-Values.md b/docs/Referencing-Request-Values.md index ba353637..d98f480e 100644 --- a/docs/Referencing-Request-Values.md +++ b/docs/Referencing-Request-Values.md @@ -132,3 +132,79 @@ and for query variables you can use "source": "entire-query" } ``` + +# Using a template +If the above source types do not provide sufficient flexibility for your needs, it is possible to provide a [Go template][tt] to compute the value. The template _context_ provides access to the headers, query parameters, parsed payload, and the complete request body content. For clarity, the following examples show the YAML form of the definition rather than JSON, since template strings will often contain double quotes, line breaks, etc. that need to be specially encoded in JSON. + +## Examples + +Extract a value from the payload, if it is present, otherwise from the query string (this allows for a hook that may be called with either a POST request with the form data in the payload, or a GET request with the same data in the URL): + +```yaml +- source: template + name: |- + {{- with .Payload.requestId -}} + {{- . -}} + {{- else -}} + {{- .Query.requestId -}} + {{- end -}} +``` + +Given the following JSON payload describing multiple commits: + +```json +{ + "commits": [ + { + "commit": { + "commit-id": 1 + } + }, { + "commit": { + "commit-id": 2 + } + } + ] +} +``` + +this template would generate a semicolon-separated list of all the commit IDs: + +```yaml +- source: template + name: |- + {{- range $i, $c := .Payload.commits -}} + {{- if gt $i 0 -}};{{- end -}} + {{- index $c.commit "commit-id" -}} + {{- end -}} +``` + +Here `.Payload.commits` is the array of objects, each of these has a field `commit`, which in turn has a field `commit-id`. The `range` operator iterates over the commits array, setting `$i` to the (zero-based) index and `$c` to the object. The template then prints a semicolon if this is not the first iteration, then we extract the `commit` field from that object, then in turn the `commit-id`. Note how the first level can be extracted with just `$c.commit` because the field name is a valid identifier, but for the second level we must use the `index` function. + +To access request _header_ values, use the `.GetHeader` function: + +```yaml +- source: template + name: |- + {{- .GetHeader "x-request-id" }}:{{ index .Query "app-id" -}} +``` + +## Template context + +The following items are available to templates, in addition to the [standard functions](https://pkg.go.dev/text/template#hdr-Functions) provided by Go: + +- `.Payload` - the parsed request payload, which may be JSON, XML or form data. +- `.Query` - the query string parameters from the hook URL. +- `.GetHeader "header-name"` - function that returns the value of the given request header, case-insensitive +- `.ContentType` - the request content type +- `.ID` - request ID assigned by webhook itself +- `.Method` - the HTTP request method (`GET`, `POST`, etc.) +- `.RemoteAddr` - IP address of the client (though this may not be accurate if webhook is behind a [reverse proxy](Hook-Rules.md#match-whitelisted-ip-range)) +- `.BodyText` - the complete raw content of the request body, as a string + +The following are also available but less frequently needed: + +- `.Body` - complete body content, but as a slice of bytes rather than as a string +- `.Headers` - the map of HTTP headers. Useful if you need to `range` over the headers, but to look up keys directly in this map you must use the canonical form - the `.GetHeader` function performs a case-insensitive lookup. + +[tt]: https://golang.org/pkg/text/template/ \ No newline at end of file diff --git a/docs/Templates.md b/docs/Templates.md index 35f10a05..612ef831 100644 --- a/docs/Templates.md +++ b/docs/Templates.md @@ -73,5 +73,33 @@ Additionally, the result is piped through the built-in Go template function `js` ``` +## Changing the template delimiters + +If your hook configuration includes lookup arguments of type `{"source": "template"}`, and you also need to parse the hooks file _as_ a template, you can use the `-template-delims` parameter to change the template delimiter used when processing the hook file so it does not clash with the standard `{{ ... }}` delimiters used for template lookups. The parameter is a comma-separated pair of the left and right delimiter strings, e.g. `-template-delims='[[,]]'` would use square brackets. For a configuration like this: + +```json +[ + { + "id": "example", + "trigger-rule": { + "check-signature": { + "algorithm": "sha256", + "secret": "[[ getenv `XXXTEST_SECRET` | js ]]", + "signature": { + "source": "header", + "name": "X-Signature" + }, + "string-to-sign": { + "source": "template", + "name": "{{ .BodyText }}{{ .GetHeader `date` }}" + } + } + } + } +] +``` + +the `-template-delims='[[,]]'` setting would cause the `getenv` part to be interpreted when parsing the hook file, whereas the string-to-sign template would be executed when evaluating the trigger rule against each request. + [w]: https://github.com/adnanh/webhook [tt]: https://golang.org/pkg/text/template/ diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 6699eeb6..a4db1de2 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -41,6 +41,7 @@ const ( SourceEntirePayload string = "entire-payload" SourceEntireQuery string = "entire-query" SourceEntireHeaders string = "entire-headers" + SourceTemplate string = "template" ) const ( @@ -195,45 +196,6 @@ func ValidateMAC(payload []byte, mac hash.Hash, signatures []string) (string, er return actualMAC, e } -// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload -func CheckPayloadSignature(payload []byte, secret, signature string) (string, error) { - if secret == "" { - return "", errors.New("signature validation secret can not be empty") - } - - // Extract the signatures. - signatures := ExtractSignatures(signature, "sha1=") - - // Validate the MAC. - return ValidateMAC(payload, hmac.New(sha1.New, []byte(secret)), signatures) -} - -// CheckPayloadSignature256 calculates and verifies SHA256 signature of the given payload -func CheckPayloadSignature256(payload []byte, secret, signature string) (string, error) { - if secret == "" { - return "", errors.New("signature validation secret can not be empty") - } - - // Extract the signatures. - signatures := ExtractSignatures(signature, "sha256=") - - // Validate the MAC. - return ValidateMAC(payload, hmac.New(sha256.New, []byte(secret)), signatures) -} - -// CheckPayloadSignature512 calculates and verifies SHA512 signature of the given payload -func CheckPayloadSignature512(payload []byte, secret, signature string) (string, error) { - if secret == "" { - return "", errors.New("signature validation secret can not be empty") - } - - // Extract the signatures. - signatures := ExtractSignatures(signature, "sha512=") - - // Validate the MAC. - return ValidateMAC(payload, hmac.New(sha512.New, []byte(secret)), signatures) -} - func CheckScalrSignature(r *Request, signingKey string, checkDate bool) (bool, error) { if r.Headers == nil { return false, nil @@ -438,6 +400,76 @@ type Argument struct { Name string `json:"name,omitempty"` EnvName string `json:"envname,omitempty"` Base64Decode bool `json:"base64decode,omitempty"` + + // if the Argument is SourceTemplate, this will be the compiled template, + // otherwise it will be nil + template *template.Template +} + +// UnmarshalJSON parses an Argument in the normal way, and then allows the +// newly-loaded Argument to do any necessary post-processing. +func (ha *Argument) UnmarshalJSON(text []byte) error { + // First unmarshal as normal, skipping the custom unmarshaller + type jsonArgument Argument + if err := json.Unmarshal(text, (*jsonArgument)(ha)); err != nil { + return err + } + + return ha.postProcess() +} + +// postProcess does the necessary post-unmarshal processing for this argument. +// If the argument is a SourceTemplate it compiles the template string into an +// executable template. This method is idempotent, i.e. it is safe to call +// more than once on the same Argument +func (ha *Argument) postProcess() error { + if ha.Source == SourceTemplate && ha.template == nil { + // now compile the template + var err error + ha.template, err = template.New("argument").Option("missingkey=zero").Parse(ha.Name) + return err + } + + return nil +} + +// templateContext is the context passed as "." to the template executed when +// getting an Argument of type SourceTemplate +type templateContext struct { + ID string + ContentType string + Body []byte + Headers map[string]interface{} + Query map[string]interface{} + Payload map[string]interface{} + Method string + RemoteAddr string +} + +// BodyText is a convenience to access the request Body as a string. This means +// you can just say {{ .BodyText }} instead of having to do a trick like +// {{ printf "%s" .Body }} +func (ctx *templateContext) BodyText() string { + return string(ctx.Body) +} + +// GetHeader is a function to fetch a specific item out of the headers map +// by its case insensitive name. The header name is converted to canonical form +// before being looked up in the header map, e.g. {{ .GetHeader "x-request-id" }} +func (ctx *templateContext) GetHeader(name string) interface{} { + return ctx.Headers[textproto.CanonicalMIMEHeaderKey(name)] +} + +func (ha *Argument) runTemplate(r *Request) (string, error) { + w := &strings.Builder{} + ctx := &templateContext{ + r.ID, r.ContentType, r.Body, r.Headers, r.Query, r.Payload, r.RawRequest.Method, r.RawRequest.RemoteAddr, + } + err := ha.template.Execute(w, ctx) + if err == nil { + return w.String(), nil + } + return "", err } // Get Argument method returns the value for the Argument's key name @@ -500,6 +532,9 @@ func (ha *Argument) Get(r *Request) (string, error) { } return string(res), nil + + case SourceTemplate: + return ha.runTemplate(r) } if source != nil { @@ -744,7 +779,9 @@ type Hooks []Hook // LoadFromFile attempts to load hooks from the specified file, which // can be either JSON or YAML. The asTemplate parameter causes the file // contents to be parsed as a Go text/template prior to unmarshalling. -func (h *Hooks) LoadFromFile(path string, asTemplate bool) error { +// The delimsStr parameter is a comma-separated pair of the left and right +// template delimiters, or an empty string to use the default '{{,}}'. +func (h *Hooks) LoadFromFile(path string, asTemplate bool, delimsStr string) error { if path == "" { return nil } @@ -758,8 +795,12 @@ func (h *Hooks) LoadFromFile(path string, asTemplate bool) error { if asTemplate { funcMap := template.FuncMap{"getenv": getenv} + left, right, found := strings.Cut(delimsStr, ",") + if !found && delimsStr != "" { + return fmt.Errorf("invalid delimiters %q - should be left and right delimiters separated by a comma", delimsStr) + } - tmpl, err := template.New("hooks").Funcs(funcMap).Parse(string(file)) + tmpl, err := template.New("hooks").Funcs(funcMap).Delims(strings.TrimSpace(left), strings.TrimSpace(right)).Parse(string(file)) if err != nil { return err } @@ -774,7 +815,12 @@ func (h *Hooks) LoadFromFile(path string, asTemplate bool) error { file = buf.Bytes() } - return yaml.Unmarshal(file, h) + err := yaml.Unmarshal(file, h) + if err != nil { + return err + } + + return h.postProcess() } // Append appends hooks unless the new hooks contain a hook with an ID that already exists @@ -802,12 +848,81 @@ func (h *Hooks) Match(id string) *Hook { return nil } +func (h *Hooks) postProcess() error { + for i := range *h { + rules := (*h)[i].TriggerRule + if rules != nil { + if err := postProcess(rules); err != nil { + return err + } + } + } + return nil +} + // Rules is a structure that contains one of the valid rule types type Rules struct { - And *AndRule `json:"and,omitempty"` - Or *OrRule `json:"or,omitempty"` - Not *NotRule `json:"not,omitempty"` - Match *MatchRule `json:"match,omitempty"` + And *AndRule `json:"and,omitempty"` + Or *OrRule `json:"or,omitempty"` + Not *NotRule `json:"not,omitempty"` + Match *MatchRule `json:"match,omitempty"` + Signature *SignatureRule `json:"check-signature,omitempty"` +} + +// postProcess is called on each Rules instance after loading it from JSON/YAML, +// to replace any legacy constructs with their modern equivalents. +func postProcess(r *Rules) error { + if r.And != nil { + for i := range *(r.And) { + if err := postProcess(&(*r.And)[i]); err != nil { + return err + } + } + } + if r.Or != nil { + for i := range *(r.Or) { + if err := postProcess(&(*r.Or)[i]); err != nil { + return err + } + } + } + if r.Not != nil { + return postProcess((*Rules)(r.Not)) + } + if r.Match != nil { + // convert any signature matching rules to the equivalent SignatureRule + if r.Match.Type == MatchHashSHA1 || r.Match.Type == MatchHMACSHA1 { + log.Printf(`warn: use of deprecated match type %s; use a check-signature rule instead`, r.Match.Type) + r.Signature = &SignatureRule{ + Algorithm: AlgorithmSHA1, + Secret: r.Match.Secret, + Signature: r.Match.Parameter, + } + r.Match = nil + return nil + } + if r.Match.Type == MatchHashSHA256 || r.Match.Type == MatchHMACSHA256 { + log.Printf(`warn: use of deprecated match type %s; use a check-signature rule instead`, r.Match.Type) + r.Signature = &SignatureRule{ + Algorithm: AlgorithmSHA256, + Secret: r.Match.Secret, + Signature: r.Match.Parameter, + } + r.Match = nil + return nil + } + if r.Match.Type == MatchHashSHA512 || r.Match.Type == MatchHMACSHA512 { + log.Printf(`warn: use of deprecated match type %s; use a check-signature rule instead`, r.Match.Type) + r.Signature = &SignatureRule{ + Algorithm: AlgorithmSHA512, + Secret: r.Match.Secret, + Signature: r.Match.Parameter, + } + r.Match = nil + return nil + } + } + return nil } // Evaluate finds the first rule property that is not nil and returns the value @@ -822,6 +937,8 @@ func (r Rules) Evaluate(req *Request) (bool, error) { return r.Not.Evaluate(req) case r.Match != nil: return r.Match.Evaluate(req) + case r.Signature != nil: + return r.Signature.Evaluate(req) } return false, nil @@ -896,16 +1013,19 @@ type MatchRule struct { // Constants for the MatchRule type const ( - MatchValue string = "value" - MatchRegex string = "regex" + MatchValue string = "value" + MatchRegex string = "regex" + IPWhitelist string = "ip-whitelist" + ScalrSignature string = "scalr-signature" + + // legacy match types that have migrated to SignatureRule + MatchHMACSHA1 string = "payload-hmac-sha1" MatchHMACSHA256 string = "payload-hmac-sha256" MatchHMACSHA512 string = "payload-hmac-sha512" MatchHashSHA1 string = "payload-hash-sha1" MatchHashSHA256 string = "payload-hash-sha256" MatchHashSHA512 string = "payload-hash-sha512" - IPWhitelist string = "ip-whitelist" - ScalrSignature string = "scalr-signature" ) // Evaluate MatchRule will return based on the type @@ -924,29 +1044,74 @@ func (r MatchRule) Evaluate(req *Request) (bool, error) { return compare(arg, r.Value), nil case MatchRegex: return regexp.MatchString(r.Regex, arg) - case MatchHashSHA1: - log.Print(`warn: use of deprecated option payload-hash-sha1; use payload-hmac-sha1 instead`) - fallthrough - case MatchHMACSHA1: - _, err := CheckPayloadSignature(req.Body, r.Secret, arg) - return err == nil, err - case MatchHashSHA256: - log.Print(`warn: use of deprecated option payload-hash-sha256: use payload-hmac-sha256 instead`) - fallthrough - case MatchHMACSHA256: - _, err := CheckPayloadSignature256(req.Body, r.Secret, arg) - return err == nil, err - case MatchHashSHA512: - log.Print(`warn: use of deprecated option payload-hash-sha512: use payload-hmac-sha512 instead`) - fallthrough - case MatchHMACSHA512: - _, err := CheckPayloadSignature512(req.Body, r.Secret, arg) - return err == nil, err } } return false, err } +type SignatureRule struct { + Algorithm string `json:"algorithm,omitempty"` + Secret string `json:"secret,omitempty"` + Signature Argument `json:"signature,omitempty"` + Prefix string `json:"prefix,omitempty"` + StringToSign *Argument `json:"string-to-sign,omitempty"` +} + +// Constants for the SignatureRule type +const ( + AlgorithmSHA1 string = "sha1" + AlgorithmSHA256 string = "sha256" + AlgorithmSHA512 string = "sha512" +) + +// Evaluate extracts the signature payload and signature value from the request +// and checks whether the signature matches +func (r SignatureRule) Evaluate(req *Request) (bool, error) { + if r.Secret == "" { + return false, errors.New("signature validation secret can not be empty") + } + + var hashConstructor func() hash.Hash + switch r.Algorithm { + case AlgorithmSHA1: + hashConstructor = sha1.New + case AlgorithmSHA256: + hashConstructor = sha256.New + case AlgorithmSHA512: + hashConstructor = sha512.New + default: + return false, fmt.Errorf("unknown hash algorithm %s", r.Algorithm) + } + + prefix := r.Prefix + if prefix == "" { + // default prefix is "sha1=" for SHA1, etc. + prefix = fmt.Sprintf("%s=", r.Algorithm) + } + + // find the signature + sig, err := r.Signature.Get(req) + if err != nil { + return false, err + } + + // determine the payload that is signed + payload := req.Body + if r.StringToSign != nil { + payloadStr, err := r.StringToSign.Get(req) + if err != nil { + return false, fmt.Errorf("could not build string-to-sign: %w", err) + } + payload = []byte(payloadStr) + } + + // check the signature + signatures := ExtractSignatures(sig, prefix) + _, err = ValidateMAC(payload, hmac.New(hashConstructor, []byte(r.Secret)), signatures) + + return err == nil, err +} + // compare is a helper function for constant time string comparisons. func compare(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go index cbc49f70..3b764900 100644 --- a/internal/hook/hook_test.go +++ b/internal/hook/hook_test.go @@ -40,100 +40,6 @@ func TestGetParameter(t *testing.T) { } } -var checkPayloadSignatureTests = []struct { - payload []byte - secret string - signature string - mac string - ok bool -}{ - {[]byte(`{"a": "z"}`), "secret", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true}, - {[]byte(`{"a": "z"}`), "secret", "sha1=b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true}, - {[]byte(`{"a": "z"}`), "secret", "sha1=XXXe04cbb22afa8ffbff8796fc1894ed27badd9e,sha1=b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", true}, - {[]byte(``), "secret", "25af6174a0fcecc4d346680a72b7ce644b9a88e8", "25af6174a0fcecc4d346680a72b7ce644b9a88e8", true}, - // failures - {[]byte(`{"a": "z"}`), "secret", "XXXe04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", false}, - {[]byte(`{"a": "z"}`), "secret", "sha1=XXXe04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", false}, - {[]byte(`{"a": "z"}`), "secret", "sha1=XXXe04cbb22afa8ffbff8796fc1894ed27badd9e,sha1=XXXe04cbb22afa8ffbff8796fc1894ed27badd9e", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", false}, - {[]byte(`{"a": "z"}`), "secreX", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "900225703e9342328db7307692736e2f7cc7b36e", false}, - {[]byte(`{"a": "z"}`), "", "b17e04cbb22afa8ffbff8796fc1894ed27badd9e", "", false}, - {[]byte(``), "secret", "XXXf6174a0fcecc4d346680a72b7ce644b9a88e8", "25af6174a0fcecc4d346680a72b7ce644b9a88e8", false}, -} - -func TestCheckPayloadSignature(t *testing.T) { - for _, tt := range checkPayloadSignatureTests { - mac, err := CheckPayloadSignature(tt.payload, tt.secret, tt.signature) - if (err == nil) != tt.ok || mac != tt.mac { - t.Errorf("failed to check payload signature {%q, %q, %q}:\nexpected {mac:%#v, ok:%#v},\ngot {mac:%#v, ok:%#v}", tt.payload, tt.secret, tt.signature, tt.mac, tt.ok, mac, (err == nil)) - } - - if err != nil && tt.mac != "" && strings.Contains(err.Error(), tt.mac) { - t.Errorf("error message should not disclose expected mac: %s", err) - } - } -} - -var checkPayloadSignature256Tests = []struct { - payload []byte - secret string - signature string - mac string - ok bool -}{ - {[]byte(`{"a": "z"}`), "secret", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true}, - {[]byte(`{"a": "z"}`), "secret", "sha256=f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true}, - {[]byte(`{"a": "z"}`), "secret", "sha256=XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89,sha256=f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true}, - {[]byte(``), "secret", "f9e66e179b6747ae54108f82f8ade8b3c25d76fd30afde6c395822c530196169", "f9e66e179b6747ae54108f82f8ade8b3c25d76fd30afde6c395822c530196169", true}, - // failures - {[]byte(`{"a": "z"}`), "secret", "XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", false}, - {[]byte(`{"a": "z"}`), "secret", "sha256=XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", false}, - {[]byte(`{"a": "z"}`), "secret", "sha256=XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89,sha256=XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", false}, - {[]byte(`{"a": "z"}`), "", "XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "", false}, - {[]byte(``), "secret", "XXX66e179b6747ae54108f82f8ade8b3c25d76fd30afde6c395822c530196169", "f9e66e179b6747ae54108f82f8ade8b3c25d76fd30afde6c395822c530196169", false}, -} - -func TestCheckPayloadSignature256(t *testing.T) { - for _, tt := range checkPayloadSignature256Tests { - mac, err := CheckPayloadSignature256(tt.payload, tt.secret, tt.signature) - if (err == nil) != tt.ok || mac != tt.mac { - t.Errorf("failed to check payload signature {%q, %q, %q}:\nexpected {mac:%#v, ok:%#v},\ngot {mac:%#v, ok:%#v}", tt.payload, tt.secret, tt.signature, tt.mac, tt.ok, mac, (err == nil)) - } - - if err != nil && tt.mac != "" && strings.Contains(err.Error(), tt.mac) { - t.Errorf("error message should not disclose expected mac: %s", err) - } - } -} - -var checkPayloadSignature512Tests = []struct { - payload []byte - secret string - signature string - mac string - ok bool -}{ - {[]byte(`{"a": "z"}`), "secret", "4ab17cc8ec668ead8bf498f87f8f32848c04d5ca3c9bcfcd3db9363f0deb44e580b329502a7fdff633d4d8fca301cc5c94a55a2fec458c675fb0ff2655898324", "4ab17cc8ec668ead8bf498f87f8f32848c04d5ca3c9bcfcd3db9363f0deb44e580b329502a7fdff633d4d8fca301cc5c94a55a2fec458c675fb0ff2655898324", true}, - {[]byte(`{"a": "z"}`), "secret", "sha512=4ab17cc8ec668ead8bf498f87f8f32848c04d5ca3c9bcfcd3db9363f0deb44e580b329502a7fdff633d4d8fca301cc5c94a55a2fec458c675fb0ff2655898324", "4ab17cc8ec668ead8bf498f87f8f32848c04d5ca3c9bcfcd3db9363f0deb44e580b329502a7fdff633d4d8fca301cc5c94a55a2fec458c675fb0ff2655898324", true}, - {[]byte(``), "secret", "b0e9650c5faf9cd8ae02276671545424104589b3656731ec193b25d01b07561c27637c2d4d68389d6cf5007a8632c26ec89ba80a01c77a6cdd389ec28db43901", "b0e9650c5faf9cd8ae02276671545424104589b3656731ec193b25d01b07561c27637c2d4d68389d6cf5007a8632c26ec89ba80a01c77a6cdd389ec28db43901", true}, - // failures - {[]byte(`{"a": "z"}`), "secret", "74a0081f5b5988f4f3e8b8dd34dadc6291611f2e6260635a7e1535f8e95edb97ff520ba8b152e8ca5760ac42639854f3242e29efc81be73a8bf52d474d31ffea", "4ab17cc8ec668ead8bf498f87f8f32848c04d5ca3c9bcfcd3db9363f0deb44e580b329502a7fdff633d4d8fca301cc5c94a55a2fec458c675fb0ff2655898324", false}, - {[]byte(`{"a": "z"}`), "", "74a0081f5b5988f4f3e8b8dd34dadc6291611f2e6260635a7e1535f8e95edb97ff520ba8b152e8ca5760ac42639854f3242e29efc81be73a8bf52d474d31ffea", "", false}, - {[]byte(``), "secret", "XXX9650c5faf9cd8ae02276671545424104589b3656731ec193b25d01b07561c27637c2d4d68389d6cf5007a8632c26ec89ba80a01c77a6cdd389ec28db43901", "b0e9650c5faf9cd8ae02276671545424104589b3656731ec193b25d01b07561c27637c2d4d68389d6cf5007a8632c26ec89ba80a01c77a6cdd389ec28db43901", false}, -} - -func TestCheckPayloadSignature512(t *testing.T) { - for _, tt := range checkPayloadSignature512Tests { - mac, err := CheckPayloadSignature512(tt.payload, tt.secret, tt.signature) - if (err == nil) != tt.ok || mac != tt.mac { - t.Errorf("failed to check payload signature {%q, %q, %q}:\nexpected {mac:%#v, ok:%#v},\ngot {mac:%#v, ok:%#v}", tt.payload, tt.secret, tt.signature, tt.mac, tt.ok, mac, (err == nil)) - } - - if err != nil && tt.mac != "" && strings.Contains(err.Error(), tt.mac) { - t.Errorf("error message should not disclose expected mac: %s", err) - } - } -} - var checkScalrSignatureTests = []struct { description string headers map[string]interface{} @@ -274,7 +180,7 @@ var argumentGetTests = []struct { func TestArgumentGet(t *testing.T) { for _, tt := range argumentGetTests { - a := Argument{tt.source, tt.name, "", false} + a := Argument{tt.source, tt.name, "", false, nil} r := &Request{ Headers: tt.headers, Query: tt.query, @@ -294,14 +200,14 @@ var hookParseJSONParametersTests = []struct { rheaders, rquery, rpayload map[string]interface{} ok bool }{ - {[]Argument{Argument{"header", "a", "", false}}, map[string]interface{}{"A": `{"b": "y"}`}, nil, nil, map[string]interface{}{"A": map[string]interface{}{"b": "y"}}, nil, nil, true}, - {[]Argument{Argument{"url", "a", "", false}}, nil, map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, true}, - {[]Argument{Argument{"payload", "a", "", false}}, nil, nil, map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, true}, - {[]Argument{Argument{"header", "z", "", false}}, map[string]interface{}{"Z": `{}`}, nil, nil, map[string]interface{}{"Z": map[string]interface{}{}}, nil, nil, true}, + {[]Argument{Argument{"header", "a", "", false, nil}}, map[string]interface{}{"A": `{"b": "y"}`}, nil, nil, map[string]interface{}{"A": map[string]interface{}{"b": "y"}}, nil, nil, true}, + {[]Argument{Argument{"url", "a", "", false, nil}}, nil, map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, true}, + {[]Argument{Argument{"payload", "a", "", false, nil}}, nil, nil, map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, true}, + {[]Argument{Argument{"header", "z", "", false, nil}}, map[string]interface{}{"Z": `{}`}, nil, nil, map[string]interface{}{"Z": map[string]interface{}{}}, nil, nil, true}, // failures - {[]Argument{Argument{"header", "z", "", false}}, map[string]interface{}{"Z": ``}, nil, nil, map[string]interface{}{"Z": ``}, nil, nil, false}, // empty string - {[]Argument{Argument{"header", "y", "", false}}, map[string]interface{}{"X": `{}`}, nil, nil, map[string]interface{}{"X": `{}`}, nil, nil, false}, // missing parameter - {[]Argument{Argument{"string", "z", "", false}}, map[string]interface{}{"Z": ``}, nil, nil, map[string]interface{}{"Z": ``}, nil, nil, false}, // invalid argument source + {[]Argument{Argument{"header", "z", "", false, nil}}, map[string]interface{}{"Z": ``}, nil, nil, map[string]interface{}{"Z": ``}, nil, nil, false}, // empty string + {[]Argument{Argument{"header", "y", "", false, nil}}, map[string]interface{}{"X": `{}`}, nil, nil, map[string]interface{}{"X": `{}`}, nil, nil, false}, // missing parameter + {[]Argument{Argument{"string", "z", "", false, nil}}, map[string]interface{}{"Z": ``}, nil, nil, map[string]interface{}{"Z": ``}, nil, nil, false}, // invalid argument source } func TestHookParseJSONParameters(t *testing.T) { @@ -326,9 +232,9 @@ var hookExtractCommandArgumentsTests = []struct { value []string ok bool }{ - {"test", []Argument{Argument{"header", "a", "", false}}, map[string]interface{}{"A": "z"}, nil, nil, []string{"test", "z"}, true}, + {"test", []Argument{Argument{"header", "a", "", false, nil}}, map[string]interface{}{"A": "z"}, nil, nil, []string{"test", "z"}, true}, // failures - {"fail", []Argument{Argument{"payload", "a", "", false}}, map[string]interface{}{"A": "z"}, nil, nil, []string{"fail", ""}, false}, + {"fail", []Argument{Argument{"payload", "a", "", false, nil}}, map[string]interface{}{"A": "z"}, nil, nil, []string{"fail", ""}, false}, } func TestHookExtractCommandArguments(t *testing.T) { @@ -351,20 +257,21 @@ func TestHookExtractCommandArguments(t *testing.T) { // we test both cases where the name of the data is used as the name of the // env key & the case where the hook definition sets the env var name to a // fixed value using the envname construct like so:: -// [ -// { -// "id": "push", -// "execute-command": "bb2mm", -// "command-working-directory": "/tmp", -// "pass-environment-to-command": -// [ -// { -// "source": "entire-payload", -// "envname": "PAYLOAD" -// }, -// ] -// } -// ] +// +// [ +// { +// "id": "push", +// "execute-command": "bb2mm", +// "command-working-directory": "/tmp", +// "pass-environment-to-command": +// [ +// { +// "source": "entire-payload", +// "envname": "PAYLOAD" +// }, +// ] +// } +// ] var hookExtractCommandArgumentsForEnvTests = []struct { exec string args []Argument @@ -375,14 +282,14 @@ var hookExtractCommandArgumentsForEnvTests = []struct { // successes { "test", - []Argument{Argument{"header", "a", "", false}}, + []Argument{Argument{"header", "a", "", false, nil}}, map[string]interface{}{"A": "z"}, nil, nil, []string{"HOOK_a=z"}, true, }, { "test", - []Argument{Argument{"header", "a", "MYKEY", false}}, + []Argument{Argument{"header", "a", "MYKEY", false, nil}}, map[string]interface{}{"A": "z"}, nil, nil, []string{"MYKEY=z"}, true, @@ -390,7 +297,7 @@ var hookExtractCommandArgumentsForEnvTests = []struct { // failures { "fail", - []Argument{Argument{"payload", "a", "", false}}, + []Argument{Argument{"payload", "a", "", false, nil}}, map[string]interface{}{"A": "z"}, nil, nil, []string{}, false, @@ -432,7 +339,7 @@ func TestHooksLoadFromFile(t *testing.T) { for _, tt := range hooksLoadFromFileTests { h := &Hooks{} - err := h.LoadFromFile(tt.path, tt.asTemplate) + err := h.LoadFromFile(tt.path, tt.asTemplate, "") if (err == nil) != tt.ok { t.Errorf(err.Error()) } @@ -449,13 +356,13 @@ func TestHooksTemplateLoadFromFile(t *testing.T) { } h := &Hooks{} - err := h.LoadFromFile(tt.path, tt.asTemplate) + err := h.LoadFromFile(tt.path, tt.asTemplate, "") if (err == nil) != tt.ok { t.Errorf(err.Error()) continue } - s := (*h.Match("webhook").TriggerRule.And)[0].Match.Secret + s := (*h.Match("webhook").TriggerRule.And)[0].Signature.Secret if s != secret { t.Errorf("Expected secret of %q, got %q", secret, s) } @@ -489,24 +396,14 @@ var matchRuleTests = []struct { ok bool err bool }{ - {"value", "", "", "z", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", true, false}, - {"regex", "^z", "", "z", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", true, false}, - {"payload-hmac-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), "", true, false}, - {"payload-hash-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), "", true, false}, - {"payload-hmac-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, []byte(`{"a": "z"}`), "", true, false}, - {"payload-hash-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, []byte(`{"a": "z"}`), "", true, false}, + {"value", "", "", "z", "", Argument{"header", "a", "", false, nil}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", true, false}, + {"regex", "^z", "", "z", "", Argument{"header", "a", "", false, nil}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", true, false}, // failures - {"value", "", "", "X", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, false}, - {"regex", "^X", "", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, false}, - {"value", "", "2", "X", "", Argument{"header", "a", "", false}, map[string]interface{}{"Y": "z"}, nil, nil, []byte{}, "", false, true}, // reference invalid header + {"value", "", "", "X", "", Argument{"header", "a", "", false, nil}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, false}, + {"regex", "^X", "", "", "", Argument{"header", "a", "", false, nil}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, false}, + {"value", "", "2", "X", "", Argument{"header", "a", "", false, nil}, map[string]interface{}{"Y": "z"}, nil, nil, []byte{}, "", false, true}, // reference invalid header // errors - {"regex", "*", "", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, true}, // invalid regex - {"payload-hmac-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, []byte{}, "", false, true}, // invalid hmac - {"payload-hash-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, []byte{}, "", false, true}, // invalid hmac - {"payload-hmac-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, []byte{}, "", false, true}, // invalid hmac - {"payload-hash-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, []byte{}, "", false, true}, // invalid hmac - {"payload-hmac-sha512", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, []byte{}, "", false, true}, // invalid hmac - {"payload-hash-sha512", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, []byte{}, "", false, true}, // invalid hmac + {"regex", "*", "", "", "", Argument{"header", "a", "", false, nil}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, true}, // invalid regex // IP whitelisting, valid cases {"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", true, false}, // valid IPv4, with range {"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", true, false}, // valid IPv4, with range @@ -541,6 +438,55 @@ func TestMatchRule(t *testing.T) { } } +var signatureRuleTests = []struct { + algorithm, secret string + sigSource Argument + stringToSign *Argument + headers, query, payload map[string]interface{} + body []byte + ok bool + err bool +}{ + {"sha1", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true, false}, + {"sha1", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true, false}, + {"sha256", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, []byte(`{"a": "z"}`), true, false}, + {"sha256", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, []byte(`{"a": "z"}`), true, false}, + // errors + {"sha1", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac + {"sha1", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac + {"sha256", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac + {"sha256", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac + {"sha512", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac + {"sha512", "secret", Argument{"header", "a", "", false, nil}, nil, map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac + + // template to build custom string-to-sign + {"sha256", "secret", Argument{"header", "a", "", false, nil}, &Argument{"template", "{{ printf \"%s\\n%s\" .BodyText (.GetHeader \"x-id\") }}", "", false, nil}, map[string]interface{}{"A": "sha256=4f1d62e6e6de1e31537a5faefabeffd7dce115bc499584feefbf8db6d2da4027", "X-Id": "test"}, nil, nil, []byte(`{"a": "z"}`), true, false}, + {"sha256", "secret", Argument{"header", "a", "", false, nil}, &Argument{"template", "{{ printf \"%s\\n%s\" .BodyText (.GetHeader \"x-id\") }}", "", false, nil}, map[string]interface{}{"A": "sha256=4f1d62e6e6de1e31537a5faefabeffd7dce115bc499584feefbf8db6d2da4027", "X-Id": "unexpected"}, nil, nil, []byte(`{"a": "z"}`), false, true}, +} + +func TestSignatureRule(t *testing.T) { + for i, tt := range signatureRuleTests { + if tt.stringToSign != nil { + // post process the argument, as it would have been if it were loaded from a hooks file + tt.stringToSign.postProcess() + } + r := SignatureRule{tt.algorithm, tt.secret, tt.sigSource, "", tt.stringToSign} + req := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Body: tt.body, + RawRequest: &http.Request{ + RemoteAddr: "", + }, + } + ok, err := r.Evaluate(req) + if ok != tt.ok || (err != nil) != tt.err { + t.Errorf("%d failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", i, r, tt.ok, tt.err, ok, err) + } + } +} + var andRuleTests = []struct { desc string // description of the test case rule AndRule @@ -552,8 +498,8 @@ var andRuleTests = []struct { { "(a=z, b=y): a=z && b=y", AndRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false, nil}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false, nil}, ""}}, }, map[string]interface{}{"A": "z", "B": "y"}, nil, nil, []byte{}, @@ -562,8 +508,8 @@ var andRuleTests = []struct { { "(a=z, b=Y): a=z && b=y", AndRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false, nil}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false, nil}, ""}}, }, map[string]interface{}{"A": "z", "B": "Y"}, nil, nil, []byte{}, @@ -573,22 +519,22 @@ var andRuleTests = []struct { { "(a=z, b=y, c=x, d=w=, e=X, f=X): a=z && (b=y && c=x) && (d=w || e=v) && !f=u", AndRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false, nil}, ""}}, { And: &AndRule{ - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, - {Match: &MatchRule{"value", "", "", "x", Argument{"header", "c", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false, nil}, ""}}, + {Match: &MatchRule{"value", "", "", "x", Argument{"header", "c", "", false, nil}, ""}}, }, }, { Or: &OrRule{ - {Match: &MatchRule{"value", "", "", "w", Argument{"header", "d", "", false}, ""}}, - {Match: &MatchRule{"value", "", "", "v", Argument{"header", "e", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "w", Argument{"header", "d", "", false, nil}, ""}}, + {Match: &MatchRule{"value", "", "", "v", Argument{"header", "e", "", false, nil}, ""}}, }, }, { Not: &NotRule{ - Match: &MatchRule{"value", "", "", "u", Argument{"header", "f", "", false}, ""}, + Match: &MatchRule{"value", "", "", "u", Argument{"header", "f", "", false, nil}, ""}, }, }, }, @@ -600,7 +546,7 @@ var andRuleTests = []struct { // failures { "invalid rule", - AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", "", false}, ""}}}, + AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", "", false, nil}, ""}}}, map[string]interface{}{"Y": "z"}, nil, nil, nil, false, true, }, @@ -632,8 +578,8 @@ var orRuleTests = []struct { { "(a=z, b=X): a=z || b=y", OrRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false, nil}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false, nil}, ""}}, }, map[string]interface{}{"A": "z", "B": "X"}, nil, nil, []byte{}, @@ -642,8 +588,8 @@ var orRuleTests = []struct { { "(a=X, b=y): a=z || b=y", OrRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false, nil}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false, nil}, ""}}, }, map[string]interface{}{"A": "X", "B": "y"}, nil, nil, []byte{}, @@ -652,8 +598,8 @@ var orRuleTests = []struct { { "(a=Z, b=Y): a=z || b=y", OrRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false, nil}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false, nil}, ""}}, }, map[string]interface{}{"A": "Z", "B": "Y"}, nil, nil, []byte{}, @@ -663,7 +609,7 @@ var orRuleTests = []struct { { "missing parameter node", OrRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false, nil}, ""}}, }, map[string]interface{}{"Y": "Z"}, nil, nil, []byte{}, @@ -694,8 +640,8 @@ var notRuleTests = []struct { ok bool err bool }{ - {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", "", false}, ""}}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false}, - {"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false}, + {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", "", false, nil}, ""}}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false}, + {"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false, nil}, ""}}, map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false}, } func TestNotRule(t *testing.T) { diff --git a/webhook.go b/webhook.go index 9ed793c6..72736651 100644 --- a/webhook.go +++ b/webhook.go @@ -39,6 +39,7 @@ var ( hooksURLPrefix = flag.String("urlprefix", "hooks", "url prefix to use for served hooks (protocol://yourserver:port/PREFIX/:hook-id)") secure = flag.Bool("secure", false, "use HTTPS instead of HTTP") asTemplate = flag.Bool("template", false, "parse hooks file as a Go template") + templateDelimiters = flag.String("template-delims", "", "a comma-separated pair of delimiters, e.g. '((,))' or '[[,]]' to use instead of the standard '{{,}}' when parsing hooks file as a template, to avoid clashing with any \"source\": \"template\" arguments") cert = flag.String("cert", "cert.pem", "path to the HTTPS certificate pem file") key = flag.String("key", "key.pem", "path to the HTTPS certificate private key pem file") justDisplayVersion = flag.Bool("version", false, "display webhook version and quit") @@ -204,7 +205,7 @@ func main() { newHooks := hook.Hooks{} - err := newHooks.LoadFromFile(hooksFilePath, *asTemplate) + err := newHooks.LoadFromFile(hooksFilePath, *asTemplate, *templateDelimiters) if err != nil { log.Printf("couldn't load hooks from file! %+v\n", err) @@ -670,7 +671,7 @@ func reloadHooks(hooksFilePath string) { // parse and swap log.Printf("attempting to reload hooks from %s\n", hooksFilePath) - err := hooksInFile.LoadFromFile(hooksFilePath, *asTemplate) + err := hooksInFile.LoadFromFile(hooksFilePath, *asTemplate, *templateDelimiters) if err != nil { log.Printf("couldn't load hooks from file! %+v\n", err)