feat: Add ext-auth plugin support for authentication blacklists/whitelists (#1694)

This commit is contained in:
韩贤涛
2025-01-21 14:28:49 +08:00
committed by GitHub
parent cfa3baddf8
commit 0259eaddbb
14 changed files with 1126 additions and 425 deletions

View File

@@ -0,0 +1,79 @@
package expr
import (
"strings"
regexp "github.com/wasilibs/go-re2"
)
const (
ModeWhitelist = "whitelist"
ModeBlacklist = "blacklist"
)
type MatchRules struct {
Mode string
RuleList []Rule
}
type Rule struct {
Domain string
Path Matcher
}
func MatchRulesDefaults() MatchRules {
return MatchRules{
Mode: ModeWhitelist,
RuleList: []Rule{},
}
}
// IsAllowedByMode checks if the given domain and path are allowed based on the configuration mode.
func (config *MatchRules) IsAllowedByMode(domain, path string) bool {
switch config.Mode {
case ModeWhitelist:
for _, rule := range config.RuleList {
if rule.matchDomainAndPath(domain, path) {
return true
}
}
return false
case ModeBlacklist:
for _, rule := range config.RuleList {
if rule.matchDomainAndPath(domain, path) {
return false
}
}
return true
default:
return false
}
}
// matchDomainAndPath checks if the given domain and path match the rule.
// If rule.Domain is empty, it only checks rule.Path.
// If rule.Path is empty, it only checks rule.Domain.
// If both are empty, it returns false.
func (rule *Rule) matchDomainAndPath(domain, path string) bool {
if rule.Domain == "" && rule.Path == nil {
return false
}
domainMatch := rule.Domain == "" || matchDomain(domain, rule.Domain)
pathMatch := rule.Path == nil || rule.Path.Match(path)
return domainMatch && pathMatch
}
// matchDomain checks if the given domain matches the pattern.
func matchDomain(domain string, pattern string) bool {
// Convert wildcard pattern to regex pattern
regexPattern := convertWildcardToRegex(pattern)
matched, _ := regexp.MatchString(regexPattern, domain)
return matched
}
// convertWildcardToRegex converts a wildcard pattern to a regex pattern.
func convertWildcardToRegex(pattern string) string {
pattern = regexp.QuoteMeta(pattern)
pattern = "^" + strings.ReplaceAll(pattern, "\\*", ".*") + "$"
return pattern
}

View File

@@ -0,0 +1,259 @@
package expr
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsAllowedByMode(t *testing.T) {
tests := []struct {
name string
config MatchRules
domain string
path string
expected bool
}{
{
name: "Whitelist mode, rule matches",
config: MatchRules{
Mode: ModeWhitelist,
RuleList: []Rule{
{
Domain: "example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "example.com",
path: "/foo",
expected: true,
},
{
name: "Whitelist mode, rule does not match",
config: MatchRules{
Mode: ModeWhitelist,
RuleList: []Rule{
{
Domain: "example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "example.com",
path: "/bar",
expected: false,
},
{
name: "Blacklist mode, rule matches",
config: MatchRules{
Mode: ModeBlacklist,
RuleList: []Rule{
{
Domain: "example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "example.com",
path: "/foo",
expected: false,
},
{
name: "Blacklist mode, rule does not match",
config: MatchRules{
Mode: ModeBlacklist,
RuleList: []Rule{
{
Domain: "example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "example.com",
path: "/bar",
expected: true,
},
{
name: "Domain matches, Path is empty",
config: MatchRules{
Mode: ModeWhitelist,
RuleList: []Rule{
{Domain: "example.com", Path: nil},
},
},
domain: "example.com",
path: "/foo",
expected: true,
},
{
name: "Domain is empty, Path matches",
config: MatchRules{
Mode: ModeWhitelist,
RuleList: []Rule{
{
Domain: "",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "example.com",
path: "/foo",
expected: true,
},
{
name: "Both Domain and Path are empty",
config: MatchRules{
Mode: ModeWhitelist,
RuleList: []Rule{
{Domain: "", Path: nil},
},
},
domain: "example.com",
path: "/foo",
expected: false,
},
{
name: "Invalid mode",
config: MatchRules{
Mode: "invalid",
RuleList: []Rule{
{
Domain: "example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "example.com",
path: "/foo",
expected: false,
},
{
name: "Whitelist mode, generic domain matches",
config: MatchRules{
Mode: ModeWhitelist,
RuleList: []Rule{
{
Domain: "*.example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "sub.example.com",
path: "/foo",
expected: true,
},
{
name: "Whitelist mode, generic domain does not match",
config: MatchRules{
Mode: ModeWhitelist,
RuleList: []Rule{
{
Domain: "*.example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "example.com",
path: "/foo",
expected: false,
},
{
name: "Blacklist mode, generic domain matches",
config: MatchRules{
Mode: ModeBlacklist,
RuleList: []Rule{
{
Domain: "*.example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "sub.example.com",
path: "/foo",
expected: false,
},
{
name: "Blacklist mode, generic domain does not match",
config: MatchRules{
Mode: ModeBlacklist,
RuleList: []Rule{
{
Domain: "*.example.com",
Path: func() Matcher {
pathMatcher, err := newStringExactMatcher("/foo", true)
if err != nil {
t.Fatalf("Failed to create Matcher: %v", err)
}
return pathMatcher
}(),
},
},
},
domain: "example.com",
path: "/foo",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.config.IsAllowedByMode(tt.domain, tt.path)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -4,18 +4,17 @@ import (
"errors"
"strings"
"github.com/tidwall/gjson"
regexp "github.com/wasilibs/go-re2"
)
const (
matchPatternExact string = "exact"
matchPatternPrefix string = "prefix"
matchPatternSuffix string = "suffix"
matchPatternContains string = "contains"
matchPatternRegex string = "regex"
MatchPatternExact string = "exact"
MatchPatternPrefix string = "prefix"
MatchPatternSuffix string = "suffix"
MatchPatternContains string = "contains"
MatchPatternRegex string = "regex"
matchIgnoreCase string = "ignore_case"
MatchIgnoreCase string = "ignore_case"
)
type Matcher interface {
@@ -78,78 +77,16 @@ func (m *stringRegexMatcher) Match(s string) bool {
return m.regex.MatchString(s)
}
type repeatedStringMatcher struct {
matchers []Matcher
}
func (rsm *repeatedStringMatcher) Match(s string) bool {
for _, m := range rsm.matchers {
if m.Match(s) {
return true
}
}
return false
}
func buildRepeatedStringMatcher(matchers []gjson.Result, allIgnoreCase bool) (Matcher, error) {
builtMatchers := make([]Matcher, len(matchers))
createMatcher := func(json gjson.Result, targetKey string, ignoreCase bool, matcherType MatcherConstructor) (Matcher, error) {
result := json.Get(targetKey)
if result.Exists() && result.String() != "" {
target := result.String()
return matcherType(target, ignoreCase)
}
return nil, nil
}
for i, item := range matchers {
var matcher Matcher
var err error
// If allIgnoreCase is true, it takes precedence over any user configuration,
// forcing case-insensitive matching regardless of individual item settings.
ignoreCase := allIgnoreCase
if !allIgnoreCase {
ignoreCaseResult := item.Get(matchIgnoreCase)
if ignoreCaseResult.Exists() && ignoreCaseResult.Bool() {
ignoreCase = true
}
}
for _, matcherType := range []struct {
key string
creator MatcherConstructor
}{
{matchPatternExact, newStringExactMatcher},
{matchPatternPrefix, newStringPrefixMatcher},
{matchPatternSuffix, newStringSuffixMatcher},
{matchPatternContains, newStringContainsMatcher},
{matchPatternRegex, newStringRegexMatcher},
} {
if matcher, err = createMatcher(item, matcherType.key, ignoreCase, matcherType.creator); err != nil {
return nil, err
}
if matcher != nil {
break
}
}
if matcher == nil {
return nil, errors.New("unknown string matcher type")
}
builtMatchers[i] = matcher
}
return &repeatedStringMatcher{
matchers: builtMatchers,
}, nil
}
type MatcherConstructor func(string, bool) (Matcher, error)
var matcherConstructors = map[string]MatcherConstructor{
MatchPatternExact: newStringExactMatcher,
MatchPatternPrefix: newStringPrefixMatcher,
MatchPatternSuffix: newStringSuffixMatcher,
MatchPatternContains: newStringContainsMatcher,
MatchPatternRegex: newStringRegexMatcher,
}
func newStringExactMatcher(target string, ignoreCase bool) (Matcher, error) {
if ignoreCase {
target = strings.ToLower(target)
@@ -189,14 +126,11 @@ func newStringRegexMatcher(target string, ignoreCase bool) (Matcher, error) {
return &stringRegexMatcher{regex: re}, nil
}
func BuildRepeatedStringMatcherIgnoreCase(matchers []gjson.Result) (Matcher, error) {
return buildRepeatedStringMatcher(matchers, true)
}
func BuildRepeatedStringMatcher(matchers []gjson.Result) (Matcher, error) {
return buildRepeatedStringMatcher(matchers, false)
}
func BuildStringMatcher(matcher gjson.Result) (Matcher, error) {
return BuildRepeatedStringMatcher([]gjson.Result{matcher})
func BuildStringMatcher(matchType, target string, ignoreCase bool) (Matcher, error) {
for constructorType, constructor := range matcherConstructors {
if constructorType == matchType {
return constructor(target, ignoreCase)
}
}
return nil, errors.New("unknown string matcher type")
}

View File

@@ -4,78 +4,96 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
func TestStringMatcher(t *testing.T) {
tests := []struct {
name string
cfg string
matchType string
target string
ignoreCase bool
matched []string
mismatched []string
}{
{
name: "exact",
cfg: `{"exact": "foo"}`,
matchType: MatchPatternExact,
target: "foo",
matched: []string{"foo"},
mismatched: []string{"fo", "fooo"},
},
{
name: "exact, ignore_case",
cfg: `{"exact": "foo", "ignore_case": true}`,
matched: []string{"Foo", "foo"},
name: "exact, ignore_case",
matchType: MatchPatternExact,
target: "foo",
ignoreCase: true,
matched: []string{"Foo", "foo"},
},
{
name: "prefix",
cfg: `{"prefix": "/p"}`,
matchType: MatchPatternPrefix,
target: "/p",
matched: []string{"/p", "/pa"},
mismatched: []string{"/P"},
},
{
name: "prefix, ignore_case",
cfg: `{"prefix": "/p", "ignore_case": true}`,
matchType: MatchPatternPrefix,
target: "/p",
ignoreCase: true,
matched: []string{"/P", "/p", "/pa", "/Pa"},
mismatched: []string{"/"},
},
{
name: "suffix",
cfg: `{"suffix": "foo"}`,
matchType: MatchPatternSuffix,
target: "foo",
matched: []string{"foo", "0foo"},
mismatched: []string{"fo", "fooo", "aFoo"},
},
{
name: "suffix, ignore_case",
cfg: `{"suffix": "foo", "ignore_case": true}`,
matchType: MatchPatternSuffix,
target: "foo",
ignoreCase: true,
matched: []string{"aFoo", "foo"},
mismatched: []string{"fo", "fooo"},
},
{
name: "contains",
cfg: `{"contains": "foo"}`,
matchType: MatchPatternContains,
target: "foo",
matched: []string{"foo", "0foo", "fooo"},
mismatched: []string{"fo", "aFoo"},
},
{
name: "contains, ignore_case",
cfg: `{"contains": "foo", "ignore_case": true}`,
matchType: MatchPatternContains,
target: "foo",
ignoreCase: true,
matched: []string{"aFoo", "foo", "FoO"},
mismatched: []string{"fo"},
},
{
name: "regex",
cfg: `{"regex": "fo{2}"}`,
matchType: MatchPatternRegex,
target: "fo{2}",
matched: []string{"foo", "0foo", "fooo"},
mismatched: []string{"aFoo", "fo"},
},
{
name: "regex, ignore_case",
cfg: `{"regex": "fo{2}", "ignore_case": true}`,
matchType: MatchPatternRegex,
target: "fo{2}",
ignoreCase: true,
matched: []string{"foo", "0foo", "fooo", "aFoo"},
mismatched: []string{"fo"},
},
{
name: "regex, ignore_case & case insensitive specified in regex",
cfg: `{"regex": "(?i)fo{2}", "ignore_case": true}`,
matchType: MatchPatternRegex,
target: "(?i)fo{2}",
ignoreCase: true,
matched: []string{"foo", "0foo", "fooo", "aFoo"},
mismatched: []string{"fo"},
},
@@ -83,7 +101,7 @@ func TestStringMatcher(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
built, _ := BuildStringMatcher(gjson.Parse(tt.cfg))
built, _ := BuildStringMatcher(tt.matchType, tt.target, tt.ignoreCase)
for _, s := range tt.matched {
assert.True(t, built.Match(s))
}
@@ -93,30 +111,3 @@ func TestStringMatcher(t *testing.T) {
})
}
}
func TestBuildRepeatedStringMatcherIgnoreCase(t *testing.T) {
cfgs := []string{
`{"exact":"foo"}`,
`{"prefix":"pre"}`,
`{"regex":"^Cache"}`,
}
matched := []string{"Foo", "foO", "foo", "PreA", "cache-control", "Cache-Control"}
mismatched := []string{"afoo", "fo"}
ms := []gjson.Result{}
for _, cfg := range cfgs {
ms = append(ms, gjson.Parse(cfg))
}
built, _ := BuildRepeatedStringMatcherIgnoreCase(ms)
for _, s := range matched {
assert.True(t, built.Match(s))
}
for _, s := range mismatched {
assert.False(t, built.Match(s))
}
}
func TestPassOutRegexCompileErr(t *testing.T) {
cfg := `{"regex":"(?!)aa"}`
_, err := BuildRepeatedStringMatcher([]gjson.Result{gjson.Parse(cfg)})
assert.NotNil(t, err)
}

View File

@@ -0,0 +1,76 @@
package expr
import (
"errors"
"github.com/tidwall/gjson"
)
type repeatedStringMatcher struct {
matchers []Matcher
}
func (rsm *repeatedStringMatcher) Match(s string) bool {
for _, m := range rsm.matchers {
if m.Match(s) {
return true
}
}
return false
}
func buildRepeatedStringMatcher(matchers []gjson.Result, allIgnoreCase bool) (Matcher, error) {
builtMatchers := make([]Matcher, len(matchers))
createMatcher := func(json gjson.Result, targetKey string, ignoreCase bool, constructor MatcherConstructor) (Matcher, error) {
result := json.Get(targetKey)
if result.Exists() && result.String() != "" {
target := result.String()
return constructor(target, ignoreCase)
}
return nil, nil
}
for i, item := range matchers {
var matcher Matcher
var err error
// If allIgnoreCase is true, it takes precedence over any user configuration,
// forcing case-insensitive matching regardless of individual item settings.
ignoreCase := allIgnoreCase
if !allIgnoreCase {
ignoreCaseResult := item.Get(MatchIgnoreCase)
if ignoreCaseResult.Exists() && ignoreCaseResult.Bool() {
ignoreCase = true
}
}
for key, creator := range matcherConstructors {
if matcher, err = createMatcher(item, key, ignoreCase, creator); err != nil {
return nil, err
}
if matcher != nil {
break
}
}
if matcher == nil {
return nil, errors.New("unknown string matcher type")
}
builtMatchers[i] = matcher
}
return &repeatedStringMatcher{
matchers: builtMatchers,
}, nil
}
func BuildRepeatedStringMatcherIgnoreCase(matchers []gjson.Result) (Matcher, error) {
return buildRepeatedStringMatcher(matchers, true)
}
func BuildRepeatedStringMatcher(matchers []gjson.Result) (Matcher, error) {
return buildRepeatedStringMatcher(matchers, false)
}

View File

@@ -0,0 +1,36 @@
package expr
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
func TestBuildRepeatedStringMatcherIgnoreCase(t *testing.T) {
cfg := `[
{"exact":"foo"},
{"prefix":"pre"},
{"regex":"^Cache"}
]`
matched := []string{"Foo", "foO", "foo", "PreA", "cache-control", "Cache-Control"}
mismatched := []string{"afoo", "fo"}
jsonArray := gjson.Parse(cfg).Array()
built, err := BuildRepeatedStringMatcherIgnoreCase(jsonArray)
if err != nil {
t.Fatalf("Failed to build RepeatedStringMatcher: %v", err)
}
for _, s := range matched {
assert.True(t, built.Match(s))
}
for _, s := range mismatched {
assert.False(t, built.Match(s))
}
}
func TestPassOutRegexCompileErr(t *testing.T) {
cfg := `{"regex":"(?!)aa"}`
_, err := BuildRepeatedStringMatcher([]gjson.Result{gjson.Parse(cfg)})
assert.NotNil(t, err)
}