Add HostSNIRegexp rule matcher for TCP

This commit is contained in:
Romain 2022-03-18 16:04:08 +01:00 committed by GitHub
parent 0d58e8d1ad
commit 2da7fa0397
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 311 additions and 7 deletions

View file

@ -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: The table below lists all the available matchers:
| Rule | Description | | Rule | Description |
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------| |---------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|
| ```HostSNI(`domain-1`, ...)``` | Check if the Server Name Indication corresponds to the given `domains`. | | ```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. | | ```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" !!! 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)). 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" !!! important "HostSNI & TLS"
It is important to note that the Server Name Indication is an extension of the TLS protocol. It is important to note that the Server Name Indication is an extension of the TLS protocol.

View file

@ -1,11 +1,13 @@
package tcp package tcp
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/traefik/traefik/v2/pkg/ip" "github.com/traefik/traefik/v2/pkg/ip"
@ -17,8 +19,9 @@ import (
) )
var tcpFuncs = map[string]func(*matchersTree, ...string) error{ var tcpFuncs = map[string]func(*matchersTree, ...string) error{
"HostSNI": hostSNI, "HostSNI": hostSNI,
"ClientIP": clientIP, "HostSNIRegexp": hostSNIRegexp,
"ClientIP": clientIP,
} }
// ParseHostSNI extracts the HostSNIs declared in a rule. // ParseHostSNI extracts the HostSNIs declared in a rule.
@ -326,3 +329,124 @@ func hostSNI(tree *matchersTree, hosts ...string) error {
return nil 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
}

View file

@ -108,12 +108,66 @@ func Test_addTCPRoute(t *testing.T) {
serverName: "bar", serverName: "bar",
matchErr: true, 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", desc: "Valid negative HostSNI rule not matching",
rule: "!HostSNI(`bar`)", rule: "!HostSNI(`bar`)",
serverName: "bar", serverName: "bar",
matchErr: true, 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", desc: "Empty ClientIP rule",
rule: "ClientIP()", 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) { func Test_ClientIP(t *testing.T) {
testCases := []struct { testCases := []struct {
desc string desc string