Skip to content

Commit

Permalink
Merge pull request #442 from myENA-feature/route-acl
Browse files Browse the repository at this point in the history
Fixes #439
  • Loading branch information
magiconair committed Feb 18, 2018
2 parents 224ab70 + 7c111ce commit 4ab67c2
Show file tree
Hide file tree
Showing 11 changed files with 515 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:fe80::/10` | Restrict access to source addresses within the `10.0.0.0/8` or `fe80::/10` CIDR mask. All other requests will be denied.
`deny=ip:10.0.0.0/8,ip:fe80::1234` | Deny requests that source from the `10.0.0.0/8` CIDR mask or `fe80::1234`. 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
54 changes: 54 additions & 0 deletions docs/content/feature/access-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
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 `fe80::/10` subnet you would add the following option:

```
allow=ip:192.168.1.0/24,ip:fe80::/10
```

With this specified only clients sourced from those two subnets will
be allowed. All other requests to that route will be denied.


Inversely, to deny a specific set of clients you can use the
following option syntax:

```
deny=ip:fe80::1234,100.123.0.0/16
```

With this configuration access will be denied to any clients with
the `fe80::1234` address or coming from the `100.123.0.0/16` network.

Single host addresses (addresses without a prefix) will have a
`/32` prefix, for IPv4, or a `/128` prefix, for IPv6, added automatically.
That means `1.2.3.4` is equivalent to `1.2.3.4/32` and `fe80::1234`
is equivalent to `fe80::1234/128` when specifying
address blocks for `allow` or `deny` rules.

The source ip used for validation against the defined ruleset is
taken from information available in the request.

For `HTTP` requests the client `RemoteAddr` is always validated
followed by the first element of the `X-Forwarded-For` header, if
present. When either of these elements match an `allow` the request
will be allowed; similarly when either element matches a `deny` the
request will be denied.

For `TCP` requests the source address of the network socket
is used as the sole paramater for validation.

If the inbound connection uses the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt)
to transmit the true source address of the client then it will
be used for both `HTTP` and `TCP` connections for validating access.

30 changes: 30 additions & 0 deletions proxy/http_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,36 @@ func TestProxySTSHeader(t *testing.T) {
}
}

func TestProxyChecksHeaderForAccessRules(t *testing.T) {
var hdr http.Header = make(http.Header)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hdr = r.Header
}))
defer server.Close()

proxy := httptest.NewServer(&HTTPProxy{
Config: config.Proxy{},
Transport: http.DefaultTransport,
Lookup: func(r *http.Request) *route.Target {
tgt := &route.Target{
URL: mustParse(server.URL),
Opts: map[string]string{"allow": "ip:127.0.0.0/8,ip:fe80::/10,ip:::1"},
}
tgt.ProcessAccessRules()
return tgt
},
})
defer proxy.Close()

req, _ := http.NewRequest("GET", proxy.URL, nil)
req.Header.Set("X-Forwarded-For", "1.2.3.4")
resp, _ := mustDo(req)

if got, want := resp.StatusCode, http.StatusForbidden; got != want {
t.Errorf("got %v want %v", got, want)
}
}

func TestProxyNoRouteHTML(t *testing.T) {
want := "<html>503</html>"
noroute.SetHTML(want)
Expand Down
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
4 changes: 4 additions & 0 deletions proxy/tcp/sni_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ func (p *SNIProxy) ServeTCP(in net.Conn) error {
}
addr := t.URL.Host

if t.AccessDeniedTCP(in) {
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
4 changes: 4 additions & 0 deletions proxy/tcp/tcp_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ func (p *Proxy) ServeTCP(in net.Conn) error {
}
addr := t.URL.Host

if t.AccessDeniedTCP(in) {
return nil
}

out, err := net.DialTimeout("tcp", addr, p.DialTimeout)
if err != nil {
log.Print("[WARN] tcp: cannot connect to upstream ", addr)
Expand Down
174 changes: 174 additions & 0 deletions route/access_rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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 {
var ip net.IP
host, _, err := net.SplitHostPort(r.RemoteAddr)

if err != nil {
log.Printf("[ERROR] failed to get host from remote header %s: %s",
r.RemoteAddr, err.Error())
return false
}

if ip = net.ParseIP(host); ip == nil {
log.Printf("[WARN] failed to parse remote address %s", host)
}

// check remote source and return if denied
if t.denyByIP(ip) {
return true
}

// check xff source if present
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// only use left-most element (client)
xff = strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])
// only continue if xff differs from host
if xff != host {
if ip = net.ParseIP(xff); ip == nil {
log.Printf("[WARN] failed to parse xff address %s", xff)
}
// check xff source and return if denied
if t.denyByIP(ip) {
return true
}
}
}

// default allow
return false
}

// AccessDeniedTCP checks rules on the target for TCP proxy routes.
func (t *Target) AccessDeniedTCP(c net.Conn) bool {
ip := net.ParseIP(c.RemoteAddr().String())
if t.denyByIP(ip) {
return true
}
// default allow
return false
}

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
log.Printf("[INFO] route rules denied access from %s to %s",
ip.String(), t.URL.String())
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
log.Printf("[INFO] route rules denied access from %s to %s",
ip.String(), t.URL.String())
return true
}
}
}

// default - do not deny
return false
}

// ProcessAccessRules processes access rules from options specified on the target route
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
}

func (t *Target) parseAccessRule(allowDeny string) error {
var accessTag string
var temps []string
var value string
var ip net.IP

// 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)
}

// form access type tag
accessTag = allowDeny + ":" + strings.ToLower(strings.TrimSpace(temps[0]))

// switch on formed access tag - currently only ip types are implemented
switch accessTag {
case ipAllowTag, ipDenyTag:
if value = strings.TrimSpace(temps[1]); !strings.Contains(value, "/") {
if ip = net.ParseIP(value); ip == nil {
return fmt.Errorf("failed to parse IP %s", value)
}
if ip.To4() != nil {
value = ip.String() + "/32"
} else {
value = ip.String() + "/128"
}
}
_, net, err := net.ParseCIDR(value)
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
}
Loading

0 comments on commit 4ab67c2

Please sign in to comment.