Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IP Whitelists for Frontend (with Docker- & Kubernetes-Provider Support) #1332

Merged
merged 2 commits into from
May 19, 2017

Conversation

MaZderMind
Copy link
Contributor

@MaZderMind MaZderMind commented Mar 22, 2017

This PR fixes #816. It adds the ability to specify a List of CIDR Whitelists per Frontend. If Whitelists are configured and the Request-IP does not match one of them, it is aborted with a 403 Forbidden.

This obviously requires traefik to be directly mapped on public ips, if the incoming requests are nat'ed, ie through dockers publish-functionality or docker-compose ports, traefik will only see an ip of the internal network and won't be able to filter according to the source-ip.

This PR adds support for the Docker-Provider and the Kubernetes-Provider, but it should be trivial to extend the support to other Providers, I just have no test-setup for those. For both it parses the Annoatation/Label "traefik.frontend.whitelistSourceRange", for Kubernetes it also accepts the quasi-standard "ingress.kubernetes.io/whitelist-source-range". They can take a Comma-Separated List of CIDR Nets (IPv4 & v6), like this:

machine:
  image: katacoda/docker-http-server
  labels:
    - "traefik.backend=local-only-echo"
    - "traefik.frontend.whitelistSourceRange="10.42.0.0/16, 152.89.1.33/32, afed:be44::/16"
    - "traefik.frontend.rule=Host:local-only-echo.example.com"

or for Kubernetes:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: hello-minikube
  annotations:
    traefik.frontend.whitelistSourceRange: "10.42.0.0/16, 152.89.1.33/32, afed:be44::/16"
spec:
  rules:
  - host: echo.kube
    http:
      paths:
      - path: /
        backend:
          serviceName: hello-minikube
          servicePort: web

For File-Based Config, there's the whitelistSourceRange property, which takes an array of CIDR Net-Strings:

[frontends]
  [frontends.frontend2]
  backend = "backend1"
  passHostHeader = true
  priority = 10
  whitelistSourceRange = ["10.42.0.0/16", "152.89.1.33/32", "afed:be44::/16"]

The applied Whitelists are Displayed on the Dashboard:
whitelists-dashboard

For my own personal use and for easy testing, I pushed a Docker-Image based on this PR:
https://hub.docker.com/r/mazdermind/traefik/tags/

I'd be happy to receive some feedback, because Go is still fairly new.
Best Regards,
Peter

@MaZderMind MaZderMind changed the title Ingress ip whitelist IP Whitelists for Frontend (with Docker- & Kuberentes-Provider Support) Mar 22, 2017
@timoreimann
Copy link
Contributor

@MaZderMind thanks for your contribution!

I'll try to review the code. In the meantime, could you please run make fmt and commit your changes in order to please the style gods (and our CI)?

@MaZderMind
Copy link
Contributor Author

@timoreimann Thank you, I'm already at it. Sorry for not checking this before pushing, I'm quite new to the Go Ecosystem

@timoreimann
Copy link
Contributor

@MaZderMind no worries. The CI is our friend. :-)

func NewIPWhitelister(whitelistStrings []string) (*IPWhitelister, error) {
whitelister := IPWhitelister{}

if len(whitelistStrings) < 1 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's simplify and compare against a length of zero.

}

whitelister.whitelists = whitelists
whitelister.handler = negroni.HandlerFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest we use a dedicated function for the handler func as opposed to defining it inline.

If you define it on the IPWhitelister struct, you gain access to the configured whitelists.

whitelister := IPWhitelister{}

