From 173273b466a064ab345a7204881f874ce4032f4c Mon Sep 17 00:00:00 2001 From: HandcraftedBits Date: Mon, 20 Feb 2017 22:00:39 -0500 Subject: [PATCH] Add IP whitelist match rule. --- hook/hook.go | 73 ++++++++++++++++++++++++++++++------- hook/hook_test.go | 92 +++++++++++++++++++++++++++-------------------- webhook.go | 2 +- 3 files changed, 115 insertions(+), 52 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index 02859c6f..b653537f 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io/ioutil" + "net" "net/textproto" "reflect" "regexp" @@ -100,6 +101,48 @@ func CheckPayloadSignature(payload []byte, secret string, signature string) (str 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 +522,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 +541,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 +563,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 +585,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,6 +597,7 @@ 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 @@ -561,10 +605,15 @@ const ( MatchValue string = "value" MatchRegex string = "regex" MatchHashSHA1 string = "payload-hash-sha1" + 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: diff --git a/hook/hook_test.go b/hook/hook_test.go index 39622e9a..a274196c 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -241,29 +241,43 @@ 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}, + {"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}, // 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 + // 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 +295,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 +304,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 +314,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 +340,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 +348,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 +366,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 +375,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 +384,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 +394,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 +403,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 +418,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 8d796331..69147342 100644 --- a/webhook.go +++ b/webhook.go @@ -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)