From 79d7e03c8fa93862c108d6b60271ad592d98ccf2 Mon Sep 17 00:00:00 2001 From: Aaron Hurt Date: Mon, 12 Feb 2018 19:07:39 -0600 Subject: [PATCH] add basic ip centric access control on routes --- docs/content/cfg/_index.md | 18 ++-- docs/content/feature/_index.md | 1 + docs/content/feature/access-control.md | 31 ++++++ proxy/http_proxy.go | 5 + proxy/tcp/sni_proxy.go | 5 + proxy/tcp/tcp_proxy.go | 5 + route/access_rules.go | 128 +++++++++++++++++++++++++ route/access_rules_test.go | 107 +++++++++++++++++++++ route/route.go | 6 ++ route/target.go | 3 + 10 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 docs/content/feature/access-control.md create mode 100644 route/access_rules.go create mode 100644 route/access_rules_test.go diff --git a/docs/content/cfg/_index.md b/docs/content/cfg/_index.md index cbcd7c416..6bcfa9c53 100644 --- a/docs/content/cfg/_index.md +++ b/docs/content/cfg/_index.md @@ -24,14 +24,16 @@ Add a route for a service `svc` for the `src` (e.g. `/path` or `:port`) to a `ds `route add [ weight ][ tags ",,..."][ opts "k1=v1 k2=v2 ..."]` -Option | Description --------------------- | ----------- -`strip=/path` | Forward `/path/to/file` as `/to/file` -`proto=tcp` | Upstream service is TCP, `dst` must be `:port` -`proto=https` | Upstream service is HTTPS -`tlsskipverify=true` | Disable TLS cert validation for HTTPS upstream -`host=name` | Set the `Host` header to `name`. If `name == 'dst'` then the `Host` header will be set to the registered upstream host name -`register=name` | Register fabio as new service `name`. Useful for registering hostnames for host specific routes. +Option | Description +------------------------------------------ | ----------- +`allow=ip:10.0.0.0/8,ip:192.168.0.0/24` | Restrict access to source addresses within the `10.0.0.0/8` or `192.168.0.0/24` CIDR mask. All other requests will be denied. +`deny=ip:10.0.0.0/8,ip:1.2.3.4/32` | Deny requests that source from the `10.0.0.0/8` CIDR mask or `1.2.3.4`. All other requests will be allowed. +`strip=/path` | Forward `/path/to/file` as `/to/file` +`proto=tcp` | Upstream service is TCP, `dst` must be `:port` +`proto=https` | Upstream service is HTTPS +`tlsskipverify=true` | Disable TLS cert validation for HTTPS upstream +`host=name` | Set the `Host` header to `name`. If `name == 'dst'` then the `Host` header will be set to the registered upstream host name +`register=name` | Register fabio as new service `name`. Useful for registering hostnames for host specific routes. ##### Example diff --git a/docs/content/feature/_index.md b/docs/content/feature/_index.md index 588074fa6..0a6a28984 100644 --- a/docs/content/feature/_index.md +++ b/docs/content/feature/_index.md @@ -6,6 +6,7 @@ weight: 200 The following list provides a list of features supported by fabio. * [Access Logging](/feature/access-logging/) - customizable access logs + * [Access Control](/feature/access-control/) - route specific access control * [Certificate Stores](/feature/certificate-stores/) - dynamic certificate stores like file system, HTTP server, [Consul](https://consul.io/) and [Vault](https://vaultproject.io/) * [Compression](/feature/compression/) - GZIP compression for HTTP responses * [Docker Support](/feature/docker/) - Official Docker image, Registrator and Docker Compose example diff --git a/docs/content/feature/access-control.md b/docs/content/feature/access-control.md new file mode 100644 index 000000000..5369af3df --- /dev/null +++ b/docs/content/feature/access-control.md @@ -0,0 +1,31 @@ +--- +title: "Access Control" +since: "1.5.8" +--- + +fabio supports basic ip centric access control per route. You may +specify one of `allow` or `deny` options per route to control access. +Currently only source ip control is available. + + + +To allow access to a route from clients within the `192.168.1.0/24` +and `10.0.0.0/8` subnet you would add the following option: + +``` +allow=ip:192.168.1.0/24,ip:10.0.0.0/8 +``` + +With this specified only clients sourced from those two subnets will +be allowed. All other requests to that route will be denied. + + +Inversely, to only deny a specific set of clients you can use the +following option syntax: + +``` +deny=ip:1.2.3.4/32,100.123.0.0/16 +``` + +With this configuration access will be denied to any clients with +the `1.2.3.4` address or coming from the `100.123.0.0/16` network. diff --git a/proxy/http_proxy.go b/proxy/http_proxy.go index ba505fc97..0898d7afe 100644 --- a/proxy/http_proxy.go +++ b/proxy/http_proxy.go @@ -84,6 +84,11 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if t.AccessDeniedHTTP(r) { + http.Error(w, "access denied", http.StatusForbidden) + return + } + // build the request url since r.URL will get modified // by the reverse proxy and contains only the RequestURI anyway requestURL := &url.URL{ diff --git a/proxy/tcp/sni_proxy.go b/proxy/tcp/sni_proxy.go index b75d06705..b1361253b 100644 --- a/proxy/tcp/sni_proxy.go +++ b/proxy/tcp/sni_proxy.go @@ -78,6 +78,11 @@ func (p *SNIProxy) ServeTCP(in net.Conn) error { } addr := t.URL.Host + if t.AccessDeniedTCP(in) { + log.Print("[INFO] route rules denied access to ", t.URL.String()) + return nil + } + out, err := net.DialTimeout("tcp", addr, p.DialTimeout) if err != nil { log.Print("[WARN] tcp+sni: cannot connect to upstream ", addr) diff --git a/proxy/tcp/tcp_proxy.go b/proxy/tcp/tcp_proxy.go index 5b14cc950..0551fa6f0 100644 --- a/proxy/tcp/tcp_proxy.go +++ b/proxy/tcp/tcp_proxy.go @@ -48,6 +48,11 @@ func (p *Proxy) ServeTCP(in net.Conn) error { } addr := t.URL.Host + if t.AccessDeniedTCP(in) { + log.Print("[INFO] route rules denied access to ", t.URL.String()) + return nil + } + out, err := net.DialTimeout("tcp", addr, p.DialTimeout) if err != nil { log.Print("[WARN] tcp: cannot connect to upstream ", addr) diff --git a/route/access_rules.go b/route/access_rules.go new file mode 100644 index 000000000..f1449584a --- /dev/null +++ b/route/access_rules.go @@ -0,0 +1,128 @@ +package route + +import ( + "errors" + "fmt" + "log" + "net" + "net/http" + "strings" +) + +const ( + ipAllowTag = "allow:ip" + ipDenyTag = "deny:ip" +) + +// AccessDeniedHTTP checks rules on the target for HTTP proxy routes. +func (t *Target) AccessDeniedHTTP(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + + if err != nil { + log.Printf("[ERROR] Failed to get host from RemoteAddr %s: %s", + r.RemoteAddr, err.Error()) + return false + } + + // prefer xff header if set + if xff := r.Header.Get("X-Forwarded-For"); xff != "" && xff != host { + host = xff + } + + // currently only one function - more may be added in the future + return t.denyByIP(net.ParseIP(host)) +} + +// AccessDeniedTCP checks rules on the target for TCP proxy routes. +func (t *Target) AccessDeniedTCP(c net.Conn) bool { + // currently only one function - more may be added in the future + return t.denyByIP(net.ParseIP(c.RemoteAddr().String())) +} + +func (t *Target) denyByIP(ip net.IP) bool { + if ip == nil || t.accessRules == nil { + return false + } + + // check allow (whitelist) first if it exists + if _, ok := t.accessRules[ipAllowTag]; ok { + var block *net.IPNet + for _, x := range t.accessRules[ipAllowTag] { + if block, ok = x.(*net.IPNet); !ok { + log.Print("[ERROR] failed to assert ip block while checking allow rule for ", t.Service) + continue + } + if block.Contains(ip) { + // specific allow matched - allow this request + return false + } + } + // we checked all the blocks - deny this request + return true + } + + // still going - check deny (blacklist) if it exists + if _, ok := t.accessRules[ipDenyTag]; ok { + var block *net.IPNet + for _, x := range t.accessRules[ipDenyTag] { + if block, ok = x.(*net.IPNet); !ok { + log.Print("[INFO] failed to assert ip block while checking deny rule for ", t.Service) + continue + } + if block.Contains(ip) { + // specific deny matched - deny this request + return true + } + } + } + + // default - do not deny + return false +} + +func (t *Target) parseAccessRule(allowDeny string) error { + var accessTag string + var temps []string + + // init rules if needed + if t.accessRules == nil { + t.accessRules = make(map[string][]interface{}) + } + + // loop over rule elements + for _, c := range strings.Split(t.Opts[allowDeny], ",") { + if temps = strings.SplitN(c, ":", 2); len(temps) != 2 { + return fmt.Errorf("invalid access item, expected :, got %s", temps) + } + accessTag = allowDeny + ":" + strings.ToLower(temps[0]) + switch accessTag { + case ipAllowTag, ipDenyTag: + _, net, err := net.ParseCIDR(strings.TrimSpace(temps[1])) + if err != nil { + return fmt.Errorf("failed to parse CIDR %s with error: %s", + c, err.Error()) + } + // add element to rule map + t.accessRules[accessTag] = append(t.accessRules[accessTag], net) + default: + return fmt.Errorf("unknown access item type: %s", temps[0]) + } + } + return nil + +} + +func (t *Target) processAccessRules() error { + if t.Opts["allow"] != "" && t.Opts["deny"] != "" { + return errors.New("specifying allow and deny on the same route is not supported") + } + + for _, allowDeny := range []string{"allow", "deny"} { + if t.Opts[allowDeny] != "" { + if err := t.parseAccessRule(allowDeny); err != nil { + return err + } + } + } + return nil +} diff --git a/route/access_rules_test.go b/route/access_rules_test.go new file mode 100644 index 000000000..ced406edb --- /dev/null +++ b/route/access_rules_test.go @@ -0,0 +1,107 @@ +package route + +import ( + "net" + "testing" +) + +func TestAccessRules_parseAccessRule(t *testing.T) { + tests := []struct { + desc string + allowDeny string + fail bool + }{ + { + desc: "parseAccessRuleGood", + allowDeny: "ip:10.0.0.0/8,ip:192.168.0.0/24,ip:1.2.3.4/32", + }, + { + desc: "parseAccessRuleBadType", + allowDeny: "x:10.0.0.0/8", + fail: true, + }, + { + desc: "parseAccessRuleIncompleteIP", + allowDeny: "ip:10/8", + fail: true, + }, + { + desc: "parseAccessRuleBadCIDR", + allowDeny: "ip:10.0.0.0/255", + fail: true, + }, + } + + for i, tt := range tests { + tt := tt // capture loop var + + t.Run(tt.desc, func(t *testing.T) { + for _, ad := range []string{"allow", "deny"} { + tgt := &Target{Opts: map[string]string{ad: tt.allowDeny}} + err := tgt.parseAccessRule(ad) + if err != nil && !tt.fail { + t.Errorf("%d: %s\nfailed: %s", i, tt.desc, err.Error()) + return + } + } + }) + } +} + +func TestAccessRules_denyByIP(t *testing.T) { + tests := []struct { + desc string + target *Target + remote net.IP + denied bool + }{ + { + desc: "denyByIPAllowAllowed", + target: &Target{ + Opts: map[string]string{"allow": "ip:10.0.0.0/8,ip:192.168.0.0/24"}, + }, + remote: net.ParseIP("10.10.0.1"), + denied: false, + }, + { + desc: "denyByIPAllowDenied", + target: &Target{ + Opts: map[string]string{"allow": "ip:10.0.0.0/8,ip:192.168.0.0/24"}, + }, + remote: net.ParseIP("1.2.3.4"), + denied: true, + }, + { + desc: "denyByIPDenyDenied", + target: &Target{ + Opts: map[string]string{"deny": "ip:10.0.0.0/8,ip:192.168.0.0/24"}, + }, + remote: net.ParseIP("10.10.0.1"), + denied: true, + }, + { + desc: "denyByIPDenyAllow", + target: &Target{ + Opts: map[string]string{"deny": "ip:10.0.0.0/8,ip:192.168.0.0/24"}, + }, + remote: net.ParseIP("1.2.3.4"), + denied: false, + }, + } + + for i, tt := range tests { + tt := tt // capture loop var + + t.Run(tt.desc, func(t *testing.T) { + if err := tt.target.processAccessRules(); err != nil { + t.Errorf("%d: %s - failed to process access rules: %s", + i, tt.desc, err.Error()) + } + if deny := tt.target.denyByIP(tt.remote); deny != tt.denied { + t.Errorf("%d: %s\ngot denied: %t\nwant denied: %t\n", + i, tt.desc, deny, tt.denied) + return + } + }) + } +} diff --git a/route/route.go b/route/route.go index f0659c923..b3099cc90 100644 --- a/route/route.go +++ b/route/route.go @@ -65,6 +65,7 @@ func (r *Route) addTarget(service string, targetURL *url.URL, fixedWeight float6 Timer: ServiceRegistry.GetTimer(name), TimerName: name, } + if opts != nil { t.StripPath = opts["strip"] t.TLSSkipVerify = opts["tlsskipverify"] == "true" @@ -79,6 +80,11 @@ func (r *Route) addTarget(service string, targetURL *url.URL, fixedWeight float6 log.Printf("[ERROR] redirect status code should be in 3xx range. Got: %s", opts["redirect"]) } } + + if err = t.processAccessRules(); err != nil { + log.Printf("[ERROR] failed to process access rules: %s", + err.Error()) + } } r.Targets = append(r.Targets, t) diff --git a/route/target.go b/route/target.go index 6f5a70034..b5b63452a 100644 --- a/route/target.go +++ b/route/target.go @@ -50,6 +50,9 @@ type Target struct { // TimerName is the name of the timer in the metrics registry TimerName string + + // accessRules is map of access information for the target. + accessRules map[string][]interface{} } func (t *Target) GetRedirectURL(requestURL *url.URL) *url.URL {