Skip to content

Commit

Permalink
add basic ip centric access control on routes
Browse files Browse the repository at this point in the history
  • Loading branch information
aaronhurt authored and magiconair committed Feb 18, 2018
1 parent 224ab70 commit a28d244
Show file tree
Hide file tree
Showing 10 changed files with 300 additions and 8 deletions.
18 changes: 10 additions & 8 deletions docs/content/cfg/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ Add a route for a service `svc` for the `src` (e.g. `/path` or `:port`) to a `ds

`route add <svc> <src> <dst>[ weight <w>][ tags "<t1>,<t2>,..."][ 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

Expand Down
1 change: 1 addition & 0 deletions docs/content/feature/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/content/feature/access-control.md
Original file line number Diff line number Diff line change
@@ -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.

<!--more-->

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.
5 changes: 5 additions & 0 deletions proxy/http_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
5 changes: 5 additions & 0 deletions proxy/tcp/sni_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions proxy/tcp/tcp_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
127 changes: 127 additions & 0 deletions route/access_rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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 <type>:<data>, 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
}
107 changes: 107 additions & 0 deletions route/access_rules_test.go
Original file line number Diff line number Diff line change
@@ -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
}
})
}
}
6 changes: 6 additions & 0 deletions route/route.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions route/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit a28d244

Please sign in to comment.