if len(whitelistStrings) < 1 {
return nil, fmt.Errorf("Error creating IpWhitelister: no whitelists provided")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not capitalize error strings.

Also, no need to mention that this is an error. (After all, we'll be returning an error.) Just say no whitelists provided. It's only at the call site where we should have something like

if err != nil {
  log.Warnf("failed to initialize white lists: %s", err)
}

Similar for other errors.

for _, whitelistString := range whitelistStrings {
_, whitelist, err := net.ParseCIDR(whitelistString)
if err != nil {
return nil, fmt.Errorf("Error PArsing CIDR Whitelist %s: %s", whitelist, err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One extra capital A.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please decapitalize Whitelist.

whitelists = append(whitelists, whitelist)
}

whitelister.whitelists = whitelists
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can append directly into the struct field in line 31 to avoid this assignment.

Paths: []v1beta1.HTTPIngressPath{
{
Path: "/ip-source-range",
Backend: v1beta1.IngressBackend{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/ip/whitelist/ for naming consistency.

server.go Outdated
@@ -740,6 +740,17 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo
negroni.Use(metricsMiddlewareBackend)
}
}

if len(frontend.WhitelistSourceRange) > 0 {
log.Info("Configured IP Whitelists: ", frontend.WhitelistSourceRange)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd place this log messages only after we are clear there won't be an error returned by NewIPWhitelister.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use log.Infof and apply the correct verb (%s I'd think).

server.go Outdated
ipSourceRanges := frontend.WhitelistSourceRange
ipWhitelistMiddleware, err := middlewares.NewIPWhitelister(ipSourceRanges)
if err != nil {
log.Fatal("Error creating IP Whitelister: ", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use log.Fatalf.

server.go Outdated
}
negroni.Use(ipWhitelistMiddleware)
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part needs to be covered by a test. I know it's tricky because we have zero tests yet, but we need to start somewhere.

For a starter, here are my tests from a different PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Server-Construction-Code is not really nice to be tested, is it? ;)
I extracted the configuration of the middleware into a smaller, easy to test function and tested that. I think this is the better approach instead of configuring a complete Server and trying to fiddle out which middlewares are configured.

log.Debugf("Source-IP %s matched none of the %d whitelists, rejecting", remoteIP, len(whitelists))
reject(w)
}
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The middleware needs testing. Once you have moved the inline function into a dedicated one, you can test it easily against an http/httptest server.

@Russell-IO
Copy link
Contributor

This concept is purely awesome

@MaZderMind
Copy link
Contributor Author

@timoreimann Thank you for your in-depth review. It'll take me some time to get through it, though.

@errm
Copy link
Contributor

errm commented Mar 25, 2017

Could we add support for X-Forwarded-For here?

It would make sense for environments were there is some other load balancer in front of Treafik

@MaZderMind
Copy link
Contributor Author

MaZderMind commented Mar 26, 2017 via email

@timoreimann
Copy link
Contributor

I'm with @MaZderMind: Support for the header would be great, but this PR can stand on its own without it.

@timoreimann
Copy link
Contributor

hey @MaZderMind, did you have an opportunity yet to address the comments?

I believe the feature you've built is being asked for a few times per week. Would be awesome to have it. :-)

@MaZderMind
Copy link
Contributor Author

MaZderMind commented Apr 6, 2017 via email

@MaZderMind
Copy link
Contributor Author

@timoreimann I addressed all your change requests (despite the one on removing the empty string-slices on which I commented above) and merged the current master into this PR.

@MaZderMind
Copy link
Contributor Author

It seems, the build failed with "The job exceeded the maximum time limit for jobs, and has been terminated.". I'll try to retrigger.

Copy link
Contributor

@timoreimann timoreimann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second round -- things are already looking pretty good. 👍

I need to apologize for a wrong advise I gave in my previous review: While error messages should not start with a capital letter, log messages indeed should (source).

I'm okay with keeping log messages non-capitalized though as our styling is fairly inconsistent anyway.

Sorry about that! 😞

"errors"
"fmt"
"github.com/codegangsta/negroni"
"github.com/stretchr/testify/assert"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please group the non-stdlib imports below the stdlib ones, like here.

)

func TestNewIPWhitelister(t *testing.T) {
cases := map[string]struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A map of strings to structs does not seem very idiomatic. Could you please use a simplified slice of structs and have one (the first) field be your test description?

Here's an example.

Same for other tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my mind the tests are grouped by name, just as you would do it when writing them down on paper. You would not have a Headline "1" with the title as Content under it. But I'm ok following your style guide and will change this :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to add a bit more reasoning on my end: I agree with you that a map could make sense if every test had a name. However, the parameter you pass to t.Run() does not have to be static (as in our case) but could also be the result of some "dynamic computation". For instance, the tests for a math utility function could use the input parameters to define the description.

Looking at it from this angle, employing maps where a static name is needed and a slice of structs where it's not would lead to an inconsistent picture. I prefer tests to look boringly similar. :-)

}

for index, e := range cases {
t.Run(index, func(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to say e := e here to capture the range variable. See the Run a group of tests in parallel in the official sub-tests blog post and this Go wiki post (note that t.Parallel() creates goroutines under the hood).

Same for other tests.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way, what is e supposed to stand for? I immediately thought of error.

Maybe call it case or tc (for test case) instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied that from the docker tests, but it is there as wrong as it is here. fixed

t.Run(index, func(t *testing.T) {
t.Parallel()
whitelister, err := NewIPWhitelister(e.whitelistStrings)
if !reflect.DeepEqual(err, e.err) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error values should be comparable using ==.

t.Parallel()
whitelister, err := NewIPWhitelister(e.whitelistStrings)
if !reflect.DeepEqual(err, e.err) {
t.Errorf("expected err: %+v, got: %+v", e.err, err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One advantage of using sub-tests is that you can use t.Fatal*, and it will only bail out of the current test. So no need to to t.Error* + return.

for _, whitelistString := range whitelistStrings {
_, whitelist, err := net.ParseCIDR(whitelistString)
if err != nil {
return nil, fmt.Errorf("parsing CIDR whitelist %s: %+v", whitelist, err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error messages should just be formatted using %v or %s (no plus sign) as they are meant for humans.

Backend: "backend-test",
PassHostHeader: true,
EntryPoints: []string{},
WhitelistSourceRange: []string{},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still remove the empty slice from the test case definition and sanitize inside the test body; that is, check if the value is nil and assign the empty slice to it. That keeps the test cases clean and moves the sanitization logic into a central location.

As far as being able to easily spot what's wrong in case of a test failure, you should use something like go-spew's Sdump. We already use it at other occasions, so you should be able to use it in your tests too.

server_test.go Outdated
)

func TestNewServerWithoutWhitelistSourceRange(t *testing.T) {
cases := map[string]struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convert to struct slices here too please.

docs/toml.md Outdated
@@ -455,6 +455,7 @@ defaultEntryPoints = ["http", "https"]
backend = "backend1"
passHostHeader = true
priority = 10
whitelistSourceRange = ["10.42.0.0/16", "152.89.1.33/32", "afed:be44::/16"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose a bit of extra prose might be nice that explains things like

  • what does this feature do;
  • how does it behave (including error cases); and
  • what providers are supported.

}

func (whitelister *IPWhitelister) handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
var match *net.IPNet
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused (except for printing)?

@MaZderMind
Copy link
Contributor Author

@timoreimann I think i changed all the things requested. The build failed with a timeout bit I'm sure retriggering it would make it succeed.

desc string
whitelistStrings []string
expectedWhitelists []*net.IPNet
err string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this to errMessage? err is usually reserved for variables of type error.

server_test.go Outdated
t.Parallel()
middleware, err := configureIPWhitelistMiddleware(tc.whitelistStrings)

if !reflect.DeepEqual(err, tc.err) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we use assert.Equal() here?


assert.NoError(t, err, "there should be no error")
if !assert.NotNil(t, whitelister, "this should not be nil") {
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and this also seem like they would benefit from require.

Copy link
Contributor

@timoreimann timoreimann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apart from the assert/require question, I have only two minor points left.

I think we're getting close to wrapping up. :-)


import "strings"

func splitAndTrimString(separatedString string) []string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realize we don't have any dedicated tests for this function. Could you please add some? That way, we guarantee the function does what it should independent of any of its current users.

@timoreimann
Copy link
Contributor

Yes, vendor management is awful. :-(

All hopes lie on golang/dep...

@MaZderMind
Copy link
Contributor Author

@timoreimann any news on this? I might do another rebase and merge, but it's not a task I'm very keen on, not knowing if it will be merged then.

@timoreimann
Copy link
Contributor

timoreimann commented May 10, 2017

@MaZderMind we need another review for this PR for process reasons, and due to its mere size and impact. I think it's fine to refrain from rebasing until things have been finalized.

Thanks!

Copy link
Member

@emilevauge emilevauge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome job @MaZderMind ❤️
Few comments, but overall, looks great :)

func (whitelister *IPWhitelister) handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
remoteIP, err := ipFromRemoteAddr(r.RemoteAddr)
if err != nil {
log.Warnf("unable to parse remote-address from header: %s - rejecting", r.RemoteAddr)
Copy link
Member

@emilevauge emilevauge May 10, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion this is a more dangerous problem than the others, because a single wrong syntax IP will stop the complete filter from being applied, which possibly means your private frontend is now exposed to the internet.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it 👍


func reject(w http.ResponseWriter) {
statusCode := http.StatusForbidden
statusText := "request from prohibited source"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just asking, is it a safe practice to give a reason on this kind of error? I wonder if an empty statusTextwould't be better 🤔

Copy link
Contributor Author

@MaZderMind MaZderMind May 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, security by obscurity is never good practice. Our service should be save, even if an attacker knows how we secured it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But is there a need to print this text?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, not really a need. Other Webservers just repeat the HTTP Status-Code, that would probably be a string like "Forbidden". Generally I think being more verbose is better in case of debugging a problem, but If you want me to change that, I'll do so.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MaZderMind IMHO, we should not use HTTP error messages for debugging: we already have logs for this :) As we don't set any custom error message in Traefik for now, I would prefer to just remove it. @timoreimann WDYT?

Copy link
Contributor

@timoreimann timoreimann May 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also inclined to say there's no need to give away too much information, so either leave it empty or stick with "Forbidden" -- whatever is more consistent with Traefik's current behavior.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then, we remove the text :) We will be able to customize the error page in the future.

server/server.go Outdated
@@ -774,6 +782,21 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo
return serverEntryPoints, nil
}

func configureIPWhitelistMiddleware(whitelistSourceRangs []string) (negroni.Handler, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/whitelistSourceRangs/whitelistSourceRanges

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ouch, will fix that

@ldez
Copy link
Contributor

ldez commented May 18, 2017

Due to SemaphoreCI, I close and reopen the PR.

@ldez ldez closed this May 18, 2017
@ldez ldez reopened this May 18, 2017
Copy link
Member

@emilevauge emilevauge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot @MaZderMind !
LGTM
(@ldez will change the status text)

@MaZderMind
Copy link
Contributor Author

@emilevauge Sorry for not being responsive with this and previous changes. It's only one small side project for me. Thanks to @ldez for jumping in!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

IP whitelist when running as Kubernetes Ingress controller
7 participants