From 679def0151af12f46c5d8eb3c86fcaeb602efda2 Mon Sep 17 00:00:00 2001 From: Tom Moulard Date: Mon, 7 Jun 2021 18:14:09 +0200 Subject: [PATCH] Add routing IP rule matcher Co-authored-by: Jean-Baptiste Doumenjou <925513+jbdoumenjou@users.noreply.github.com> Co-authored-by: Romain --- docs/content/routing/routers/index.md | 5 ++ pkg/ip/checker.go | 13 +-- pkg/rules/rules.go | 23 ++++++ pkg/rules/rules_test.go | 111 ++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 6 deletions(-) diff --git a/docs/content/routing/routers/index.md b/docs/content/routing/routers/index.md index 8f0b20f1f..c6e76a891 100644 --- a/docs/content/routing/routers/index.md +++ b/docs/content/routing/routers/index.md @@ -239,6 +239,7 @@ The table below lists all the available matchers: | ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. It accepts a sequence of literal and regular expression paths. | | ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. It accepts a sequence of literal and regular expression prefix paths. | | ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. | +| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Match if the request client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. | !!! important "Non-ASCII Domain Names" @@ -272,6 +273,10 @@ The table below lists all the available matchers: For instance, `PathPrefix: /products` would match `/products` but also `/products/shoes` and `/products/shirts`. Since the path is forwarded as-is, your service is expected to listen on `/products`. +!!! info "ClientIP matcher" + + The `ClientIP` matcher will only match the request client IP and does not use the `X-Forwarded-For` header for matching. + ### Priority To avoid path overlap, routes are sorted, by default, in descending order using rules length. The priority is directly equal to the length of the rule, and so the longest length has the highest priority. diff --git a/pkg/ip/checker.go b/pkg/ip/checker.go index c9baf84b1..c51df036c 100644 --- a/pkg/ip/checker.go +++ b/pkg/ip/checker.go @@ -24,13 +24,14 @@ func NewChecker(trustedIPs []string) (*Checker, error) { for _, ipMask := range trustedIPs { if ipAddr := net.ParseIP(ipMask); ipAddr != nil { checker.authorizedIPs = append(checker.authorizedIPs, &ipAddr) - } else { - _, ipAddr, err := net.ParseCIDR(ipMask) - if err != nil { - return nil, fmt.Errorf("parsing CIDR trusted IPs %s: %w", ipAddr, err) - } - checker.authorizedIPsNet = append(checker.authorizedIPsNet, ipAddr) + continue } + + _, ipAddr, err := net.ParseCIDR(ipMask) + if err != nil { + return nil, fmt.Errorf("parsing CIDR trusted IPs %s: %w", ipAddr, err) + } + checker.authorizedIPsNet = append(checker.authorizedIPsNet, ipAddr) } return checker, nil diff --git a/pkg/rules/rules.go b/pkg/rules/rules.go index daacb72ca..2eeb3ee3b 100644 --- a/pkg/rules/rules.go +++ b/pkg/rules/rules.go @@ -7,6 +7,7 @@ import ( "unicode/utf8" "github.com/gorilla/mux" + "github.com/traefik/traefik/v2/pkg/ip" "github.com/traefik/traefik/v2/pkg/log" "github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator" "github.com/vulcand/predicate" @@ -16,6 +17,7 @@ var funcs = map[string]func(*mux.Route, ...string) error{ "Host": host, "HostHeader": host, "HostRegexp": hostRegexp, + "ClientIP": clientIP, "Path": path, "PathPrefix": pathPrefix, "Method": methods, @@ -155,6 +157,27 @@ func host(route *mux.Route, hosts ...string) error { return nil } +func clientIP(route *mux.Route, clientIPs ...string) error { + checker, err := ip.NewChecker(clientIPs) + if err != nil { + return fmt.Errorf("could not initialize IP Checker for \"ClientIP\" matcher: %w", err) + } + + strategy := ip.RemoteAddrStrategy{} + + route.MatcherFunc(func(req *http.Request, _ *mux.RouteMatch) bool { + ok, err := checker.Contains(strategy.GetIP(req)) + if err != nil { + log.FromContext(req.Context()).Warnf("\"ClientIP\" matcher: could not match remote address : %w", err) + return false + } + + return ok + }) + + return nil +} + func hostRegexp(route *mux.Route, hosts ...string) error { router := route.Subrouter() for _, host := range hosts { diff --git a/pkg/rules/rules_test.go b/pkg/rules/rules_test.go index 4f7e3b368..ea468b5d1 100644 --- a/pkg/rules/rules_test.go +++ b/pkg/rules/rules_test.go @@ -17,6 +17,7 @@ func Test_addRoute(t *testing.T) { desc string rule string headers map[string]string + remoteAddr string expected map[string]int expectedError bool }{ @@ -519,6 +520,112 @@ func Test_addRoute(t *testing.T) { "http://plopi/a": http.StatusOK, }, }, + { + desc: "ClientIP empty", + rule: "ClientIP(``)", + expectedError: true, + }, + { + desc: "Invalid ClientIP", + rule: "ClientIP(`invalid`)", + expectedError: true, + }, + { + desc: "Non matching ClientIP", + rule: "ClientIP(`10.10.1.1`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Non matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "::1", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Matching IP", + rule: "ClientIP(`10.0.0.0`)", + remoteAddr: "10.0.0.0:8456", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "10::10", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among several IP", + rule: "ClientIP(`10.0.0.1`, `10.0.0.0`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Non Matching IP with CIDR", + rule: "ClientIP(`11.0.0.0/24`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Non Matching IPv6 with CIDR", + rule: "ClientIP(`11::/16`)", + remoteAddr: "10::", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Matching IP with CIDR", + rule: "ClientIP(`10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IPv6 with CIDR", + rule: "ClientIP(`10::/16`)", + remoteAddr: "10::10", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among several CIDR", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among non matching CIDR and matching IP", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among matching CIDR and non matching IP", + rule: "ClientIP(`11.0.0.0`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, } for _, test := range testCases { @@ -545,6 +652,10 @@ func Test_addRoute(t *testing.T) { w := httptest.NewRecorder() req := testhelpers.MustNewRequest(http.MethodGet, calledURL, nil) + + // Useful for the ClientIP matcher + req.RemoteAddr = test.remoteAddr + for key, value := range test.headers { req.Header.Set(key, value) }