diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go index 2debe0bd..ac3266c3 100644 --- a/backend/core/extpoints/router.go +++ b/backend/core/extpoints/router.go @@ -45,7 +45,7 @@ type RouterRegistry struct { nextID uint64 routes []RouteDefinition middlewares []any - whitelist []string + whitelist PathWhitelist } // NewRouterRegistry creates a new root router collector. @@ -182,36 +182,17 @@ func (r *RouterRegistry) Routes() []RouteDefinition { // RegisterWhitelist adds path patterns to the whitelist. func (r *RouterRegistry) RegisterWhitelist(patterns ...string) { - r.mu.Lock() - defer r.mu.Unlock() - for _, p := range patterns { - clean := cleanPath(p) - if clean != "" { - r.whitelist = append(r.whitelist, clean) - } - } + r.whitelist.Add(patterns...) } // Whitelist returns a copy of all registered whitelist path patterns. func (r *RouterRegistry) Whitelist() []string { - r.mu.RLock() - defer r.mu.RUnlock() - res := make([]string, len(r.whitelist)) - copy(res, r.whitelist) - return res + return r.whitelist.Patterns() } // IsWhitelisted checks if the given path matches any registered whitelist pattern. func (r *RouterRegistry) IsWhitelisted(path string) bool { - r.mu.RLock() - defer r.mu.RUnlock() - clean := cleanPath(path) - for _, pattern := range r.whitelist { - if MatchPathPattern(pattern, clean) { - return true - } - } - return false + return r.whitelist.Match(path) } // RouterGroup represents a scoped route group with a path prefix and group-level middlewares. @@ -411,3 +392,114 @@ func MatchPathPattern(pattern, path string) bool { return false } + +// compiledPattern holds a whitelist pattern with its per-request work already done. +type compiledPattern struct { + raw string // normalised pattern, reported back by Patterns + prefix string // non-empty when the pattern ends in "/*" + parts []string // normalised pattern split on "/" +} + +// PathWhitelist matches request paths against a fixed set of patterns. +// +// Patterns are registered once during plugin Apply and never change afterwards, so +// normalising and splitting them on every request is wasted work. PathWhitelist +// does that once at registration instead. The zero value is ready to use. +type PathWhitelist struct { + mu sync.RWMutex + patterns []compiledPattern +} + +// NewPathWhitelist returns a whitelist pre-populated with the given patterns. +func NewPathWhitelist(patterns ...string) *PathWhitelist { + w := &PathWhitelist{} + w.Add(patterns...) + return w +} + +// compilePatterns normalises and splits each pattern once, ahead of any request. +func compilePatterns(patterns []string) []compiledPattern { + compiled := make([]compiledPattern, 0, len(patterns)) + for _, p := range patterns { + clean := cleanPath(p) + cp := compiledPattern{raw: clean, parts: strings.Split(clean, "/")} + if strings.HasSuffix(clean, "/*") { + cp.prefix = strings.TrimSuffix(clean, "/*") + } + compiled = append(compiled, cp) + } + return compiled +} + +// Add appends patterns, normalising and splitting each now rather than per request. +func (w *PathWhitelist) Add(patterns ...string) { + if len(patterns) == 0 { + return + } + compiled := compilePatterns(patterns) + + w.mu.Lock() + defer w.mu.Unlock() + w.patterns = append(w.patterns, compiled...) +} + +// Replace discards any existing patterns and installs the given ones, for callers +// whose configuration is a full swap rather than an incremental registration. +func (w *PathWhitelist) Replace(patterns ...string) { + compiled := compilePatterns(patterns) + + w.mu.Lock() + defer w.mu.Unlock() + w.patterns = compiled +} + +// Match reports whether path matches any registered pattern. Equivalent to calling +// MatchPathPattern for every pattern, except the path is normalised and split once. +func (w *PathWhitelist) Match(path string) bool { + clean := cleanPath(path) + pathParts := strings.Split(clean, "/") + + w.mu.RLock() + defer w.mu.RUnlock() + for i := range w.patterns { + p := &w.patterns[i] + if p.raw == clean { + return true + } + // A suffix wildcard matches both the bare prefix and anything below it. + if p.prefix != "" && (clean == p.prefix || strings.HasPrefix(clean, p.prefix+"/")) { + return true + } + if len(p.parts) != len(pathParts) { + continue + } + if matchSegments(p.parts, pathParts) { + return true + } + } + return false +} + +// matchSegments compares an already-split pattern against an already-split path. +func matchSegments(patternParts, pathParts []string) bool { + for i, part := range patternParts { + if part == "*" || strings.HasPrefix(part, ":") { + continue + } + if part != pathParts[i] { + return false + } + } + return true +} + +// Patterns returns a copy of the registered patterns in registration order. +func (w *PathWhitelist) Patterns() []string { + w.mu.RLock() + defer w.mu.RUnlock() + res := make([]string, len(w.patterns)) + for i := range w.patterns { + res[i] = w.patterns[i].raw + } + return res +} diff --git a/backend/core/extpoints/whitelist_test.go b/backend/core/extpoints/whitelist_test.go new file mode 100644 index 00000000..2a33e501 --- /dev/null +++ b/backend/core/extpoints/whitelist_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints_test + +import ( + "Wavelet/core/extpoints" + "testing" +) + +// whitelistEquivalencePatterns and paths cover every matching rule MatchPathPattern +// implements, so PathWhitelist.Match can be pinned against the behaviour it replaces. +var ( + whitelistEquivalencePatterns = []string{ + "/api/v1/user/login", + "/api/v1/oauth/*", + "/api/v1/cap/:source/authorize", + "/api/v1/files/*/download", + "/", + "login", + "/api/v1/x/", + "", + } + + whitelistEquivalencePaths = []string{ + "/api/v1/user/login", + "/api/v1/user/login/", + "/api/v1/oauth/callback", + "/api/v1/oauth", + "/api/v1/oauth/a/b", + "/api/v1/cap/github/authorize", + "/api/v1/cap/:source/authorize", + "/api/v1/files/abc/download", + "/api/v1/files/a/b/download", + "/", + "login", + "/login", + "", + "/api/v1/x", + "/api/v1/x/y", + } +) + +// legacyMatch reproduces the per-request loop every whitelist caller used before +// PathWhitelist existed. +func legacyMatch(patterns []string, path string) bool { + for _, pattern := range patterns { + if extpoints.MatchPathPattern(pattern, path) { + return true + } + } + return false +} + +func TestPathWhitelistMatchesLegacyLoop(t *testing.T) { + for _, pattern := range whitelistEquivalencePatterns { + wl := extpoints.NewPathWhitelist(pattern) + for _, path := range whitelistEquivalencePaths { + got := wl.Match(path) + want := legacyMatch([]string{pattern}, path) + if got != want { + t.Errorf("pattern %q path %q: Match=%v, legacy=%v", pattern, path, got, want) + } + } + } +} + +func TestPathWhitelistAccumulatesAcrossRegistration(t *testing.T) { + wl := extpoints.NewPathWhitelist("/api/v1/a") + wl.Add("/api/v1/b/*") + + if !wl.Match("/api/v1/a") { + t.Error("first registration lost") + } + if !wl.Match("/api/v1/b/deep") { + t.Error("second registration lost") + } + if wl.Match("/api/v1/c") { + t.Error("path outside both registrations matched") + } + + got := wl.Patterns() + want := []string{"/api/v1/a", "/api/v1/b/*"} + if len(got) != len(want) { + t.Fatalf("Patterns() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Patterns()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestPathWhitelistReplaceDropsPreviousPatterns(t *testing.T) { + wl := extpoints.NewPathWhitelist("/api/v1/a") + wl.Replace("/api/v1/b") + + if wl.Match("/api/v1/a") { + t.Error("Replace kept a pattern it should have discarded") + } + if !wl.Match("/api/v1/b") { + t.Error("Replace did not install the new pattern") + } +} + +// whitelistBenchPatterns mirrors a realistically sized auth whitelist. +var whitelistBenchPatterns = []string{ + "/api/v1/user/login", + "/api/v1/auth/refresh", + "/api/v1/oauth/*", + "/api/v1/cap/*", + "/api/v1/public/config", + "/api/v1/health", + "/api/v1/uploads/:id/file", + "/api/v1/notify/webhook/:channel", + "/login", + "/api/v1/access-tokens/:id/revoke", +} + +// TestPathWhitelistAllocationReduction asserts the point of pre-compiling: a single +// Match must allocate less than the legacy per-pattern loop it replaces. +func TestPathWhitelistAllocationReduction(t *testing.T) { + wl := extpoints.NewPathWhitelist(whitelistBenchPatterns...) + + legacy := testing.Benchmark(func(b *testing.B) { + for b.Loop() { + _ = legacyMatch(whitelistBenchPatterns, "/api/v1/uploads/9/file") + } + }) + compiled := testing.Benchmark(func(b *testing.B) { + for b.Loop() { + _ = wl.Match("/api/v1/uploads/9/file") + } + }) + + legacyAlloc := legacy.AllocsPerOp() + compiledAlloc := compiled.AllocsPerOp() + t.Logf("legacy %d allocs/op, PathWhitelist %d allocs/op", legacyAlloc, compiledAlloc) + + if compiledAlloc >= legacyAlloc { + t.Errorf("PathWhitelist allocated %d/op, want fewer than legacy %d/op", compiledAlloc, legacyAlloc) + } +} + +func BenchmarkLegacyWhitelistMatch(b *testing.B) { + for b.Loop() { + _ = legacyMatch(whitelistBenchPatterns, "/api/v1/uploads/9/file") + } +} + +func BenchmarkPathWhitelistMatch(b *testing.B) { + wl := extpoints.NewPathWhitelist(whitelistBenchPatterns...) + b.ResetTimer() + for b.Loop() { + _ = wl.Match("/api/v1/uploads/9/file") + } +} diff --git a/backend/plugins/domain/auth/middleware.go b/backend/plugins/domain/auth/middleware.go index 49ed23fa..ea93a667 100644 --- a/backend/plugins/domain/auth/middleware.go +++ b/backend/plugins/domain/auth/middleware.go @@ -13,33 +13,22 @@ import ( "crypto/sha256" "encoding/hex" "errors" - "sync" "github.com/gin-gonic/gin" ) -var ( - whitelistMu sync.RWMutex - whitelist []string -) +// whitelist holds the no-auth route patterns. They are registered during Apply and +// matched on every request, so PathWhitelist parses them once up front. +var whitelist = extpoints.NewPathWhitelist() // RegisterWhitelist registers route patterns that bypass mandatory authentication. func RegisterWhitelist(patterns ...string) { - whitelistMu.Lock() - defer whitelistMu.Unlock() - whitelist = append(whitelist, patterns...) + whitelist.Add(patterns...) } // IsWhitelisted checks if the specified path matches the auth whitelist. func IsWhitelisted(path string) bool { - whitelistMu.RLock() - defer whitelistMu.RUnlock() - for _, pattern := range whitelist { - if extpoints.MatchPathPattern(pattern, path) { - return true - } - } - return false + return whitelist.Match(path) } func hashToken(token string) string { diff --git a/backend/plugins/drivers/driver_http/middlewares.go b/backend/plugins/drivers/driver_http/middlewares.go index cb15f036..69ada22b 100644 --- a/backend/plugins/drivers/driver_http/middlewares.go +++ b/backend/plugins/drivers/driver_http/middlewares.go @@ -25,29 +25,20 @@ import ( var ( apiPrefixMu sync.RWMutex apiPrefix = "/api/v1" - - whitelistMu sync.RWMutex - whitelistPatterns []string ) +// whitelist holds the global no-auth HTTP patterns. They are set once at +// configuration time and matched per request, so PathWhitelist parses them up front. +var whitelist = extpoints.NewPathWhitelist() + // SetWhitelist configures global whitelist patterns for HTTP routes. func SetWhitelist(patterns []string) { - whitelistMu.Lock() - defer whitelistMu.Unlock() - whitelistPatterns = make([]string, len(patterns)) - copy(whitelistPatterns, patterns) + whitelist.Replace(patterns...) } // IsPathWhitelisted checks if the given path matches any registered whitelist pattern. func IsPathWhitelisted(path string) bool { - whitelistMu.RLock() - defer whitelistMu.RUnlock() - for _, pattern := range whitelistPatterns { - if extpoints.MatchPathPattern(pattern, path) { - return true - } - } - return false + return whitelist.Match(path) } func setAPIPrefix(prefix string) {