diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..75f347d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,23 @@ +name: build +on: [push, pull_request] +jobs: + build: + strategy: + matrix: + go-version: [1.14.x, 1.15.x] + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + id: go + + - name: Build + run: go build -v + + - name: Test + run: go test -v ./... diff --git a/.travis.yml b/.travis.yml index 86f29d5..54e4652 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: go go: - - 1.13.x + - 1.14.x - master os: diff --git a/Makefile b/Makefile index b341c1f..1a06482 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,18 @@ OS = darwin freebsd linux openbsd ARCHS = 386 arm amd64 arm64 +.DEFAULT_GOAL := help + +.PHONY: help +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-16s\033[0m %s\n", $$1, $$2}' + all: build release release-windows -build: deps +build: deps ## Build the project go build -release: clean deps +release: clean deps ## Generate releases for unix systems @for arch in $(ARCHS);\ do \ for os in $(OS);\ @@ -18,7 +24,7 @@ release: clean deps done \ done -release-windows: clean deps +release-windows: clean deps ## Generate release for windows @for arch in $(ARCHS);\ do \ echo "Building windows-$$arch"; \ @@ -27,12 +33,12 @@ release-windows: clean deps tar cz -C build -f build/webhook-windows-$$arch.tar.gz webhook-windows-$$arch; \ done -test: deps +test: deps ## Execute tests go test ./... -deps: +deps: ## Install dependencies using go get go get -d -v -t ./... -clean: +clean: ## Remove building artifacts rm -rf build rm -f webhook diff --git a/README.md b/README.md index 083c36f..f653672 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# What is webhook? +# What is webhook? ![build-status][badge] Webhook @@ -26,11 +26,11 @@ If you don't have time to waste configuring, hosting, debugging and maintaining # Getting started ## Installation ### Building from source -To get started, first make sure you've properly set up your [Go](http://golang.org/doc/install) 1.12 or newer environment and then run +To get started, first make sure you've properly set up your [Go](http://golang.org/doc/install) 1.14 or newer environment and then run ```bash -$ go get github.com/adnanh/webhook +$ go build github.com/adnanh/webhook ``` -to get the latest version of the [webhook][w]. +to build the latest version of the [webhook][w]. ### Using package manager #### Snap store @@ -46,7 +46,9 @@ If you are using Debian linux ("stretch" or later), you can install webhook usin Prebuilt binaries for different architectures are available at [GitHub Releases](https://github.com/adnanh/webhook/releases). ## Configuration -Next step is to define some hooks you want [webhook][w] to serve. Begin by creating an empty file named `hooks.json`. This file will contain an array of hooks the [webhook][w] will serve. Check [Hook definition page](docs/Hook-Definition.md) to see the detailed description of what properties a hook can contain, and how to use them. +Next step is to define some hooks you want [webhook][w] to serve. +[webhook][w] supports JSON or YAML configuration files, but we'll focus primarily on JSON in the following example. +Begin by creating an empty file named `hooks.json`. This file will contain an array of hooks the [webhook][w] will serve. Check [Hook definition page](docs/Hook-Definition.md) to see the detailed description of what properties a hook can contain, and how to use them. Let's define a simple hook named `redeploy-webhook` that will run a redeploy script located in `/var/scripts/redeploy.sh`. Make sure that your bash script has `#!/bin/sh` shebang on top. @@ -61,6 +63,13 @@ Our `hooks.json` file will now look like this: ] ``` +**NOTE:** If you prefer YAML, the equivalent `hooks.yaml` file would be: +```yaml +- id: redeploy-webhook + execute-command: "/var/scripts/redeploy.sh" + command-working-directory: "/var/webhook" +``` + You can now run [webhook][w] using ```bash $ /path/to/webhook -hooks hooks.json -verbose @@ -90,7 +99,7 @@ All files are ignored unless they match one of the following criteria: In either case, the given file part will be parsed as JSON and added to the `payload` map. ## Templates -[webhook][w] can parse the `hooks.json` input file as a Go template when given the `-template` [CLI parameter](docs/Webhook-Parameters.md). See the [Templates page](docs/Templates.md) for more details on template usage. +[webhook][w] can parse the hooks configuration file as a Go template when given the `-template` [CLI parameter](docs/Webhook-Parameters.md). See the [Templates page](docs/Templates.md) for more details on template usage. ## Using HTTPS [webhook][w] by default serves hooks using http. If you want [webhook][w] to serve secure content using https, you can use the `-secure` flag while starting [webhook][w]. Files containing a certificate and matching private key for the server must be provided using the `-cert /path/to/cert.pem` and `-key /path/to/key.pem` flags. If the certificate is signed by a certificate authority, the cert file should be the concatenation of the server's certificate followed by the CA's certificate. @@ -202,3 +211,4 @@ THE SOFTWARE. [w]: https://github.com/adnanh/webhook [wc]: https://github.com/adnanh/webhook-contrib +[badge]: https://github.com/adnanh/webhook/workflows/build/badge.svg diff --git a/cipher_suites.go b/cipher_suites.go deleted file mode 100644 index 81db51f..0000000 --- a/cipher_suites.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2010 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Copied from Go 1.14 tip src/crypto/tls/cipher_suites.go - -package main - -import ( - "crypto/tls" - "fmt" -) - -// CipherSuite is a TLS cipher suite. Note that most functions in this package -// accept and expose cipher suite IDs instead of this type. -type CipherSuite struct { - ID uint16 - Name string - - // Supported versions is the list of TLS protocol versions that can - // negotiate this cipher suite. - SupportedVersions []uint16 - - // Insecure is true if the cipher suite has known security issues - // due to its primitives, design, or implementation. - Insecure bool -} - -var ( - supportedUpToTLS12 = []uint16{tls.VersionTLS10, tls.VersionTLS11, tls.VersionTLS12} - supportedOnlyTLS12 = []uint16{tls.VersionTLS12} - supportedOnlyTLS13 = []uint16{tls.VersionTLS13} -) - -// CipherSuites returns a list of cipher suites currently implemented by this -// package, excluding those with security issues, which are returned by -// InsecureCipherSuites. -// -// The list is sorted by ID. Note that the default cipher suites selected by -// this package might depend on logic that can't be captured by a static list. -func CipherSuites() []*CipherSuite { - return []*CipherSuite{ - {tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, "TLS_RSA_WITH_3DES_EDE_CBC_SHA", supportedUpToTLS12, false}, - {tls.TLS_RSA_WITH_AES_128_CBC_SHA, "TLS_RSA_WITH_AES_128_CBC_SHA", supportedUpToTLS12, false}, - {tls.TLS_RSA_WITH_AES_256_CBC_SHA, "TLS_RSA_WITH_AES_256_CBC_SHA", supportedUpToTLS12, false}, - {tls.TLS_RSA_WITH_AES_128_GCM_SHA256, "TLS_RSA_WITH_AES_128_GCM_SHA256", supportedOnlyTLS12, false}, - {tls.TLS_RSA_WITH_AES_256_GCM_SHA384, "TLS_RSA_WITH_AES_256_GCM_SHA384", supportedOnlyTLS12, false}, - - {tls.TLS_AES_128_GCM_SHA256, "TLS_AES_128_GCM_SHA256", supportedOnlyTLS13, false}, - {tls.TLS_AES_256_GCM_SHA384, "TLS_AES_256_GCM_SHA384", supportedOnlyTLS13, false}, - {tls.TLS_CHACHA20_POLY1305_SHA256, "TLS_CHACHA20_POLY1305_SHA256", supportedOnlyTLS13, false}, - - {tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", supportedUpToTLS12, false}, - {tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", supportedUpToTLS12, false}, - {tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", supportedUpToTLS12, false}, - {tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", supportedUpToTLS12, false}, - {tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", supportedUpToTLS12, false}, - {tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", supportedOnlyTLS12, false}, - {tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", supportedOnlyTLS12, false}, - {tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", supportedOnlyTLS12, false}, - {tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", supportedOnlyTLS12, false}, - - // go1.14 - // {tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", supportedOnlyTLS12, false}, - // {tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", supportedOnlyTLS12, false}, - } -} - -// InsecureCipherSuites returns a list of cipher suites currently implemented by -// this package and which have security issues. -// -// Most applications should not use the cipher suites in this list, and should -// only use those returned by CipherSuites. -func InsecureCipherSuites() []*CipherSuite { - // RC4 suites are broken because RC4 is. - // CBC-SHA256 suites have no Lucky13 countermeasures. - return []*CipherSuite{ - {tls.TLS_RSA_WITH_RC4_128_SHA, "TLS_RSA_WITH_RC4_128_SHA", supportedUpToTLS12, true}, - {tls.TLS_RSA_WITH_AES_128_CBC_SHA256, "TLS_RSA_WITH_AES_128_CBC_SHA256", supportedOnlyTLS12, true}, - {tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", supportedUpToTLS12, true}, - {tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA, "TLS_ECDHE_RSA_WITH_RC4_128_SHA", supportedUpToTLS12, true}, - {tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", supportedOnlyTLS12, true}, - {tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", supportedOnlyTLS12, true}, - } -} - -// CipherSuiteName returns the standard name for the passed cipher suite ID -// (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"), or a fallback representation -// of the ID value if the cipher suite is not implemented by this package. -func CipherSuiteName(id uint16) string { - for _, c := range CipherSuites() { - if c.ID == id { - return c.Name - } - } - for _, c := range InsecureCipherSuites() { - if c.ID == id { - return c.Name - } - } - return fmt.Sprintf("0x%04X", id) -} diff --git a/docs/Hook-Definition.md b/docs/Hook-Definition.md index a963ef3..41faa13 100644 --- a/docs/Hook-Definition.md +++ b/docs/Hook-Definition.md @@ -1,5 +1,6 @@ # Hook definition -Hooks are defined as JSON objects. Please note that in order to be considered valid, a hook object must contain the `id` and `execute-command` properties. All other properties are considered optional. + +Hooks are defined as objects in the JSON or YAML hooks configuration file. Please note that in order to be considered valid, a hook object must contain the `id` and `execute-command` properties. All other properties are considered optional. ## Properties (keys) diff --git a/docs/Hook-Examples.md b/docs/Hook-Examples.md index 23896a7..ba581eb 100644 --- a/docs/Hook-Examples.md +++ b/docs/Hook-Examples.md @@ -1,5 +1,25 @@ -# Hook examples -This page is still work in progress. Feel free to contribute! +# Hook Examples + +Hooks are defined in a hooks configuration file in either JSON or YAML format, +although the examples on this page all use the JSON format. + +🌱 This page is still a work in progress. Feel free to contribute! + +### Table of Contents + +* [Incoming Github webhook](#incoming-github-webhook) +* [Incoming Bitbucket webhook](#incoming-bitbucket-webhook) +* [Incoming Gitlab webhook](#incoming-gitlab-webhook) +* [Incoming Gogs webhook](#incoming-gogs-webhook) +* [Incoming Gitea webhook](#incoming-gitea-webhook) +* [Slack slash command](#slack-slash-command) +* [A simple webhook with a secret key in GET query](#a-simple-webhook-with-a-secret-key-in-get-query) +* [JIRA Webhooks](#jira-webhooks) +* [Pass File-to-command sample](#pass-file-to-command-sample) +* [Incoming Scalr Webhook](#incoming-scalr-webhook) +* [Travis CI webhook](#travis-ci-webhook) +* [XML Payload](#xml-payload) +* [Multipart Form Data](#multipart-form-data) ## Incoming Github webhook ```json @@ -30,7 +50,7 @@ This page is still work in progress. Feel free to contribute! { "match": { - "type": "payload-hash-sha1", + "type": "payload-hmac-sha1", "secret": "mysecret", "parameter": { @@ -150,7 +170,7 @@ Values in the request body can be accessed in the command or to the match rule b { "match": { - "type": "payload-hash-sha256", + "type": "payload-hmac-sha256", "secret": "mysecret", "parameter": { @@ -425,6 +445,57 @@ Travis sends webhooks as `payload=`, so the payload needs to be par ] ``` +## JSON Array Payload + +If the JSON payload is an array instead of an object, `webhook` will process the payload and place it into a "root" object. +Therefore, references to payload values must begin with `root.`. + +For example, given the following payload (taken from the Sendgrid Event Webhook documentation): +```json +[ + { + "email": "example@test.com", + "timestamp": 1513299569, + "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>", + "event": "processed", + "category": "cat facts", + "sg_event_id": "sg_event_id", + "sg_message_id": "sg_message_id" + }, + { + "email": "example@test.com", + "timestamp": 1513299569, + "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>", + "event": "deferred", + "category": "cat facts", + "sg_event_id": "sg_event_id", + "sg_message_id": "sg_message_id", + "response": "400 try again later", + "attempt": "5" + } +] +``` + +A reference to the second item in the array would look like this: +```json +[ + { + "id": "sendgrid", + "execute-command": "{{ .Hookecho }}", + "trigger-rule": { + "match": { + "type": "value", + "parameter": { + "source": "payload", + "name": "root.1.event" + }, + "value": "deferred" + } + } + } +] +``` + ## XML Payload Given the following payload: diff --git a/docs/Hook-Rules.md b/docs/Hook-Rules.md index e1f92f6..2e5550b 100644 --- a/docs/Hook-Rules.md +++ b/docs/Hook-Rules.md @@ -1,5 +1,20 @@ # Hook rules +### Table of Contents + +* [And](#and) +* [Or](#or) +* [Not](#not) +* [Multi-level](#multi-level) +* [Match](#match) + * [Match value](#match-value) + * [Match regex](#match-regex) + * [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_. ```json @@ -95,7 +110,7 @@ "source": "header", "name": "X-Hub-Signature" }, - "type": "payload-hash-sha1", + "type": "payload-hmac-sha1", "secret": "mysecret" } }, @@ -135,9 +150,7 @@ *Please note:* Due to technical reasons, _number_ and _boolean_ values in the _match rule_ must be wrapped around with a pair of quotes. -There are three different match rules: - -### 1. Match value +### Match value ```json { "match": @@ -153,7 +166,7 @@ There are three different match rules: } ``` -### 2. Match regex +### Match regex For the regex syntax, check out ```json { @@ -170,12 +183,13 @@ For the regex syntax, check out } ``` -### 3. Match payload-hash-sha1 +### Match payload-hmac-sha1 +Validate the HMAC of the payload using the SHA1 hash and the given *secret*. ```json { "match": { - "type": "payload-hash-sha1", + "type": "payload-hmac-sha1", "secret": "yoursecret", "parameter": { @@ -193,12 +207,13 @@ will be tried unless a match is found. For example: X-Hub-Signature: sha1=the-first-signature,sha1=the-second-signature ``` -### 4. Match payload-hash-sha256 +### Match payload-hmac-sha256 +Validate the HMAC of the payload using the SHA256 hash and the given *secret*. ```json { "match": { - "type": "payload-hash-sha256", + "type": "payload-hmac-sha256", "secret": "yoursecret", "parameter": { @@ -216,12 +231,13 @@ will be tried unless a match is found. For example: X-Hub-Signature: sha256=the-first-signature,sha256=the-second-signature ``` -### 5. Match payload-hash-sha512 +### Match payload-hmac-sha512 +Validate the HMAC of the payload using the SHA512 hash and the given *secret*. ```json { "match": { - "type": "payload-hash-sha512", + "type": "payload-hmac-sha512", "secret": "yoursecret", "parameter": { @@ -239,7 +255,7 @@ will be tried unless a match is found. For example: X-Hub-Signature: sha512=the-first-signature,sha512=the-second-signature ``` -### 6. Match Whitelisted IP range +### 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`. @@ -253,7 +269,7 @@ The IP can be IPv4- or IPv6-formatted, using [CIDR notation](https://en.wikipedi } ``` -### 7. Match scalr-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 unqiue signing key is generated for each webhook endpoint URL you register in Scalr. @@ -267,4 +283,4 @@ Given the time check make sure that NTP is enabled on both your Scalr and webhoo "secret": "Scalr-provided signing key" } } -``` \ No newline at end of file +``` diff --git a/docs/Templates.md b/docs/Templates.md index 2adeab3..12798f4 100644 --- a/docs/Templates.md +++ b/docs/Templates.md @@ -1,12 +1,12 @@ # Templates in Webhook -[`webhook`][w] can parse the `hooks.json` input file as a Go template when given the `-template` [CLI parameter](Webhook-Parameters.md). +[`webhook`][w] can parse a hooks configuration file as a Go template when given the `-template` [CLI parameter](Webhook-Parameters.md). -In additional to the [built-in Go template functions and features][tt], `webhook` provides a `getenv` template function for inserting environment variables into a `hooks.json` file. +In additional to the [built-in Go template functions and features][tt], `webhook` provides a `getenv` template function for inserting environment variables into a templated configuration file. ## Example Usage -In the example `hooks.json` file below, the `payload-hash-sha1` matching rule looks up the secret hash from the environment using the `getenv` template function. +In the example JSON template file below (YAML is also supported), the `payload-hmac-sha1` matching rule looks up the HMAC secret from the environment using the `getenv` template function. Additionally, the result is piped through the built-in Go template function `js` to ensure that the result is a well-formed Javascript/JSON string. ``` @@ -44,7 +44,7 @@ Additionally, the result is piped through the built-in Go template function `js` { "match": { - "type": "payload-hash-sha1", + "type": "payload-hmac-sha1", "secret": "{{ getenv "XXXTEST_SECRET" | js }}", "parameter": { diff --git a/go.mod b/go.mod index bc7efb7..48f350c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/adnanh/webhook -go 1.13 +go 1.14 require ( github.com/clbanning/mxj v1.8.4 diff --git a/hooks.json.example b/hooks.json.example index 00f6ff3..90cd275 100644 --- a/hooks.json.example +++ b/hooks.json.example @@ -33,7 +33,7 @@ { "match": { - "type": "payload-hash-sha1", + "type": "payload-hmac-sha1", "secret": "mysecret", "parameter": { diff --git a/hooks.json.tmpl.example b/hooks.json.tmpl.example index 70e05c8..5c00643 100644 --- a/hooks.json.tmpl.example +++ b/hooks.json.tmpl.example @@ -33,7 +33,7 @@ { "match": { - "type": "payload-hash-sha1", + "type": "payload-hmac-sha1", "secret": "{{ getenv "XXXTEST_SECRET" | js }}", "parameter": { diff --git a/hooks.yaml.example b/hooks.yaml.example index 74713e0..ba9d0b7 100644 --- a/hooks.yaml.example +++ b/hooks.yaml.example @@ -15,7 +15,7 @@ trigger-rule: and: - match: - type: payload-hash-sha1 + type: payload-hmac-sha1 secret: mysecret parameter: source: header diff --git a/hooks.yaml.tmpl.example b/hooks.yaml.tmpl.example index 2bcfbd6..672c8d0 100644 --- a/hooks.yaml.tmpl.example +++ b/hooks.yaml.tmpl.example @@ -15,7 +15,7 @@ trigger-rule: and: - match: - type: payload-hash-sha1 + type: payload-hmac-sha1 secret: "{{ getenv "XXXTEST_SECRET" | js }}" parameter: source: header diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 0573a3f..d38415a 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -50,6 +50,33 @@ const ( EnvNamespace string = "HOOK_" ) +// Request represents a webhook request. +type Request struct { + // The request ID set by the RequestID middleware. + ID string + + // The Content-Type of the request. + ContentType string + + // The raw request body. + Body []byte + + // Headers is a map of the parsed headers. + Headers map[string]interface{} + + // Query is a map of the parsed URL query values. + Query map[string]interface{} + + // Payload is a map of the parsed payload. + Payload map[string]interface{} + + // Context is a map of the parsed pre-hook command result + Context map[string]interface{} + + // The underlying HTTP request. + RawRequest *http.Request +} + // ParameterNodeError describes an error walking a parameter node. type ParameterNodeError struct { key string @@ -76,6 +103,8 @@ func IsParameterNodeError(err error) bool { type SignatureError struct { Signature string Signatures []string + + emptyPayload bool } func (e *SignatureError) Error() string { @@ -83,11 +112,16 @@ func (e *SignatureError) Error() string { return "" } - if e.Signatures != nil { - return fmt.Sprintf("invalid payload signatures %s", e.Signatures) + var empty string + if e.emptyPayload { + empty = " on empty payload" } - return fmt.Sprintf("invalid payload signature %s", e.Signature) + if e.Signatures != nil { + return fmt.Sprintf("invalid payload signatures %s%s", e.Signatures, empty) + } + + return fmt.Sprintf("invalid payload signature %s%s", e.Signature, empty) } // ArgumentError describes an invalid argument passed to Hook. @@ -163,21 +197,24 @@ func ValidateMAC(payload []byte, mac hash.Hash, signatures []string) (string, er return "", err } - expectedMAC := hex.EncodeToString(mac.Sum(nil)) + actualMAC := hex.EncodeToString(mac.Sum(nil)) for _, signature := range signatures { - if hmac.Equal([]byte(signature), []byte(expectedMAC)) { - return expectedMAC, err + if hmac.Equal([]byte(signature), []byte(actualMAC)) { + return actualMAC, err } } - return expectedMAC, &SignatureError{ - Signatures: signatures, + e := &SignatureError{Signatures: signatures} + if len(payload) == 0 { + e.emptyPayload = true } + + return actualMAC, e } // CheckPayloadSignature calculates and verifies SHA1 signature of the given payload -func CheckPayloadSignature(payload []byte, secret string, signature string) (string, error) { +func CheckPayloadSignature(payload []byte, secret, signature string) (string, error) { if secret == "" { return "", errors.New("signature validation secret can not be empty") } @@ -190,7 +227,7 @@ func CheckPayloadSignature(payload []byte, secret string, signature string) (str } // CheckPayloadSignature256 calculates and verifies SHA256 signature of the given payload -func CheckPayloadSignature256(payload []byte, secret string, signature string) (string, error) { +func CheckPayloadSignature256(payload []byte, secret, signature string) (string, error) { if secret == "" { return "", errors.New("signature validation secret can not be empty") } @@ -203,7 +240,7 @@ func CheckPayloadSignature256(payload []byte, secret string, signature string) ( } // CheckPayloadSignature512 calculates and verifies SHA512 signature of the given payload -func CheckPayloadSignature512(payload []byte, secret string, signature string) (string, error) { +func CheckPayloadSignature512(payload []byte, secret, signature string) (string, error) { if secret == "" { return "", errors.New("signature validation secret can not be empty") } @@ -215,22 +252,26 @@ func CheckPayloadSignature512(payload []byte, secret string, signature string) ( return ValidateMAC(payload, hmac.New(sha512.New, []byte(secret)), signatures) } -func CheckScalrSignature(headers map[string]interface{}, body []byte, signingKey string, checkDate bool) (bool, error) { - // Check for the signature and date headers - if _, ok := headers["X-Signature"]; !ok { +func CheckScalrSignature(r *Request, signingKey string, checkDate bool) (bool, error) { + if r.Headers == nil { return false, nil } - if _, ok := headers["Date"]; !ok { + + // Check for the signature and date headers + if _, ok := r.Headers["X-Signature"]; !ok { + return false, nil + } + if _, ok := r.Headers["Date"]; !ok { return false, nil } if signingKey == "" { return false, errors.New("signature validation signing key can not be empty") } - providedSignature := headers["X-Signature"].(string) - dateHeader := headers["Date"].(string) + providedSignature := r.Headers["X-Signature"].(string) + dateHeader := r.Headers["Date"].(string) mac := hmac.New(sha1.New, []byte(signingKey)) - mac.Write(body) + mac.Write(r.Body) mac.Write([]byte(dateHeader)) expectedSignature := hex.EncodeToString(mac.Sum(nil)) @@ -257,7 +298,7 @@ func CheckScalrSignature(headers map[string]interface{}, body []byte, signingKey // CheckIPWhitelist makes sure the provided remote address (of the form IP:port) falls within the provided IP range // (in CIDR form or a single IP address). -func CheckIPWhitelist(remoteAddr string, ipRange string) (bool, error) { +func CheckIPWhitelist(remoteAddr, ipRange string) (bool, error) { // Extract IP address from remote address. // IPv6 addresses will likely be surrounded by []. @@ -296,7 +337,7 @@ func CheckIPWhitelist(remoteAddr string, ipRange string) (bool, error) { // ReplaceParameter replaces parameter value with the passed value in the passed map // (please note you should pass pointer to the map, because we're modifying it) // based on the passed string -func ReplaceParameter(s string, params interface{}, value interface{}) bool { +func ReplaceParameter(s string, params, value interface{}) bool { if params == nil { return false } @@ -385,14 +426,27 @@ func GetParameter(s string, params interface{}) (interface{}, error) { return nil, &ParameterNodeError{s} } -// ExtractParameterAsString extracts value from interface{} as string based on the passed string +// ExtractParameterAsString extracts value from interface{} as string based on +// the passed string. Complex data types are rendered as JSON instead of the Go +// Stringer format. func ExtractParameterAsString(s string, params interface{}) (string, error) { pValue, err := GetParameter(s, params) if err != nil { return "", err } - return fmt.Sprintf("%v", pValue), nil + switch v := reflect.ValueOf(pValue); v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice: + r, err := json.Marshal(pValue) + if err != nil { + return "", err + } + + return string(r), nil + + default: + return fmt.Sprintf("%v", pValue), nil + } } // Argument type specifies the parameter key name and the source it should @@ -406,43 +460,43 @@ type Argument struct { // Get Argument method returns the value for the Argument's key name // based on the Argument's source -func (ha *Argument) Get(headers, query, payload *map[string]interface{}, context *map[string]interface{}) (string, error) { +func (ha *Argument) Get(r *Request) (string, error) { var source *map[string]interface{} key := ha.Name switch ha.Source { case SourceHeader: - source = headers + source = &r.Headers key = textproto.CanonicalMIMEHeaderKey(ha.Name) case SourceQuery, SourceQueryAlias: - source = query + source = &r.Query case SourcePayload: - source = payload + source = &r.Payload case SourceContext: - source = context + source = &r.Context case SourceString: return ha.Name, nil case SourceEntirePayload: - r, err := json.Marshal(payload) + res, err := json.Marshal(&r.Payload) if err != nil { return "", err } - return string(r), nil + return string(res), nil case SourceEntireHeaders: - r, err := json.Marshal(headers) + res, err := json.Marshal(&r.Headers) if err != nil { return "", err } - return string(r), nil + return string(res), nil case SourceEntireQuery: - r, err := json.Marshal(query) + res, err := json.Marshal(&r.Query) if err != nil { return "", err } - return string(r), nil + return string(res), nil } if source != nil { @@ -540,11 +594,11 @@ type Hook struct { // ParseJSONParameters decodes specified arguments to JSON objects and replaces the // string with the newly created object -func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}, context *map[string]interface{}) []error { +func (h *Hook) ParseJSONParameters(r *Request) []error { errors := make([]error, 0) for i := range h.JSONStringParameters { - arg, err := h.JSONStringParameters[i].Get(headers, query, payload, context) + arg, err := h.JSONStringParameters[i].Get(r) if err != nil { errors = append(errors, &ArgumentError{h.JSONStringParameters[i]}) } else { @@ -563,13 +617,13 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface switch h.JSONStringParameters[i].Source { case SourceHeader: - source = headers + source = &r.Headers case SourcePayload: - source = payload + source = &r.Payload case SourceContext: - source = context + source = &r.Context case SourceQuery, SourceQueryAlias: - source = query + source = &r.Query } if source != nil { @@ -595,14 +649,14 @@ func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface // ExtractCommandArguments creates a list of arguments, based on the // PassArgumentsToCommand property that is ready to be used with exec.Command() -func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}, context *map[string]interface{}) ([]string, []error) { +func (h *Hook) ExtractCommandArguments(r *Request) ([]string, []error) { args := make([]string, 0) errors := make([]error, 0) args = append(args, h.ExecuteCommand) for i := range h.PassArgumentsToCommand { - arg, err := h.PassArgumentsToCommand[i].Get(headers, query, payload, context) + arg, err := h.PassArgumentsToCommand[i].Get(r) if err != nil { args = append(args, "") errors = append(errors, &ArgumentError{h.PassArgumentsToCommand[i]}) @@ -622,11 +676,11 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter // ExtractCommandArgumentsForEnv creates a list of arguments in key=value // format, based on the PassEnvironmentToCommand property that is ready to be used // with exec.Command(). -func (h *Hook) ExtractCommandArgumentsForEnv(headers, query, payload *map[string]interface{}, context *map[string]interface{}) ([]string, []error) { +func (h *Hook) ExtractCommandArgumentsForEnv(r *Request) ([]string, []error) { args := make([]string, 0) errors := make([]error, 0) for i := range h.PassEnvironmentToCommand { - arg, err := h.PassEnvironmentToCommand[i].Get(headers, query, payload, context) + arg, err := h.PassEnvironmentToCommand[i].Get(r) if err != nil { errors = append(errors, &ArgumentError{h.PassEnvironmentToCommand[i]}) continue @@ -658,11 +712,11 @@ type FileParameter struct { // ExtractCommandArgumentsForFile creates a list of arguments in key=value // format, based on the PassFileToCommand property that is ready to be used // with exec.Command(). -func (h *Hook) ExtractCommandArgumentsForFile(headers, query, payload *map[string]interface{}, context *map[string]interface{}) ([]FileParameter, []error) { +func (h *Hook) ExtractCommandArgumentsForFile(r *Request) ([]FileParameter, []error) { args := make([]FileParameter, 0) errors := make([]error, 0) for i := range h.PassFileToCommand { - arg, err := h.PassFileToCommand[i].Get(headers, query, payload, context) + arg, err := h.PassFileToCommand[i].Get(r) if err != nil { errors = append(errors, &ArgumentError{h.PassFileToCommand[i]}) continue @@ -769,16 +823,16 @@ type Rules struct { // Evaluate finds the first rule property that is not nil and returns the value // it evaluates to -func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, context *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { +func (r Rules) Evaluate(req *Request) (bool, error) { switch { case r.And != nil: - return r.And.Evaluate(headers, query, payload, context, body, remoteAddr) + return r.And.Evaluate(req) case r.Or != nil: - return r.Or.Evaluate(headers, query, payload, context, body, remoteAddr) + return r.Or.Evaluate(req) case r.Not != nil: - return r.Not.Evaluate(headers, query, payload, context, body, remoteAddr) + return r.Not.Evaluate(req) case r.Match != nil: - return r.Match.Evaluate(headers, query, payload, context, body, remoteAddr) + return r.Match.Evaluate(req) } return false, nil @@ -788,11 +842,11 @@ func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, context type AndRule []Rules // Evaluate AndRule will return true if and only if all of ChildRules evaluate to true -func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, context *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { +func (r AndRule) Evaluate(req *Request) (bool, error) { res := true for _, v := range r { - rv, err := v.Evaluate(headers, query, payload, context, body, remoteAddr) + rv, err := v.Evaluate(req) if err != nil { return false, err } @@ -810,11 +864,11 @@ func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, conte type OrRule []Rules // Evaluate OrRule will return true if any of ChildRules evaluate to true -func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, context *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { +func (r OrRule) Evaluate(req *Request) (bool, error) { res := false for _, v := range r { - rv, err := v.Evaluate(headers, query, payload, context, body, remoteAddr) + rv, err := v.Evaluate(req) if err != nil { return false, err } @@ -832,8 +886,8 @@ func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, contex type NotRule Rules // Evaluate NotRule will return true if and only if ChildRule evaluates to false -func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, context *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { - rv, err := Rules(r).Evaluate(headers, query, payload, context, body, remoteAddr) +func (r NotRule) Evaluate(req *Request) (bool, error) { + rv, err := Rules(r).Evaluate(req) return !rv, err } @@ -851,6 +905,9 @@ type MatchRule struct { const ( MatchValue string = "value" MatchRegex string = "regex" + 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" @@ -859,16 +916,16 @@ const ( ) // Evaluate MatchRule will return based on the type -func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, context *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { +func (r MatchRule) Evaluate(req *Request) (bool, error) { if r.Type == IPWhitelist { - return CheckIPWhitelist(remoteAddr, r.IPRange) + return CheckIPWhitelist(req.RawRequest.RemoteAddr, r.IPRange) } if r.Type == ScalrSignature { - return CheckScalrSignature(*headers, *body, r.Secret, true) + return CheckScalrSignature(req, r.Secret, true) } - arg, err := r.Parameter.Get(headers, query, payload, context) + arg, err := r.Parameter.Get(req) if err == nil { switch r.Type { case MatchValue: @@ -876,13 +933,22 @@ func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, con case MatchRegex: return regexp.MatchString(r.Regex, arg) case MatchHashSHA1: - _, err := CheckPayloadSignature(*body, r.Secret, arg) + 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: - _, err := CheckPayloadSignature256(*body, r.Secret, arg) + 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: - _, err := CheckPayloadSignature512(*body, r.Secret, arg) + 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 } } diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go index 7d84acf..84ad248 100644 --- a/internal/hook/hook_test.go +++ b/internal/hook/hook_test.go @@ -1,6 +1,7 @@ package hook import ( + "net/http" "os" "reflect" "strings" @@ -49,12 +50,14 @@ var checkPayloadSignatureTests = []struct { {[]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) { @@ -80,11 +83,13 @@ var checkPayloadSignature256Tests = []struct { {[]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) { @@ -109,9 +114,11 @@ var checkPayloadSignature512Tests = []struct { }{ {[]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) { @@ -130,7 +137,7 @@ func TestCheckPayloadSignature512(t *testing.T) { var checkScalrSignatureTests = []struct { description string headers map[string]interface{} - payload []byte + body []byte secret string expectedSignature string ok bool @@ -169,7 +176,11 @@ var checkScalrSignatureTests = []struct { func TestCheckScalrSignature(t *testing.T) { for _, testCase := range checkScalrSignatureTests { - valid, err := CheckScalrSignature(testCase.headers, testCase.payload, testCase.secret, false) + r := &Request{ + Headers: testCase.headers, + Body: testCase.body, + } + valid, err := CheckScalrSignature(r, testCase.secret, false) if valid != testCase.ok { t.Errorf("failed to check scalr signature fot test case: %s\nexpected ok:%#v, got ok:%#v}", testCase.description, testCase.ok, valid) @@ -217,6 +228,9 @@ var extractParameterTests = []struct { {"a.b.0", map[string]interface{}{"a": map[string]interface{}{"b": []interface{}{"x", "y", "z"}}}, "x", true}, {"a.1.b", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": "y"}, map[string]interface{}{"b": "z"}}}, "z", true}, {"a.1.b.c", map[string]interface{}{"a": []interface{}{map[string]interface{}{"b": map[string]interface{}{"c": "y"}}, map[string]interface{}{"b": map[string]interface{}{"c": "z"}}}}, "z", true}, + {"b", map[string]interface{}{"b": map[string]interface{}{"z": 1}}, `{"z":1}`, true}, + {"c", map[string]interface{}{"c": []interface{}{"y", "z"}}, `["y","z"]`, true}, + {"d", map[string]interface{}{"d": [2]interface{}{"y", "z"}}, `["y","z"]`, true}, // failures {"check_nil", nil, "", false}, {"a.X", map[string]interface{}{"a": map[string]interface{}{"b": "z"}}, "", false}, // non-existent parameter reference @@ -233,63 +247,75 @@ func TestExtractParameter(t *testing.T) { for _, tt := range extractParameterTests { value, err := ExtractParameterAsString(tt.s, tt.params) if (err == nil) != tt.ok || value != tt.value { - t.Errorf("failed to extract parameter %q:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, err:%s}", tt.s, tt.value, tt.ok, value, err) + t.Errorf("failed to extract parameter %q:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, err:%v}", tt.s, tt.value, tt.ok, value, err) } } } var argumentGetTests = []struct { source, name string - headers, query, payload, context *map[string]interface{} + headers, query, payload, context map[string]interface{} value string ok bool }{ - {"header", "a", &map[string]interface{}{"A": "z"}, nil, nil, nil, "z", true}, - {"url", "a", nil, &map[string]interface{}{"a": "z"}, nil, nil, "z", true}, - {"payload", "a", nil, nil, &map[string]interface{}{"a": "z"}, nil, "z", true}, - {"context", "a", nil, nil, nil, &map[string]interface{}{"a": "z"}, "z", true}, + {"header", "a", map[string]interface{}{"A": "z"}, nil, nil, nil, "z", true}, + {"url", "a", nil, map[string]interface{}{"a": "z"}, nil, nil, "z", true}, + {"payload", "a", nil, nil, map[string]interface{}{"a": "z"}, nil, "z", true}, + {"context", "a", nil, nil, nil, map[string]interface{}{"a": "z"}, "z", true}, {"string", "a", nil, nil, nil, nil, "a", true}, // failures - {"header", "a", nil, &map[string]interface{}{"a": "z"}, &map[string]interface{}{"a": "z"}, nil, "", false}, // nil headers - {"url", "a", &map[string]interface{}{"A": "z"}, nil, &map[string]interface{}{"a": "z"}, nil, "", false}, // nil query - {"payload", "a", &map[string]interface{}{"A": "z"}, &map[string]interface{}{"a": "z"}, nil, nil, "", false}, // nil payload + {"header", "a", nil, map[string]interface{}{"a": "z"}, map[string]interface{}{"a": "z"}, nil, "", false}, // nil headers + {"url", "a", map[string]interface{}{"A": "z"}, nil, map[string]interface{}{"a": "z"}, nil, "", false}, // nil query + {"payload", "a", map[string]interface{}{"A": "z"}, map[string]interface{}{"a": "z"}, nil, nil, "", false}, // nil payload {"context", "a", nil, nil, nil, nil, "", false}, // nil context - {"foo", "a", &map[string]interface{}{"A": "z"}, nil, nil, nil, "", false}, // invalid source + {"foo", "a", map[string]interface{}{"A": "z"}, nil, nil, nil, "", false}, // invalid source } func TestArgumentGet(t *testing.T) { for _, tt := range argumentGetTests { a := Argument{tt.source, tt.name, "", false} - value, err := a.Get(tt.headers, tt.query, tt.payload, tt.context) + r := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Context: tt.context, + } + value, err := a.Get(r) if (err == nil) != tt.ok || value != tt.value { - t.Errorf("failed to get {%q, %q}:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, err:%s}", tt.source, tt.name, tt.value, tt.ok, value, err) + t.Errorf("failed to get {%q, %q}:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, err:%v}", tt.source, tt.name, tt.value, tt.ok, value, err) } } } var hookParseJSONParametersTests = []struct { params []Argument - headers, query, payload, context *map[string]interface{} - rheaders, rquery, rpayload, rcontext *map[string]interface{} + headers, query, payload, context map[string]interface{} + rheaders, rquery, rpayload, rcontext map[string]interface{} ok bool }{ - {[]Argument{Argument{"header", "a", "", false}}, &map[string]interface{}{"A": `{"b": "y"}`}, nil, nil, nil, &map[string]interface{}{"A": map[string]interface{}{"b": "y"}}, nil, nil, nil, true}, - {[]Argument{Argument{"url", "a", "", false}}, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, nil, true}, - {[]Argument{Argument{"payload", "a", "", false}}, nil, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, true}, - {[]Argument{Argument{"context", "a", "", false}}, nil, nil, nil, &map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, nil, &map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, true}, - {[]Argument{Argument{"header", "z", "", false}}, &map[string]interface{}{"Z": `{}`}, nil, nil, nil, &map[string]interface{}{"Z": map[string]interface{}{}}, nil, nil, nil, true}, + {[]Argument{Argument{"header", "a", "", false}}, map[string]interface{}{"A": `{"b": "y"}`}, nil, nil, nil, map[string]interface{}{"A": map[string]interface{}{"b": "y"}}, nil, nil, nil, true}, + {[]Argument{Argument{"url", "a", "", false}}, nil, map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, nil, map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, nil, true}, + {[]Argument{Argument{"payload", "a", "", false}}, nil, nil, map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, nil, map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, nil, true}, + {[]Argument{Argument{"context", "a", "", false}}, nil, nil, nil, map[string]interface{}{"a": `{"b": "y"}`}, nil, nil, nil, map[string]interface{}{"a": map[string]interface{}{"b": "y"}}, true}, + {[]Argument{Argument{"header", "z", "", false}}, map[string]interface{}{"Z": `{}`}, nil, nil, nil, map[string]interface{}{"Z": map[string]interface{}{}}, nil, nil, nil, true}, // failures - {[]Argument{Argument{"header", "z", "", false}}, &map[string]interface{}{"Z": ``}, nil, nil, nil, &map[string]interface{}{"Z": ``}, nil, nil, nil, false}, // empty string - {[]Argument{Argument{"header", "y", "", false}}, &map[string]interface{}{"X": `{}`}, nil, nil, nil, &map[string]interface{}{"X": `{}`}, nil, nil, nil, false}, // missing parameter - {[]Argument{Argument{"string", "z", "", false}}, &map[string]interface{}{"Z": ``}, nil, nil, nil, &map[string]interface{}{"Z": ``}, nil, nil, nil, false}, // invalid argument source + {[]Argument{Argument{"header", "z", "", false}}, map[string]interface{}{"Z": ``}, nil, nil, nil, map[string]interface{}{"Z": ``}, nil, nil, nil, false}, // empty string + {[]Argument{Argument{"header", "y", "", false}}, map[string]interface{}{"X": `{}`}, nil, nil, nil, map[string]interface{}{"X": `{}`}, nil, nil, nil, false}, // missing parameter + {[]Argument{Argument{"string", "z", "", false}}, map[string]interface{}{"Z": ``}, nil, nil, nil, map[string]interface{}{"Z": ``}, nil, nil, nil, false}, // invalid argument source } func TestHookParseJSONParameters(t *testing.T) { for _, tt := range hookParseJSONParametersTests { h := &Hook{JSONStringParameters: tt.params} - err := h.ParseJSONParameters(tt.headers, tt.query, tt.payload, tt.context) + r := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Context: tt.context, + } + err := h.ParseJSONParameters(r) if (err == nil) != tt.ok || !reflect.DeepEqual(tt.headers, tt.rheaders) { - t.Errorf("failed to parse %v:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.params, *tt.rheaders, tt.ok, *tt.headers, (err == nil)) + t.Errorf("failed to parse %v:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.params, tt.rheaders, tt.ok, tt.headers, (err == nil)) } } } @@ -297,19 +323,25 @@ func TestHookParseJSONParameters(t *testing.T) { var hookExtractCommandArgumentsTests = []struct { exec string args []Argument - headers, query, payload, context *map[string]interface{} + headers, query, payload, context map[string]interface{} value []string ok bool }{ - {"test", []Argument{Argument{"header", "a", "", false}}, &map[string]interface{}{"A": "z"}, nil, nil, nil, []string{"test", "z"}, true}, + {"test", []Argument{Argument{"header", "a", "", false}}, map[string]interface{}{"A": "z"}, nil, nil, nil, []string{"test", "z"}, true}, // failures - {"fail", []Argument{Argument{"payload", "a", "", false}}, &map[string]interface{}{"A": "z"}, nil, nil, nil, []string{"fail", ""}, false}, + {"fail", []Argument{Argument{"payload", "a", "", false}}, map[string]interface{}{"A": "z"}, nil, nil, nil, []string{"fail", ""}, false}, } func TestHookExtractCommandArguments(t *testing.T) { for _, tt := range hookExtractCommandArgumentsTests { h := &Hook{ExecuteCommand: tt.exec, PassArgumentsToCommand: tt.args} - value, err := h.ExtractCommandArguments(tt.headers, tt.query, tt.payload, tt.context) + r := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Context: tt.context, + } + value, err := h.ExtractCommandArguments(r) if (err == nil) != tt.ok || !reflect.DeepEqual(value, tt.value) { t.Errorf("failed to extract args {cmd=%q, args=%v}:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.exec, tt.args, tt.value, tt.ok, value, (err == nil)) } @@ -338,7 +370,7 @@ func TestHookExtractCommandArguments(t *testing.T) { var hookExtractCommandArgumentsForEnvTests = []struct { exec string args []Argument - headers, query, payload, context *map[string]interface{} + headers, query, payload, context map[string]interface{} value []string ok bool }{ @@ -346,14 +378,14 @@ var hookExtractCommandArgumentsForEnvTests = []struct { { "test", []Argument{Argument{"header", "a", "", false}}, - &map[string]interface{}{"A": "z"}, nil, nil, nil, + map[string]interface{}{"A": "z"}, nil, nil, nil, []string{"HOOK_a=z"}, true, }, { "test", []Argument{Argument{"header", "a", "MYKEY", false}}, - &map[string]interface{}{"A": "z"}, nil, nil, nil, + map[string]interface{}{"A": "z"}, nil, nil, nil, []string{"MYKEY=z"}, true, }, @@ -361,7 +393,7 @@ var hookExtractCommandArgumentsForEnvTests = []struct { { "fail", []Argument{Argument{"payload", "a", "", false}}, - &map[string]interface{}{"A": "z"}, nil, nil, nil, + map[string]interface{}{"A": "z"}, nil, nil, nil, []string{}, false, }, @@ -370,7 +402,13 @@ var hookExtractCommandArgumentsForEnvTests = []struct { func TestHookExtractCommandArgumentsForEnv(t *testing.T) { for _, tt := range hookExtractCommandArgumentsForEnvTests { h := &Hook{ExecuteCommand: tt.exec, PassEnvironmentToCommand: tt.args} - value, err := h.ExtractCommandArgumentsForEnv(tt.headers, tt.query, tt.payload, tt.context) + r := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Context: tt.context, + } + value, err := h.ExtractCommandArgumentsForEnv(r) if (err == nil) != tt.ok || !reflect.DeepEqual(value, tt.value) { t.Errorf("failed to extract args for env {cmd=%q, args=%v}:\nexpected %#v, ok: %v\ngot %#v, ok: %v", tt.exec, tt.args, tt.value, tt.ok, value, (err == nil)) } @@ -448,24 +486,31 @@ func TestHooksMatch(t *testing.T) { var matchRuleTests = []struct { typ, regex, secret, value, ipRange string param Argument - headers, query, payload, context *map[string]interface{} + headers, query, payload, context map[string]interface{} body []byte remoteAddr string ok bool err bool }{ - {"value", "", "", "z", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", true, false}, - {"regex", "^z", "", "z", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", true, false}, - {"payload-hash-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, nil, []byte(`{"a": "z"}`), "", true, false}, - {"payload-hash-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, nil, []byte(`{"a": "z"}`), "", true, false}, + {"value", "", "", "z", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", true, false}, + {"regex", "^z", "", "z", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", true, false}, + {"payload-hmac-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, nil, []byte(`{"a": "z"}`), "", true, false}, + {"payload-hash-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, nil, []byte(`{"a": "z"}`), "", true, false}, + {"payload-hmac-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, nil, []byte(`{"a": "z"}`), "", true, false}, + {"payload-hash-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, nil, []byte(`{"a": "z"}`), "", true, false}, // failures - {"value", "", "", "X", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", false, false}, - {"regex", "^X", "", "", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", false, false}, - {"value", "", "2", "X", "", Argument{"header", "a", "", false}, &map[string]interface{}{"Y": "z"}, nil, nil, nil, []byte{}, "", false, true}, // reference invalid header + {"value", "", "", "X", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", false, false}, + {"regex", "^X", "", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", false, false}, + {"value", "", "2", "X", "", Argument{"header", "a", "", false}, map[string]interface{}{"Y": "z"}, nil, nil, nil, []byte{}, "", false, true}, // reference invalid header // errors - {"regex", "*", "", "", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", false, true}, // invalid regex - {"payload-hash-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": ""}, nil, nil, nil, []byte{}, "", false, true}, // invalid hmac - {"payload-hash-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, &map[string]interface{}{"A": ""}, nil, nil, nil, []byte{}, "", false, true}, // invalid hmac + {"regex", "*", "", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": "z"}, nil, nil, nil, []byte{}, "", false, true}, // invalid regex + {"payload-hmac-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, nil, []byte{}, "", false, true}, // invalid hmac + {"payload-hash-sha1", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, nil, []byte{}, "", false, true}, // invalid hmac + {"payload-hmac-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, nil, []byte{}, "", false, true}, // invalid hmac + {"payload-hash-sha256", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, nil, []byte{}, "", false, true}, // invalid hmac + {"payload-hmac-sha512", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, nil, []byte{}, "", false, true}, // invalid hmac + {"payload-hash-sha512", "", "secret", "", "", Argument{"header", "a", "", false}, map[string]interface{}{"A": ""}, nil, nil, nil, []byte{}, "", false, true}, // invalid hmac + // IP whitelisting, valid cases {"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, 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, nil, []byte{}, "192.168.0.2:9000", true, false}, // valid IPv4, with range @@ -484,7 +529,17 @@ var matchRuleTests = []struct { func TestMatchRule(t *testing.T) { for i, tt := range matchRuleTests { r := MatchRule{tt.typ, tt.regex, tt.secret, tt.value, tt.param, tt.ipRange} - ok, err := r.Evaluate(tt.headers, tt.query, tt.payload, tt.context, &tt.body, tt.remoteAddr) + req := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Context: tt.context, + Body: tt.body, + RawRequest: &http.Request{ + RemoteAddr: tt.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) } @@ -494,7 +549,7 @@ func TestMatchRule(t *testing.T) { var andRuleTests = []struct { desc string // description of the test case rule AndRule - headers, query, payload, context *map[string]interface{} + headers, query, payload, context map[string]interface{} body []byte ok bool err bool @@ -505,7 +560,7 @@ var andRuleTests = []struct { {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, }, - &map[string]interface{}{"A": "z", "B": "y"}, nil, nil, nil, []byte{}, + map[string]interface{}{"A": "z", "B": "y"}, nil, nil, nil, []byte{}, true, false, }, { @@ -514,7 +569,7 @@ var andRuleTests = []struct { {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, }, - &map[string]interface{}{"A": "z", "B": "Y"}, nil, nil, nil, []byte{}, + map[string]interface{}{"A": "z", "B": "Y"}, nil, nil, nil, []byte{}, false, false, }, // Complex test to cover Rules.Evaluate @@ -540,7 +595,7 @@ var andRuleTests = []struct { }, }, }, - &map[string]interface{}{"A": "z", "B": "y", "C": "x", "D": "w", "E": "X", "F": "X"}, nil, nil, nil, []byte{}, + map[string]interface{}{"A": "z", "B": "y", "C": "x", "D": "w", "E": "X", "F": "X"}, nil, nil, nil, []byte{}, true, false, }, {"empty rule", AndRule{{}}, nil, nil, nil, nil, nil, false, false}, @@ -548,14 +603,21 @@ var andRuleTests = []struct { { "invalid rule", AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", "", false}, ""}}}, - &map[string]interface{}{"Y": "z"}, nil, nil, nil, nil, + map[string]interface{}{"Y": "z"}, nil, nil, nil, nil, false, true, }, } func TestAndRule(t *testing.T) { for _, tt := range andRuleTests { - ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, tt.context, &tt.body, "") + r := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Context: tt.context, + Body: tt.body, + } + ok, err := tt.rule.Evaluate(r) if ok != tt.ok || (err != nil) != tt.err { t.Errorf("failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", tt.desc, tt.ok, tt.err, ok, err) } @@ -565,7 +627,7 @@ func TestAndRule(t *testing.T) { var orRuleTests = []struct { desc string // description of the test case rule OrRule - headers, query, payload, context *map[string]interface{} + headers, query, payload, context map[string]interface{} body []byte ok bool err bool @@ -576,7 +638,7 @@ var orRuleTests = []struct { {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, }, - &map[string]interface{}{"A": "z", "B": "X"}, nil, nil, nil, []byte{}, + map[string]interface{}{"A": "z", "B": "X"}, nil, nil, nil, []byte{}, true, false, }, { @@ -585,7 +647,7 @@ var orRuleTests = []struct { {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, }, - &map[string]interface{}{"A": "X", "B": "y"}, nil, nil, nil, []byte{}, + map[string]interface{}{"A": "X", "B": "y"}, nil, nil, nil, []byte{}, true, false, }, { @@ -594,7 +656,7 @@ var orRuleTests = []struct { {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", "", false}, ""}}, }, - &map[string]interface{}{"A": "Z", "B": "Y"}, nil, nil, nil, []byte{}, + map[string]interface{}{"A": "Z", "B": "Y"}, nil, nil, nil, []byte{}, false, false, }, // failures @@ -603,14 +665,21 @@ var orRuleTests = []struct { OrRule{ {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", "", false}, ""}}, }, - &map[string]interface{}{"Y": "Z"}, nil, nil, nil, []byte{}, + map[string]interface{}{"Y": "Z"}, nil, nil, nil, []byte{}, false, true, }, } func TestOrRule(t *testing.T) { for _, tt := range orRuleTests { - ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, tt.context, &tt.body, "") + r := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Context: tt.context, + Body: tt.body, + } + ok, err := tt.rule.Evaluate(r) if ok != tt.ok || (err != nil) != tt.err { t.Errorf("%#v:\nexpected ok: %#v, err: %v\ngot ok: %#v err: %v", tt.desc, tt.ok, tt.err, ok, err) } @@ -620,18 +689,25 @@ func TestOrRule(t *testing.T) { var notRuleTests = []struct { desc string // description of the test case rule NotRule - headers, query, payload, context *map[string]interface{} + headers, query, payload, context map[string]interface{} body []byte ok bool err bool }{ - {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", "", false}, ""}}, &map[string]interface{}{"A": "z"}, nil, 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, nil, []byte{}, false, false}, + {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", "", false}, ""}}, map[string]interface{}{"A": "z"}, nil, 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, nil, []byte{}, false, false}, } func TestNotRule(t *testing.T) { for _, tt := range notRuleTests { - ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, tt.context, &tt.body, "") + r := &Request{ + Headers: tt.headers, + Query: tt.query, + Payload: tt.payload, + Context: tt.context, + Body: tt.body, + } + ok, err := tt.rule.Evaluate(r) if ok != tt.ok || (err != nil) != tt.err { t.Errorf("failed to match %#v:\nexpected ok: %#v, err: %v\ngot ok: %#v, err: %v", tt.rule, tt.ok, tt.err, ok, err) } diff --git a/internal/middleware/dumper.go b/internal/middleware/dumper.go index 582c163..ae0e110 100644 --- a/internal/middleware/dumper.go +++ b/internal/middleware/dumper.go @@ -8,13 +8,11 @@ import ( "bytes" "fmt" "io" - "io/ioutil" "net" "net/http" + "net/http/httputil" "sort" "strings" - - "github.com/gorilla/mux" ) // responseDupper tees the response to a buffer and a response writer. @@ -34,70 +32,53 @@ func Dumper(w io.Writer) func(http.Handler) http.Handler { // Request ID rid := r.Context().Value(RequestIDKey) - // Request URL - buf.WriteString(fmt.Sprintf("> [%s] %s %s", rid, r.Method, r.URL.String())) + // Dump request - // Request Headers - keys := make([]string, len(r.Header)) - i := 0 - for k := range r.Header { - keys[i] = k - i++ - } - sort.Strings(keys) - for _, k := range keys { - buf.WriteString(fmt.Sprintf("\n> [%s] %s: %s", rid, k, strings.Join(r.Header[k], ", "))) - } - - // Request parameters - params := mux.Vars(r) - keys = make([]string, len(params)) - i = 0 - for k := range params { - keys[i] = k - i++ - } - sort.Strings(keys) - for _, k := range keys { - buf.WriteString(fmt.Sprintf("\n> [%s] %s: %s", rid, k, strings.Join(r.Header[k], ", "))) - } - - // Request body - b, err := ioutil.ReadAll(r.Body) + bd, err := httputil.DumpRequest(r, true) if err != nil { - b = []byte("failed to read body: " + err.Error()) + buf.WriteString(fmt.Sprintf("[%s] Error dumping request for debugging: %s\n", rid, err)) } - if len(b) > 0 { - buf.WriteByte('\n') - lines := strings.Split(string(b), "\n") - for _, line := range lines { - buf.WriteString(fmt.Sprintf("> [%s] %s\n", rid, line)) - } + + sc := bufio.NewScanner(bytes.NewBuffer(bd)) + sc.Split(bufio.ScanLines) + for sc.Scan() { + buf.WriteString(fmt.Sprintf("> [%s] ", rid)) + buf.WriteString(sc.Text() + "\n") } - r.Body = ioutil.NopCloser(bytes.NewBuffer(b)) + + w.Write(buf.Bytes()) + buf.Reset() + + // Dump Response dupper := &responseDupper{ResponseWriter: rw, Buffer: &bytes.Buffer{}} h.ServeHTTP(dupper, r) - buf.WriteString(fmt.Sprintf("\n< [%s] %s", rid, http.StatusText(dupper.Status))) - keys = make([]string, len(dupper.Header())) - i = 0 + // Response Status + buf.WriteString(fmt.Sprintf("< [%s] %d %s\n", rid, dupper.Status, http.StatusText(dupper.Status))) + + // Response Headers + keys := make([]string, len(dupper.Header())) + i := 0 for k := range dupper.Header() { keys[i] = k i++ } sort.Strings(keys) for _, k := range keys { - buf.WriteString(fmt.Sprintf("\n< [%s] %s: %s", rid, k, strings.Join(dupper.Header()[k], ", "))) + buf.WriteString(fmt.Sprintf("< [%s] %s: %s\n", rid, k, strings.Join(dupper.Header()[k], ", "))) } + + // Response Body if dupper.Buffer.Len() > 0 { - buf.WriteByte('\n') - lines := strings.Split(dupper.Buffer.String(), "\n") - for _, line := range lines { - buf.WriteString(fmt.Sprintf("< [%s] %s\n", rid, line)) + buf.WriteString(fmt.Sprintf("< [%s]\n", rid)) + sc = bufio.NewScanner(dupper.Buffer) + sc.Split(bufio.ScanLines) + for sc.Scan() { + buf.WriteString(fmt.Sprintf("< [%s] ", rid)) + buf.WriteString(sc.Text() + "\n") } } - buf.WriteByte('\n') w.Write(buf.Bytes()) }) } diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go index 50c6a44..899dd1c 100644 --- a/internal/middleware/logger.go +++ b/internal/middleware/logger.go @@ -39,18 +39,18 @@ type LogEntry struct { } // Write constructs and writes the final log entry. -func (l *LogEntry) Write(status, bytes int, elapsed time.Duration) { +func (l *LogEntry) Write(status, totalBytes int, elapsed time.Duration) { rid := GetReqID(l.req.Context()) if rid != "" { fmt.Fprintf(l.buf, "[%s] ", rid) } - fmt.Fprintf(l.buf, "%03d | %s | %s | ", status, humanize.IBytes(uint64(bytes)), elapsed) + fmt.Fprintf(l.buf, "%03d | %s | %s | ", status, humanize.IBytes(uint64(totalBytes)), elapsed) l.buf.WriteString(l.req.Host + " | " + l.req.Method + " " + l.req.RequestURI) log.Print(l.buf.String()) } -/// Panic prints the call stack for a panic. +// Panic prints the call stack for a panic. func (l *LogEntry) Panic(v interface{}, stack []byte) { e := l.NewLogEntry(l.req).(*LogEntry) fmt.Fprintf(e.buf, "panic: %#v", v) diff --git a/internal/pidfile/pidfile.go b/internal/pidfile/pidfile.go index c88c159..e602dcb 100644 --- a/internal/pidfile/pidfile.go +++ b/internal/pidfile/pidfile.go @@ -35,10 +35,10 @@ func New(path string) (*PIDFile, error) { return nil, err } // Note MkdirAll returns nil if a directory already exists - if err := MkdirAll(filepath.Dir(path), os.FileMode(0755)); err != nil { + if err := MkdirAll(filepath.Dir(path), os.FileMode(0o755)); err != nil { return nil, err } - if err := ioutil.WriteFile(path, []byte(fmt.Sprintf("%d", os.Getpid())), 0644); err != nil { + if err := ioutil.WriteFile(path, []byte(fmt.Sprintf("%d", os.Getpid())), 0o600); err != nil { return nil, err } diff --git a/test/hooks.json.tmpl b/test/hooks.json.tmpl index 0c7fc0c..3075762 100644 --- a/test/hooks.json.tmpl +++ b/test/hooks.json.tmpl @@ -31,7 +31,7 @@ { "match": { - "type": "payload-hash-sha1", + "type": "payload-hmac-sha1", "secret": "mysecret", "parameter": { @@ -168,6 +168,22 @@ ], } }, + { + "id": "sendgrid", + "execute-command": "{{ .Hookecho }}", + "command-working-directory": "/", + "response-message": "success", + "trigger-rule": { + "match": { + "type": "value", + "parameter": { + "source": "payload", + "name": "root.0.event" + }, + "value": "processed" + } + } + }, { "id": "plex", "execute-command": "{{ .Hookecho }}", @@ -259,5 +275,29 @@ "name": "passed" } ], + }, + { + "id": "empty-payload-signature", + "execute-command": "{{ .Hookecho }}", + "command-working-directory": "/", + "include-command-output-in-response": true, + "trigger-rule": + { + "and": + [ + { + "match": + { + "type": "payload-hmac-sha1", + "secret": "mysecret", + "parameter": + { + "source": "header", + "name": "X-Hub-Signature" + } + } + } + ] + } } ] diff --git a/test/hooks.yaml.tmpl b/test/hooks.yaml.tmpl index f1e1204..16aa8c1 100644 --- a/test/hooks.yaml.tmpl +++ b/test/hooks.yaml.tmpl @@ -8,7 +8,7 @@ source: header name: X-Hub-Signature secret: mysecret - type: payload-hash-sha1 + type: payload-hmac-sha1 - match: parameter: source: payload @@ -97,6 +97,18 @@ name: "app.messages.message.#text" value: "Hello!!" +- id: sendgrid + execute-command: '{{ .Hookecho }}' + command-working-directory: / + response-message: success + trigger-rule: + match: + type: value + parameter: + source: payload + name: root.0.event + value: processed + - id: plex trigger-rule: match: @@ -150,3 +162,16 @@ - id: warn-on-space execute-command: '{{ .Hookecho }} foo' include-command-output-in-response: true + +- id: empty-payload-signature + include-command-output-in-response: true + execute-command: '{{ .Hookecho }}' + command-working-directory: / + trigger-rule: + and: + - match: + parameter: + source: header + name: X-Hub-Signature + secret: mysecret + type: payload-hmac-sha1 diff --git a/tls.go b/tls.go index 8e6cb73..526fd36 100644 --- a/tls.go +++ b/tls.go @@ -8,7 +8,7 @@ import ( ) func writeTLSSupportedCipherStrings(w io.Writer, min uint16) error { - for _, c := range CipherSuites() { + for _, c := range tls.CipherSuites() { var found bool for _, v := range c.SupportedVersions { @@ -50,7 +50,7 @@ func getTLSMinVersion(v string) uint16 { // getTLSCipherSuites converts a comma separated list of cipher suites into a // slice of TLS cipher suite IDs. func getTLSCipherSuites(v string) []uint16 { - supported := CipherSuites() + supported := tls.CipherSuites() if v == "" { suites := make([]uint16, len(supported)) diff --git a/vendor/modules.txt b/vendor/modules.txt index 06d8a6a..8c121cf 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,20 +1,37 @@ # github.com/clbanning/mxj v1.8.4 +## explicit github.com/clbanning/mxj # github.com/dustin/go-humanize v1.0.0 +## explicit github.com/dustin/go-humanize +# github.com/fsnotify/fsnotify v1.4.7 +## explicit # github.com/ghodss/yaml v1.0.0 +## explicit github.com/ghodss/yaml # github.com/go-chi/chi v4.0.2+incompatible +## explicit github.com/go-chi/chi github.com/go-chi/chi/middleware # github.com/gofrs/uuid v3.2.0+incompatible +## explicit github.com/gofrs/uuid # github.com/gorilla/mux v1.7.3 +## explicit github.com/gorilla/mux +# github.com/kr/pretty v0.1.0 +## explicit +# golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 +## explicit # golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 +## explicit golang.org/x/sys/unix golang.org/x/sys/windows +# gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 +## explicit # gopkg.in/fsnotify.v1 v1.4.2 +## explicit gopkg.in/fsnotify.v1 # gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7 +## explicit gopkg.in/yaml.v2 diff --git a/webhook.go b/webhook.go index fb0116a..422e966 100644 --- a/webhook.go +++ b/webhook.go @@ -18,6 +18,7 @@ import ( "path/filepath" "strings" "time" + "unicode" "github.com/adnanh/webhook/internal/hook" "github.com/adnanh/webhook/internal/middleware" @@ -30,7 +31,7 @@ import ( ) const ( - version = "2.6.11" + version = "2.7.0" ) var ( @@ -143,7 +144,7 @@ func main() { } if *logPath != "" { - file, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + file, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) if err != nil { logQueue = append(logQueue, fmt.Sprintf("error opening log file %q: %v", *logPath, err)) // we'll bail out below @@ -303,10 +304,14 @@ func main() { } func hookHandler(w http.ResponseWriter, r *http.Request) { - rid := middleware.GetReqID(r.Context()) + req := &hook.Request{ + ID: middleware.GetReqID(r.Context()), + RawRequest: r, + } - log.Printf("[%s] incoming HTTP %s request from %s\n", rid, r.Method, r.RemoteAddr) + log.Printf("[%s] incoming HTTP %s request from %s\n", req.ID, r.Method, r.RemoteAddr) + // TODO: rename this to avoid confusion with Request.ID id := mux.Vars(r)["id"] matchedHook := matchLoadedHook(id) @@ -342,138 +347,89 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { if !allowedMethod { w.WriteHeader(http.StatusMethodNotAllowed) - log.Printf("[%s] HTTP %s method not allowed for hook %q", rid, r.Method, id) + log.Printf("[%s] HTTP %s method not allowed for hook %q", req.ID, r.Method, id) return } - log.Printf("[%s] %s got matched\n", rid, id) + log.Printf("[%s] %s got matched\n", req.ID, id) for _, responseHeader := range responseHeaders { w.Header().Set(responseHeader.Name, responseHeader.Value) } - var ( - body []byte - err error - ) + var err error // set contentType to IncomingPayloadContentType or header value - contentType := r.Header.Get("Content-Type") + req.ContentType = r.Header.Get("Content-Type") if len(matchedHook.IncomingPayloadContentType) != 0 { - contentType = matchedHook.IncomingPayloadContentType + req.ContentType = matchedHook.IncomingPayloadContentType } - isMultipart := strings.HasPrefix(contentType, "multipart/form-data;") + isMultipart := strings.HasPrefix(req.ContentType, "multipart/form-data;") if !isMultipart { - body, err = ioutil.ReadAll(r.Body) + req.Body, err = ioutil.ReadAll(r.Body) if err != nil { - log.Printf("[%s] error reading the request body: %+v\n", rid, err) + log.Printf("[%s] error reading the request body: %+v\n", req.ID, err) } } // parse headers - headers := valuesToMap(r.Header) + req.Headers = valuesToMap(r.Header) // parse query variables - query := valuesToMap(r.URL.Query()) + req.Query = valuesToMap(r.URL.Query()) // parse body - var payload map[string]interface{} - // parse context - var context map[string]interface{} - - if matchedHook.PreHookCommand != "" { - // check the command exists - preHookCommandPath, err := exec.LookPath(matchedHook.PreHookCommand) - if err != nil { - // give a last chance, maybe it's a relative path - preHookCommandPathRelativeToCurrentWorkingDirectory := filepath.Join(matchedHook.CommandWorkingDirectory, matchedHook.PreHookCommand) - // check the command exists - preHookCommandPath, err = exec.LookPath(preHookCommandPathRelativeToCurrentWorkingDirectory) - } - - if err != nil { - log.Printf("[%s] unable to locate pre-hook command: '%s', %+v\n", rid, matchedHook.PreHookCommand, err) - // check if parameters specified in pre-hook command by mistake - if strings.IndexByte(matchedHook.PreHookCommand, ' ') != -1 { - s := strings.Fields(matchedHook.PreHookCommand)[0] - log.Printf("[%s] please use a wrapper script to provide arguments to pre-hook command for '%s'\n", rid, s) - } - } else { - preHookCommandStdin := hook.PreHookContext{ - HookID: matchedHook.ID, - Method: r.Method, - Base64EncodedBody: base64.StdEncoding.EncodeToString(body), - RemoteAddr: r.RemoteAddr, - URI: r.RequestURI, - Host: r.Host, - Headers: r.Header, - Query: r.URL.Query(), - } - - if preHookCommandStdinJSONString, err := json.Marshal(preHookCommandStdin); err != nil { - log.Printf("[%s] unable to encode pre-hook context as JSON string for the pre-hook command: %+v\n", rid, err) - } else { - preHookCommand := exec.Command(preHookCommandPath) - preHookCommand.Dir = matchedHook.CommandWorkingDirectory - preHookCommand.Env = append(os.Environ()) - - if preHookCommandStdinPipe, err := preHookCommand.StdinPipe(); err != nil { - log.Printf("[%s] unable to acquire stdin pipe for the pre-hook command: %+v\n", rid, err) - } else { - _, err := io.WriteString(preHookCommandStdinPipe, string(preHookCommandStdinJSONString)) - preHookCommandStdinPipe.Close() - if err != nil { - log.Printf("[%s] unable to write to pre-hook command stdin: %+v\n", rid, err) - } else { - log.Printf("[%s] executing pre-hook command %s (%s) using %s as cwd\n", rid, matchedHook.PreHookCommand, preHookCommand.Path, preHookCommand.Dir) - - if preHookCommandOutput, err := preHookCommand.CombinedOutput(); err != nil { - log.Printf("[%s] unable to execute pre-hook command: %+v\n", rid, err) - } else { - JSONDecoder := json.NewDecoder(strings.NewReader(string(preHookCommandOutput))) - JSONDecoder.UseNumber() - - if err := JSONDecoder.Decode(&context); err != nil { - log.Printf("[%s] unable to parse pre-hook command output: %+v\npre-hook command output was: %+v\n", rid, err, string(preHookCommandOutput)) - } - } - } - } - } - } - } - switch { - case strings.Contains(contentType, "json"): - decoder := json.NewDecoder(bytes.NewReader(body)) + case strings.Contains(req.ContentType, "json"): + decoder := json.NewDecoder(bytes.NewReader(req.Body)) decoder.UseNumber() - err := decoder.Decode(&payload) - if err != nil { - log.Printf("[%s] error parsing JSON payload %+v\n", rid, err) + var firstChar byte + for i := 0; i < len(req.Body); i++ { + if unicode.IsSpace(rune(req.Body[i])) { + continue + } + firstChar = req.Body[i] + break } - case strings.Contains(contentType, "x-www-form-urlencoded"): - fd, err := url.ParseQuery(string(body)) - if err != nil { - log.Printf("[%s] error parsing form payload %+v\n", rid, err) + if firstChar == byte('[') { + var arrayPayload interface{} + err := decoder.Decode(&arrayPayload) + if err != nil { + log.Printf("[%s] error parsing JSON array payload %+v\n", req.ID, err) + } + + req.Payload = make(map[string]interface{}, 1) + req.Payload["root"] = arrayPayload } else { - payload = valuesToMap(fd) + err := decoder.Decode(&req.Payload) + if err != nil { + log.Printf("[%s] error parsing JSON payload %+v\n", req.ID, err) + } } - case strings.Contains(contentType, "xml"): - payload, err = mxj.NewMapXmlReader(bytes.NewReader(body)) + case strings.Contains(req.ContentType, "x-www-form-urlencoded"): + fd, err := url.ParseQuery(string(req.Body)) if err != nil { - log.Printf("[%s] error parsing XML payload: %+v\n", rid, err) + log.Printf("[%s] error parsing form payload %+v\n", req.ID, err) + } else { + req.Payload = valuesToMap(fd) + } + + case strings.Contains(req.ContentType, "xml"): + req.Payload, err = mxj.NewMapXmlReader(bytes.NewReader(req.Body)) + if err != nil { + log.Printf("[%s] error parsing XML payload: %+v\n", req.ID, err) } case isMultipart: err = r.ParseMultipartForm(*maxMultipartMem) if err != nil { - msg := fmt.Sprintf("[%s] error parsing multipart form: %+v\n", rid, err) + msg := fmt.Sprintf("[%s] error parsing multipart form: %+v\n", req.ID, err) log.Println(msg) w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "Error occurred while parsing multipart form.") @@ -481,14 +437,14 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { } for k, v := range r.MultipartForm.Value { - log.Printf("[%s] found multipart form value %q", rid, k) + log.Printf("[%s] found multipart form value %q", req.ID, k) - if payload == nil { - payload = make(map[string]interface{}) + if req.Payload == nil { + req.Payload = make(map[string]interface{}) } // TODO(moorereason): support duplicate, named values - payload[k] = v[0] + req.Payload[k] = v[0] } for k, v := range r.MultipartForm.File { @@ -517,11 +473,11 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { } if parseAsJSON { - log.Printf("[%s] parsing multipart form file %q as JSON\n", rid, k) + log.Printf("[%s] parsing multipart form file %q as JSON\n", req.ID, k) f, err := v[0].Open() if err != nil { - msg := fmt.Sprintf("[%s] error parsing multipart form file: %+v\n", rid, err) + msg := fmt.Sprintf("[%s] error parsing multipart form file: %+v\n", req.ID, err) log.Println(msg) w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "Error occurred while parsing multipart form file.") @@ -534,24 +490,87 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { var part map[string]interface{} err = decoder.Decode(&part) if err != nil { - log.Printf("[%s] error parsing JSON payload file: %+v\n", rid, err) + log.Printf("[%s] error parsing JSON payload file: %+v\n", req.ID, err) } - if payload == nil { - payload = make(map[string]interface{}) + if req.Payload == nil { + req.Payload = make(map[string]interface{}) } - payload[k] = part + req.Payload[k] = part } } default: - log.Printf("[%s] error parsing body payload due to unsupported content type header: %s\n", rid, contentType) + log.Printf("[%s] error parsing body payload due to unsupported content type header: %s\n", req.ID, req.ContentType) + } + + if matchedHook.PreHookCommand != "" { + // check the command exists + var lookpath string + if filepath.IsAbs(matchedHook.PreHookCommand) || matchedHook.CommandWorkingDirectory == "" { + lookpath = matchedHook.PreHookCommand + } else { + lookpath = filepath.Join(matchedHook.CommandWorkingDirectory, matchedHook.PreHookCommand) + } + + preHookCommandPath, err := exec.LookPath(lookpath) + + if err != nil { + log.Printf("[%s] unable to locate pre-hook command: '%s', %+v\n", req.ID, matchedHook.PreHookCommand, err) + // check if parameters specified in pre-hook command by mistake + if strings.IndexByte(matchedHook.PreHookCommand, ' ') != -1 { + s := strings.Fields(matchedHook.PreHookCommand)[0] + log.Printf("[%s] please use a wrapper script to provide arguments to pre-hook command for '%s'\n", req.ID, s) + } + } else { + preHookCommandStdin := hook.PreHookContext{ + HookID: matchedHook.ID, + Method: r.Method, + Base64EncodedBody: base64.StdEncoding.EncodeToString(req.Body), + RemoteAddr: r.RemoteAddr, + URI: r.RequestURI, + Host: r.Host, + Headers: r.Header, + Query: r.URL.Query(), + } + + if preHookCommandStdinJSONString, err := json.Marshal(preHookCommandStdin); err != nil { + log.Printf("[%s] unable to encode pre-hook context as JSON string for the pre-hook command: %+v\n", req.ID, err) + } else { + preHookCommand := exec.Command(preHookCommandPath) + preHookCommand.Dir = matchedHook.CommandWorkingDirectory + preHookCommand.Env = append(os.Environ()) + + if preHookCommandStdinPipe, err := preHookCommand.StdinPipe(); err != nil { + log.Printf("[%s] unable to acquire stdin pipe for the pre-hook command: %+v\n", req.ID, err) + } else { + _, err := io.WriteString(preHookCommandStdinPipe, string(preHookCommandStdinJSONString)) + preHookCommandStdinPipe.Close() + if err != nil { + log.Printf("[%s] unable to write to pre-hook command stdin: %+v\n", req.ID, err) + } else { + log.Printf("[%s] executing pre-hook command %s (%s) using %s as cwd\n", req.ID, matchedHook.PreHookCommand, preHookCommand.Path, preHookCommand.Dir) + + if preHookCommandOutput, err := preHookCommand.CombinedOutput(); err != nil { + log.Printf("[%s] unable to execute pre-hook command: %+v\n", req.ID, err) + } else { + JSONDecoder := json.NewDecoder(strings.NewReader(string(preHookCommandOutput))) + JSONDecoder.UseNumber() + + if err := JSONDecoder.Decode(&req.Context); err != nil { + log.Printf("[%s] unable to parse pre-hook command output: %+v\npre-hook command output was: %+v\n", req.ID, err, string(preHookCommandOutput)) + } + } + } + } + } + } } // handle hook - errors := matchedHook.ParseJSONParameters(&headers, &query, &payload, &context) + errors := matchedHook.ParseJSONParameters(req) for _, err := range errors { - log.Printf("[%s] error parsing JSON parameters: %s\n", rid, err) + log.Printf("[%s] error parsing JSON parameters: %s\n", req.ID, err) } var ok bool @@ -559,29 +578,29 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { if matchedHook.TriggerRule == nil { ok = true } else { - ok, err = matchedHook.TriggerRule.Evaluate(&headers, &query, &payload, &context, &body, r.RemoteAddr) + ok, err = matchedHook.TriggerRule.Evaluate(req) if err != nil { if !hook.IsParameterNodeError(err) { - msg := fmt.Sprintf("[%s] error evaluating hook: %s", rid, err) + msg := fmt.Sprintf("[%s] error evaluating hook: %s", req.ID, err) log.Println(msg) w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "Error occurred while evaluating hook rules.") return } - log.Printf("[%s] %v", rid, err) + log.Printf("[%s] %v", req.ID, err) } } if ok { - log.Printf("[%s] %s hook triggered successfully\n", rid, matchedHook.ID) + log.Printf("[%s] %s hook triggered successfully\n", req.ID, matchedHook.ID) for _, responseHeader := range matchedHook.ResponseHeaders { w.Header().Set(responseHeader.Name, responseHeader.Value) } if matchedHook.CaptureCommandOutput { - response, err := handleHook(matchedHook, rid, &headers, &query, &payload, &context, &body) + response, err := handleHook(matchedHook, req) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -594,16 +613,16 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { } else { // Check if a success return code is configured for the hook if matchedHook.SuccessHttpResponseCode != 0 { - writeHttpResponseCode(w, rid, matchedHook.ID, matchedHook.SuccessHttpResponseCode) + writeHttpResponseCode(w, req.ID, matchedHook.ID, matchedHook.SuccessHttpResponseCode) } fmt.Fprint(w, response) } } else { - go handleHook(matchedHook, rid, &headers, &query, &payload, &context, &body) + go handleHook(matchedHook, req) // Check if a success return code is configured for the hook if matchedHook.SuccessHttpResponseCode != 0 { - writeHttpResponseCode(w, rid, matchedHook.ID, matchedHook.SuccessHttpResponseCode) + writeHttpResponseCode(w, req.ID, matchedHook.ID, matchedHook.SuccessHttpResponseCode) } fmt.Fprint(w, matchedHook.ResponseMessage) @@ -613,34 +632,34 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { // Check if a return code is configured for the hook if matchedHook.TriggerRuleMismatchHttpResponseCode != 0 { - writeHttpResponseCode(w, rid, matchedHook.ID, matchedHook.TriggerRuleMismatchHttpResponseCode) + writeHttpResponseCode(w, req.ID, matchedHook.ID, matchedHook.TriggerRuleMismatchHttpResponseCode) } // if none of the hooks got triggered - log.Printf("[%s] %s got matched, but didn't get triggered because the trigger rules were not satisfied\n", rid, matchedHook.ID) + log.Printf("[%s] %s got matched, but didn't get triggered because the trigger rules were not satisfied\n", req.ID, matchedHook.ID) fmt.Fprint(w, "Hook rules were not satisfied.") } -func handleHook(h *hook.Hook, rid string, headers, query, payload *map[string]interface{}, context *map[string]interface{}, body *[]byte) (string, error) { +func handleHook(h *hook.Hook, r *hook.Request) (string, error) { var errors []error // check the command exists - cmdPath, err := exec.LookPath(h.ExecuteCommand) - if err != nil { - // give a last chance, maybe is a relative path - relativeToCwd := filepath.Join(h.CommandWorkingDirectory, h.ExecuteCommand) - // check the command exists - cmdPath, err = exec.LookPath(relativeToCwd) + var lookpath string + if filepath.IsAbs(h.ExecuteCommand) || h.CommandWorkingDirectory == "" { + lookpath = h.ExecuteCommand + } else { + lookpath = filepath.Join(h.CommandWorkingDirectory, h.ExecuteCommand) } + cmdPath, err := exec.LookPath(lookpath) if err != nil { - log.Printf("[%s] unable to locate command: '%s'\n", rid, h.ExecuteCommand) + log.Printf("[%s] error in %s", r.ID, err) // check if parameters specified in execute-command by mistake if strings.IndexByte(h.ExecuteCommand, ' ') != -1 { s := strings.Fields(h.ExecuteCommand)[0] - log.Printf("[%s] please use 'pass-arguments-to-command' to specify args for '%s'\n", rid, s) + log.Printf("[%s] use 'pass-arguments-to-command' to specify args for '%s'", r.ID, s) } return "", err @@ -649,37 +668,37 @@ func handleHook(h *hook.Hook, rid string, headers, query, payload *map[string]in cmd := exec.Command(cmdPath) cmd.Dir = h.CommandWorkingDirectory - cmd.Args, errors = h.ExtractCommandArguments(headers, query, payload, context) + cmd.Args, errors = h.ExtractCommandArguments(r) for _, err := range errors { - log.Printf("[%s] error extracting command arguments: %s\n", rid, err) + log.Printf("[%s] error extracting command arguments: %s\n", r.ID, err) } var envs []string - envs, errors = h.ExtractCommandArgumentsForEnv(headers, query, payload, context) + envs, errors = h.ExtractCommandArgumentsForEnv(r) for _, err := range errors { - log.Printf("[%s] error extracting command arguments for environment: %s\n", rid, err) + log.Printf("[%s] error extracting command arguments for environment: %s\n", r.ID, err) } - files, errors := h.ExtractCommandArgumentsForFile(headers, query, payload, context) + files, errors := h.ExtractCommandArgumentsForFile(r) for _, err := range errors { - log.Printf("[%s] error extracting command arguments for file: %s\n", rid, err) + log.Printf("[%s] error extracting command arguments for file: %s\n", r.ID, err) } for i := range files { tmpfile, err := ioutil.TempFile(h.CommandWorkingDirectory, files[i].EnvName) if err != nil { - log.Printf("[%s] error creating temp file [%s]\n", rid, err) + log.Printf("[%s] error creating temp file [%s]", r.ID, err) continue } - log.Printf("[%s] writing env %s file %s", rid, files[i].EnvName, tmpfile.Name()) + log.Printf("[%s] writing env %s file %s", r.ID, files[i].EnvName, tmpfile.Name()) if _, err := tmpfile.Write(files[i].Data); err != nil { - log.Printf("[%s] error writing file %s [%s]\n", rid, tmpfile.Name(), err) + log.Printf("[%s] error writing file %s [%s]", r.ID, tmpfile.Name(), err) continue } if err := tmpfile.Close(); err != nil { - log.Printf("[%s] error closing file %s [%s]\n", rid, tmpfile.Name(), err) + log.Printf("[%s] error closing file %s [%s]", r.ID, tmpfile.Name(), err) continue } @@ -689,32 +708,32 @@ func handleHook(h *hook.Hook, rid string, headers, query, payload *map[string]in cmd.Env = append(os.Environ(), envs...) - log.Printf("[%s] executing %s (%s) with arguments %q and environment %s using %s as cwd\n", rid, h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir) + log.Printf("[%s] executing %s (%s) with arguments %q and environment %s using %s as cwd\n", r.ID, h.ExecuteCommand, cmd.Path, cmd.Args, envs, cmd.Dir) out, err := cmd.CombinedOutput() - log.Printf("[%s] command output: %s\n", rid, out) + log.Printf("[%s] command output: %s\n", r.ID, out) if err != nil { - log.Printf("[%s] error occurred: %+v\n", rid, err) + log.Printf("[%s] error occurred: %+v\n", r.ID, err) } for i := range files { if files[i].File != nil { - log.Printf("[%s] removing file %s\n", rid, files[i].File.Name()) + log.Printf("[%s] removing file %s\n", r.ID, files[i].File.Name()) err := os.Remove(files[i].File.Name()) if err != nil { - log.Printf("[%s] error removing file %s [%s]\n", rid, files[i].File.Name(), err) + log.Printf("[%s] error removing file %s [%s]", r.ID, files[i].File.Name(), err) } } } - log.Printf("[%s] finished handling %s\n", rid, h.ID) + log.Printf("[%s] finished handling %s\n", r.ID, h.ID) return string(out), err } -func writeHttpResponseCode(w http.ResponseWriter, rid string, hookId string, responseCode int) { +func writeHttpResponseCode(w http.ResponseWriter, rid, hookId string, responseCode int) { // Check if the given return code is supported by the http package // by testing if there is a StatusText for this code. if len(http.StatusText(responseCode)) > 0 { diff --git a/webhook_test.go b/webhook_test.go index 3b10060..c8a46cc 100644 --- a/webhook_test.go +++ b/webhook_test.go @@ -53,7 +53,11 @@ func TestStaticParams(t *testing.T) { b := &bytes.Buffer{} log.SetOutput(b) - _, err = handleHook(spHook, "test", &spHeaders, &map[string]interface{}{}, &map[string]interface{}{}, &map[string]interface{}{}, &[]byte{}) + r := &hook.Request{ + ID: "test", + Headers: spHeaders, + } + _, err = handleHook(spHook, r) if err != nil { t.Fatalf("Unexpected error: %v\n", err) } @@ -77,7 +81,7 @@ func TestWebhook(t *testing.T) { for _, tt := range hookHandlerTests { t.Run(tt.desc+"@"+hookTmpl, func(t *testing.T) { ip, port := serverAddress(t) - args := []string{fmt.Sprintf("-hooks=%s", configPath), fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"} + args := []string{fmt.Sprintf("-hooks=%s", configPath), fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-debug"} if len(tt.cliMethods) != 0 { args = append(args, "-http-methods="+strings.Join(tt.cliMethods, ",")) @@ -111,6 +115,7 @@ func TestWebhook(t *testing.T) { var res *http.Response req.Header.Add("Content-Type", tt.contentType) + req.ContentLength = int64(len(tt.body)) client := &http.Client{} res, err = client.Do(req) @@ -171,7 +176,7 @@ func buildHookecho(t *testing.T) (binPath string, cleanupFn func()) { return binPath, func() { os.RemoveAll(tmp) } } -func genConfig(t *testing.T, bin string, hookTemplate string) (configPath string, cleanupFn func()) { +func genConfig(t *testing.T, bin, hookTemplate string) (configPath string, cleanupFn func()) { tmpl := template.Must(template.ParseFiles(hookTemplate)) tmp, err := ioutil.TempDir("", "webhook-config-") @@ -546,6 +551,28 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00 `success`, ``, }, + { + "payload-json-array", + "sendgrid", + nil, + "POST", + nil, + "application/json", + `[ + { + "email": "example@test.com", + "timestamp": 1513299569, + "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>", + "event": "processed", + "category": "cat facts", + "sg_event_id": "sg_event_id", + "sg_message_id": "sg_message_id" + } +]`, + http.StatusOK, + `success`, + ``, + }, { "multipart", "plex", @@ -663,6 +690,19 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00 ``, }, + { + "empty-payload-signature", // allow empty payload signature validation + "empty-payload-signature", + nil, + "POST", + map[string]string{"X-Hub-Signature": "33f9d709782f62b8b4a0178586c65ab098a39fe2"}, + "application/json", + ``, + http.StatusOK, + ``, + ``, + }, + // test with disallowed global HTTP method {"global disallowed method", "bitbucket", []string{"Post "}, "GET", nil, `{}`, "application/json", http.StatusMethodNotAllowed, ``, ``}, // test with disallowed HTTP method @@ -684,7 +724,7 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00 // Check logs {"static params should pass", "static-params-ok", nil, "POST", nil, "application/json", `{}`, http.StatusOK, "arg: passed\n", `(?s)command output: arg: passed`}, - {"command with space logs warning", "warn-on-space", nil, "POST", nil, "application/json", `{}`, http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details.", `(?s)unable to locate command.*use 'pass[-]arguments[-]to[-]command' to specify args`}, + {"command with space logs warning", "warn-on-space", nil, "POST", nil, "application/json", `{}`, http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details.", `(?s)error in exec:.*use 'pass[-]arguments[-]to[-]command' to specify args`}, {"unsupported content type error", "github", nil, "POST", map[string]string{"Content-Type": "nonexistent/format"}, "application/json", `{}`, http.StatusBadRequest, `Hook rules were not satisfied.`, `(?s)error parsing body payload due to unsupported content type header:`}, }