diff --git a/hook/hook.go b/hook/hook.go index 02859c6..b336e54 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -3,11 +3,13 @@ package hook import ( "crypto/hmac" "crypto/sha1" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/ioutil" + "net" "net/textproto" "reflect" "regexp" @@ -100,6 +102,67 @@ func CheckPayloadSignature(payload []byte, secret string, signature string) (str return expectedMAC, err } +// CheckPayloadSignature256 calculates and verifies SHA256 signature of the given payload +func CheckPayloadSignature256(payload []byte, secret string, signature string) (string, error) { + if strings.HasPrefix(signature, "sha256=") { + signature = signature[7:] + } + + mac := hmac.New(sha256.New, []byte(secret)) + _, err := mac.Write(payload) + if err != nil { + return "", err + } + expectedMAC := hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(signature), []byte(expectedMAC)) { + return expectedMAC, &SignatureError{signature} + } + return expectedMAC, err +} + +// 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) { + // Extract IP address from remote address. + + ip := remoteAddr + + if strings.LastIndex(remoteAddr, ":") != -1 { + ip = remoteAddr[0:strings.LastIndex(remoteAddr, ":")] + } + + ip = strings.TrimSpace(ip) + + // IPv6 addresses will likely be surrounded by [], so don't forget to remove those. + + if strings.HasPrefix(ip, "[") && strings.HasSuffix(ip, "]") { + ip = ip[1 : len(ip)-1] + } + + parsedIP := net.ParseIP(strings.TrimSpace(ip)) + + if parsedIP == nil { + return false, fmt.Errorf("invalid IP address found in remote address '%s'", remoteAddr) + } + + // Extract IP range in CIDR form. If a single IP address is provided, turn it into CIDR form. + + ipRange = strings.TrimSpace(ipRange) + + if strings.Index(ipRange, "/") == -1 { + ipRange = ipRange + "/32" + } + + _, cidr, err := net.ParseCIDR(ipRange) + + if err != nil { + return false, err + } + + return cidr.Contains(parsedIP), nil +} + // 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 @@ -479,16 +542,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{}, body *[]byte) (bool, error) { +func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { switch { case r.And != nil: - return r.And.Evaluate(headers, query, payload, body) + return r.And.Evaluate(headers, query, payload, body, remoteAddr) case r.Or != nil: - return r.Or.Evaluate(headers, query, payload, body) + return r.Or.Evaluate(headers, query, payload, body, remoteAddr) case r.Not != nil: - return r.Not.Evaluate(headers, query, payload, body) + return r.Not.Evaluate(headers, query, payload, body, remoteAddr) case r.Match != nil: - return r.Match.Evaluate(headers, query, payload, body) + return r.Match.Evaluate(headers, query, payload, body, remoteAddr) } return false, nil @@ -498,11 +561,11 @@ func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[ 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{}, body *[]byte) (bool, error) { +func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { res := true for _, v := range r { - rv, err := v.Evaluate(headers, query, payload, body) + rv, err := v.Evaluate(headers, query, payload, body, remoteAddr) if err != nil { return false, err } @@ -520,11 +583,11 @@ func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body 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{}, body *[]byte) (bool, error) { +func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { res := false for _, v := range r { - rv, err := v.Evaluate(headers, query, payload, body) + rv, err := v.Evaluate(headers, query, payload, body, remoteAddr) if err != nil { return false, err } @@ -542,8 +605,8 @@ func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body * 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{}, body *[]byte) (bool, error) { - rv, err := Rules(r).Evaluate(headers, query, payload, body) +func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { + rv, err := Rules(r).Evaluate(headers, query, payload, body, remoteAddr) return !rv, err } @@ -554,17 +617,24 @@ type MatchRule struct { Secret string `json:"secret,omitempty"` Value string `json:"value,omitempty"` Parameter Argument `json:"parameter,omitempty"` + IPRange string `json:"ip-range,omitempty"` } // Constants for the MatchRule type const ( - MatchValue string = "value" - MatchRegex string = "regex" - MatchHashSHA1 string = "payload-hash-sha1" + MatchValue string = "value" + MatchRegex string = "regex" + MatchHashSHA1 string = "payload-hash-sha1" + MatchHashSHA256 string = "payload-hash-sha256" + IPWhitelist string = "ip-whitelist" ) // Evaluate MatchRule will return based on the type -func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) { +func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte, remoteAddr string) (bool, error) { + if r.Type == IPWhitelist { + return CheckIPWhitelist(remoteAddr, r.IPRange) + } + if arg, ok := r.Parameter.Get(headers, query, payload); ok { switch r.Type { case MatchValue: @@ -574,6 +644,9 @@ func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, bod case MatchHashSHA1: _, err := CheckPayloadSignature(*body, r.Secret, arg) return err == nil, err + case MatchHashSHA256: + _, err := CheckPayloadSignature256(*body, r.Secret, arg) + return err == nil, err } } return false, nil diff --git a/hook/hook_test.go b/hook/hook_test.go index 39622e9..7828e00 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -33,6 +33,32 @@ func TestCheckPayloadSignature(t *testing.T) { } } +var checkPayloadSignature256Tests = []struct { + payload []byte + secret string + signature string + mac string + ok bool +}{ + {[]byte(`{"a": "z"}`), "secret", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true}, + {[]byte(`{"a": "z"}`), "secret", "sha256=f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", true}, + // failures + {[]byte(`{"a": "z"}`), "secret", "XXX7af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89", false}, +} + +func TestCheckPayloadSignature256(t *testing.T) { + for _, tt := range checkPayloadSignature256Tests { + mac, err := CheckPayloadSignature256(tt.payload, tt.secret, tt.signature) + if (err == nil) != tt.ok || mac != tt.mac { + t.Errorf("failed to check payload signature {%q, %q, %q}:\nexpected {mac:%#v, ok:%#v},\ngot {mac:%#v, ok:%#v}", tt.payload, tt.secret, tt.signature, tt.mac, tt.ok, mac, (err == nil)) + } + + if err != nil && strings.Contains(err.Error(), tt.mac) { + t.Errorf("error message should not disclose expected mac: %s", err) + } + } +} + var extractParameterTests = []struct { s string params interface{} @@ -241,29 +267,45 @@ func TestHooksMatch(t *testing.T) { } var matchRuleTests = []struct { - typ, regex, secret, value string - param Argument - headers, query, payload *map[string]interface{} - body []byte - ok bool - err bool + typ, regex, secret, value, ipRange string + param Argument + headers, query, payload *map[string]interface{} + body []byte + remoteAddr string + ok bool + err bool }{ {"value", "", "", "z", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false}, {"regex", "^z", "", "z", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false}, {"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "b17e04cbb22afa8ffbff8796fc1894ed27badd9e"}, nil, nil, []byte(`{"a": "z"}`), true, false}, + {"payload-hash-sha256", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "f417af3a21bd70379b5796d5f013915e7029f62c580fb0f500f59a35a6f04c89"}, nil, nil, []byte(`{"a": "z"}`), true, false}, // failures - {"value", "", "", "X", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false}, - {"regex", "^X", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false}, - {"value", "", "2", "X", Argument{"header", "a", ""}, &map[string]interface{}{"Y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header + {"value", "", "", "X", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, false}, + {"regex", "^X", "", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, false}, + {"value", "", "2", "X", "", Argument{"header", "a", ""}, &map[string]interface{}{"Y": "z"}, nil, nil, []byte{}, "", false, false}, // reference invalid header // errors - {"regex", "*", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, true}, // invalid regex - {"payload-hash-sha1", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac + {"regex", "*", "", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, "", false, true}, // invalid regex + {"payload-hash-sha1", "", "secret", "", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": ""}, nil, nil, []byte{}, "", false, true}, // invalid hmac + {"payload-hash-sha256", "", "secret", "", Argument{"header", "a", ""}, &map[string]interface{}{"A": ""}, nil, nil, []byte{}, false, true}, // invalid hmac + // IP whitelisting, valid cases + {"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", true, false}, // valid IPv4, with range + {"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", true, false}, // valid IPv4, with range + {"ip-whitelist", "", "", "", "192.168.0.1", Argument{}, nil, nil, nil, []byte{}, "192.168.0.1:9000", true, false}, // valid IPv4, no range + {"ip-whitelist", "", "", "", "::1/24", Argument{}, nil, nil, nil, []byte{}, "[::1]:9000", true, false}, // valid IPv6, with range + {"ip-whitelist", "", "", "", "::1", Argument{}, nil, nil, nil, []byte{}, "[::1]:9000", true, false}, // valid IPv6, no range + // IP whitelisting, invalid cases + {"ip-whitelist", "", "", "", "192.168.0.1/a", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", false, true}, // invalid IPv4, with range + {"ip-whitelist", "", "", "", "192.168.0.a", Argument{}, nil, nil, nil, []byte{}, "192.168.0.2:9000", false, true}, // invalid IPv4, no range + {"ip-whitelist", "", "", "", "192.168.0.1/24", Argument{}, nil, nil, nil, []byte{}, "192.168.0.a:9000", false, true}, // invalid IPv4 address + {"ip-whitelist", "", "", "", "::1/a", Argument{}, nil, nil, nil, []byte{}, "[::1]:9000", false, true}, // invalid IPv6, with range + {"ip-whitelist", "", "", "", "::z", Argument{}, nil, nil, nil, []byte{}, "[::1]:9000", false, true}, // invalid IPv6, no range + {"ip-whitelist", "", "", "", "::1/24", Argument{}, nil, nil, nil, []byte{}, "[::z]:9000", false, true}, // invalid IPv6 address } func TestMatchRule(t *testing.T) { for i, tt := range matchRuleTests { - r := MatchRule{tt.typ, tt.regex, tt.secret, tt.value, tt.param} - ok, err := r.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) + 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.body, tt.remoteAddr) 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 != nil)) } @@ -281,8 +323,8 @@ var andRuleTests = []struct { { "(a=z, b=y): a=z && b=y", AndRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}}, }, &map[string]interface{}{"A": "z", "B": "y"}, nil, nil, []byte{}, true, false, @@ -290,8 +332,8 @@ var andRuleTests = []struct { { "(a=z, b=Y): a=z && b=y", AndRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}}, }, &map[string]interface{}{"A": "z", "B": "Y"}, nil, nil, []byte{}, false, false, @@ -300,22 +342,22 @@ var andRuleTests = []struct { { "(a=z, b=y, c=x, d=w=, e=X, f=X): a=z && (b=y && c=x) && (d=w || e=v) && !f=u", AndRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, { And: &AndRule{ - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, - {Match: &MatchRule{"value", "", "", "x", Argument{"header", "c", ""}}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}}, + {Match: &MatchRule{"value", "", "", "x", Argument{"header", "c", ""}, ""}}, }, }, { Or: &OrRule{ - {Match: &MatchRule{"value", "", "", "w", Argument{"header", "d", ""}}}, - {Match: &MatchRule{"value", "", "", "v", Argument{"header", "e", ""}}}, + {Match: &MatchRule{"value", "", "", "w", Argument{"header", "d", ""}, ""}}, + {Match: &MatchRule{"value", "", "", "v", Argument{"header", "e", ""}, ""}}, }, }, { Not: &NotRule{ - Match: &MatchRule{"value", "", "", "u", Argument{"header", "f", ""}}, + Match: &MatchRule{"value", "", "", "u", Argument{"header", "f", ""}, ""}, }, }, }, @@ -326,7 +368,7 @@ var andRuleTests = []struct { // failures { "invalid rule", - AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}}}}, + AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}, ""}}}, &map[string]interface{}{"Y": "z"}, nil, nil, nil, false, false, }, @@ -334,7 +376,7 @@ var andRuleTests = []struct { func TestAndRule(t *testing.T) { for _, tt := range andRuleTests { - ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) + ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body, "") 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) } @@ -352,8 +394,8 @@ var orRuleTests = []struct { { "(a=z, b=X): a=z || b=y", OrRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}}, }, &map[string]interface{}{"A": "z", "B": "X"}, nil, nil, []byte{}, true, false, @@ -361,8 +403,8 @@ var orRuleTests = []struct { { "(a=X, b=y): a=z || b=y", OrRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}}, }, &map[string]interface{}{"A": "X", "B": "y"}, nil, nil, []byte{}, true, false, @@ -370,8 +412,8 @@ var orRuleTests = []struct { { "(a=Z, b=Y): a=z || b=y", OrRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, - {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, + {Match: &MatchRule{"value", "", "", "y", Argument{"header", "b", ""}, ""}}, }, &map[string]interface{}{"A": "Z", "B": "Y"}, nil, nil, []byte{}, false, false, @@ -380,7 +422,7 @@ var orRuleTests = []struct { { "invalid rule", OrRule{ - {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, + {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, }, &map[string]interface{}{"Y": "Z"}, nil, nil, []byte{}, false, false, @@ -389,7 +431,7 @@ var orRuleTests = []struct { func TestOrRule(t *testing.T) { for _, tt := range orRuleTests { - ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body) + ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body, "") 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) } @@ -404,13 +446,13 @@ var notRuleTests = []struct { ok bool err bool }{ - {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false}, - {"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, false, false}, + {"(a=z): !a=X", NotRule{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a", ""}, ""}}, &map[string]interface{}{"A": "z"}, nil, nil, []byte{}, true, false}, + {"(a=z): !a=z", NotRule{Match: &MatchRule{"value", "", "", "z", Argument{"header", "a", ""}, ""}}, &map[string]interface{}{"A": "z"}, 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.body) + ok, err := tt.rule.Evaluate(tt.headers, tt.query, tt.payload, &tt.body, "") 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/webhook.go b/webhook.go index 8d79633..e07b04d 100644 --- a/webhook.go +++ b/webhook.go @@ -21,7 +21,7 @@ import ( ) const ( - version = "2.6.2" + version = "2.6.3" ) var ( @@ -243,7 +243,7 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { if matchedHook.TriggerRule == nil { ok = true } else { - ok, err = matchedHook.TriggerRule.Evaluate(&headers, &query, &payload, &body) + ok, err = matchedHook.TriggerRule.Evaluate(&headers, &query, &payload, &body, r.RemoteAddr) if err != nil { msg := fmt.Sprintf("error evaluating hook: %s", err) log.Printf(msg)