From 2da7fa0397c2efc1d9cd507a59a74d9c4b87b7a8 Mon Sep 17 00:00:00 2001 From: Romain Date: Fri, 18 Mar 2022 16:04:08 +0100 Subject: [PATCH] Add HostSNIRegexp rule matcher for TCP --- docs/content/routing/routers/index.md | 19 ++- pkg/muxer/tcp/mux.go | 128 ++++++++++++++++++- pkg/muxer/tcp/mux_test.go | 171 ++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 7 deletions(-) diff --git a/docs/content/routing/routers/index.md b/docs/content/routing/routers/index.md index 7b52fbc97..d86b53859 100644 --- a/docs/content/routing/routers/index.md +++ b/docs/content/routing/routers/index.md @@ -819,16 +819,25 @@ If the rule is verified, the router becomes active, calls middlewares, and then The table below lists all the available matchers: -| Rule | Description | -|---------------------------------------------|-----------------------------------------------------------------------------------------------------------| -| ```HostSNI(`domain-1`, ...)``` | Check if the Server Name Indication corresponds to the given `domains`. | -| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Check if the request client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. | +| Rule | Description | +|---------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------| +| ```HostSNI(`domain-1`, ...)``` | Check if the Server Name Indication corresponds to the given `domains`. | +| ```HostSNIRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Check if the Server Name Indication matches the given regular expressions. See "Regexp Syntax" below. | +| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Check if the request client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. | !!! important "Non-ASCII Domain Names" - Non-ASCII characters are not supported in the `HostSNI` expression, and by doing so the associated TCP router will be invalid. + Non-ASCII characters are not supported in the `HostSNI` and `HostSNIRegexp` expressions, and so using them would invalidate the associated TCP router. Domain names containing non-ASCII characters must be provided as punycode encoded values ([rfc 3492](https://tools.ietf.org/html/rfc3492)). +!!! important "Regexp Syntax" + + `HostSNIRegexp` accepts an expression with zero or more groups enclosed by curly braces, which are called named regexps. + Named regexps, of the form `{name:regexp}`, are the only expressions considered for regexp matching. + The regexp name (`name` in the above example) is an arbitrary value, that exists only for historical reasons. + + Any `regexp` supported by [Go's regexp package](https://golang.org/pkg/regexp/) may be used. + !!! important "HostSNI & TLS" It is important to note that the Server Name Indication is an extension of the TLS protocol. diff --git a/pkg/muxer/tcp/mux.go b/pkg/muxer/tcp/mux.go index 260761a74..b82675614 100644 --- a/pkg/muxer/tcp/mux.go +++ b/pkg/muxer/tcp/mux.go @@ -1,11 +1,13 @@ package tcp import ( + "bytes" "errors" "fmt" "net" "regexp" "sort" + "strconv" "strings" "github.com/traefik/traefik/v2/pkg/ip" @@ -17,8 +19,9 @@ import ( ) var tcpFuncs = map[string]func(*matchersTree, ...string) error{ - "HostSNI": hostSNI, - "ClientIP": clientIP, + "HostSNI": hostSNI, + "HostSNIRegexp": hostSNIRegexp, + "ClientIP": clientIP, } // ParseHostSNI extracts the HostSNIs declared in a rule. @@ -326,3 +329,124 @@ func hostSNI(tree *matchersTree, hosts ...string) error { return nil } + +// hostSNIRegexp checks if the SNI Host of the connection matches the matcher host regexp. +func hostSNIRegexp(tree *matchersTree, templates ...string) error { + if len(templates) == 0 { + return fmt.Errorf("empty value for \"HostSNIRegexp\" matcher is not allowed") + } + + var regexps []*regexp.Regexp + + for _, template := range templates { + preparedPattern, err := preparePattern(template) + if err != nil { + return fmt.Errorf("invalid pattern value for \"HostSNIRegexp\" matcher, %q is not a valid pattern: %w", template, err) + } + + regexp, err := regexp.Compile(preparedPattern) + if err != nil { + return err + } + + regexps = append(regexps, regexp) + } + + tree.matcher = func(meta ConnData) bool { + for _, regexp := range regexps { + if regexp.MatchString(meta.serverName) { + return true + } + } + + return false + } + + return nil +} + +// TODO: expose more of containous/mux fork to get rid of the following copied code (https://github.com/containous/mux/blob/8ffa4f6d063c/regexp.go). + +// preparePattern builds a regexp pattern from the initial user defined expression. +// This function reuses the code dedicated to host matching of the newRouteRegexp func from the gorilla/mux library. +// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. +func preparePattern(template string) (string, error) { + // Check if it is well-formed. + idxs, errBraces := braceIndices(template) + if errBraces != nil { + return "", errBraces + } + + defaultPattern := "[^.]+" + pattern := bytes.NewBufferString("") + + // Host SNI matching is case-insensitive + fmt.Fprint(pattern, "(?i)") + + pattern.WriteByte('^') + var end int + var err error + for i := 0; i < len(idxs); i += 2 { + // Set all values we are interested in. + raw := template[end:idxs[i]] + end = idxs[i+1] + parts := strings.SplitN(template[idxs[i]+1:end-1], ":", 2) + name := parts[0] + patt := defaultPattern + if len(parts) == 2 { + patt = parts[1] + } + // Name or pattern can't be empty. + if name == "" || patt == "" { + return "", fmt.Errorf("mux: missing name or pattern in %q", + template[idxs[i]:end]) + } + // Build the regexp pattern. + fmt.Fprintf(pattern, "%s(?P<%s>%s)", regexp.QuoteMeta(raw), varGroupName(i/2), patt) + + // Append variable name and compiled pattern. + if err != nil { + return "", err + } + } + + // Add the remaining. + raw := template[end:] + pattern.WriteString(regexp.QuoteMeta(raw)) + pattern.WriteByte('$') + + return pattern.String(), nil +} + +// varGroupName builds a capturing group name for the indexed variable. +// This function is a copy of varGroupName func from the gorilla/mux library. +// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. +func varGroupName(idx int) string { + return "v" + strconv.Itoa(idx) +} + +// braceIndices returns the first level curly brace indices from a string. +// This function is a copy of braceIndices func from the gorilla/mux library. +// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. +func braceIndices(s string) ([]int, error) { + var level, idx int + var idxs []int + for i := 0; i < len(s); i++ { + switch s[i] { + case '{': + if level++; level == 1 { + idx = i + } + case '}': + if level--; level == 0 { + idxs = append(idxs, idx, i+1) + } else if level < 0 { + return nil, fmt.Errorf("mux: unbalanced braces in %q", s) + } + } + } + if level != 0 { + return nil, fmt.Errorf("mux: unbalanced braces in %q", s) + } + return idxs, nil +} diff --git a/pkg/muxer/tcp/mux_test.go b/pkg/muxer/tcp/mux_test.go index 44be7c792..65f3021b7 100644 --- a/pkg/muxer/tcp/mux_test.go +++ b/pkg/muxer/tcp/mux_test.go @@ -108,12 +108,66 @@ func Test_addTCPRoute(t *testing.T) { serverName: "bar", matchErr: true, }, + { + desc: "Empty HostSNIRegexp rule", + rule: "HostSNIRegexp()", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Empty HostSNIRegexp rule", + rule: "HostSNIRegexp(``)", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Valid HostSNIRegexp rule matching", + rule: "HostSNIRegexp(`{subdomain:[a-z]+}.foobar`)", + serverName: "sub.foobar", + }, + { + desc: "Valid negative HostSNIRegexp rule matching", + rule: "!HostSNIRegexp(`bar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule matching with alternative case", + rule: "hostsniregexp(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule matching with alternative case", + rule: "HOSTSNIREGEXP(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule not matching", + rule: "HostSNIRegexp(`foobar`)", + serverName: "bar", + matchErr: true, + }, { desc: "Valid negative HostSNI rule not matching", rule: "!HostSNI(`bar`)", serverName: "bar", matchErr: true, }, + { + desc: "Valid HostSNIRegexp rule matching empty servername", + rule: "HostSNIRegexp(`{subdomain:[a-z]*}`)", + serverName: "", + }, + { + desc: "Valid HostSNIRegexp rule with one name", + rule: "HostSNIRegexp(`{dummy}`)", + serverName: "toto", + }, + { + desc: "Valid HostSNIRegexp rule with one name 2", + rule: "HostSNIRegexp(`{dummy}`)", + serverName: "toto.com", + matchErr: true, + }, { desc: "Empty ClientIP rule", rule: "ClientIP()", @@ -608,6 +662,123 @@ func Test_HostSNI(t *testing.T) { } } +func Test_HostSNIRegexp(t *testing.T) { + testCases := []struct { + desc string + pattern string + serverNames map[string]bool + buildErr bool + }{ + { + desc: "unbalanced braces", + pattern: "subdomain:(foo\\.)?bar\\.com}", + buildErr: true, + }, + { + desc: "empty group name", + pattern: "{:(foo\\.)?bar\\.com}", + buildErr: true, + }, + { + desc: "empty capturing group", + pattern: "{subdomain:}", + buildErr: true, + }, + { + desc: "malformed capturing group", + pattern: "{subdomain:(foo\\.?bar\\.com}", + buildErr: true, + }, + { + desc: "not interpreted as a regexp", + pattern: "bar.com", + serverNames: map[string]bool{ + "bar.com": true, + "barucom": false, + }, + }, + { + desc: "capturing group", + pattern: "{subdomain:(foo\\.)?bar\\.com}", + serverNames: map[string]bool{ + "foo.bar.com": true, + "bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "non capturing group", + pattern: "{subdomain:(?:foo\\.)?bar\\.com}", + serverNames: map[string]bool{ + "foo.bar.com": true, + "bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "regex insensitive", + pattern: "{dummy:[A-Za-z-]+\\.bar\\.com}", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "insensitive host", + pattern: "{dummy:[a-z-]+\\.bar\\.com}", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "insensitive host simple", + pattern: "foo.bar.com", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matchersTree := &matchersTree{} + err := hostSNIRegexp(matchersTree, test.pattern) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + for serverName, match := range test.serverNames { + meta := ConnData{ + serverName: serverName, + } + + assert.Equal(t, match, matchersTree.match(meta)) + } + }) + } +} + func Test_ClientIP(t *testing.T) { testCases := []struct { desc string