From 3052cc0da32bdd816425fc501036c77c82e32999 Mon Sep 17 00:00:00 2001 From: Stavros Date: Tue, 18 Aug 2026 20:55:05 +0300 Subject: [PATCH 01/10] fix: only allow one auth module to succeed per request --- internal/controller/proxy_controller.go | 32 +++++++++++++++----- internal/controller/proxy_controller_test.go | 28 ++++++++++++++++- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index c239e29f..85f4fa92 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -465,6 +465,10 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont // We get the path from the query string path := c.Query("path") + if strings.TrimSpace(path) == "" { + return ProxyContext{}, errors.New("path not found") + } + // For envoy we need to support every method method := c.Request.Method @@ -536,20 +540,32 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy) } - var ctx ProxyContext + ctxs := make(map[AuthModuleType]ProxyContext) for _, module := range authModules { controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module) - ctx, err = controller.getContextFromAuthModule(c, module) - if err == nil { - controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module) - break + ctx, err := controller.getContextFromAuthModule(c, module) + if err != nil { + controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err) + continue } - controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err) + controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module) + ctxs[module] = ctx } - if err != nil { - return ProxyContext{}, err + if len(ctxs) == 0 { + return ProxyContext{}, fmt.Errorf("no auth module context found") + } + + if len(ctxs) > 1 { + controller.log.App.Warn().Msg("Multiple auth module contexts found, something is wrong in your proxy config or someone is trying to spoof the request, denying") + return ProxyContext{}, fmt.Errorf("multiple auth module contexts found") + } + + var ctx ProxyContext + + for _, c := range ctxs { + ctx = c } // Parse the raw path to populate the cleaned path used for ACLs diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index 4d2e23a3..b63ced50 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -261,7 +261,7 @@ func TestProxyController(t *testing.T) { description: "Ensure extauthz with envoy non browser returns json", middlewares: []gin.HandlerFunc{}, run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { - req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil) + req := httptest.NewRequest("HEAD", "/api/auth/envoy", nil) req.Header.Set("x-forwarded-host", "test.example.com") req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-uri", "/hello") @@ -877,6 +877,32 @@ func TestProxyController(t *testing.T) { assert.Equal(t, "bar", recorder.Header().Get("x-foo")) }, }, + { + description: "Forward auth and auth request headers should fail for nginx", + run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { + req := httptest.NewRequest("GET", "/api/auth/nginx", nil) + req.Header.Set("x-forwarded-host", "foo.example.com") + req.Header.Set("x-forwarded-proto", "https") + req.Header.Set("x-forwarded-uri", "/foo?bar=foo") + req.Header.Set("x-original-url", "https://foo.example.com/foo?bar=foo") + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, + { + description: "Forward auth and ext authz headers should fail for envoy", + run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { + req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil) + req.Host = "foo.example.com" + req.Header.Set("x-forwarded-host", "foo.example.com") + req.Header.Set("x-forwarded-proto", "https") + req.Header.Set("x-forwarded-uri", "/foo?bar=foo") + router.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + }, + }, } store := memory.New() From 75c7aad40eff9776451bd1364028c31679c104d4 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 21 Aug 2026 13:10:15 +0300 Subject: [PATCH 02/10] fix: don't depend on auth modules failing for spoofing decision --- internal/controller/proxy_controller.go | 63 ++++++++++++++------ internal/controller/proxy_controller_test.go | 2 +- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 85f4fa92..57b56e4d 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -518,6 +518,39 @@ func (controller *ProxyController) getContextFromAuthModule(c *gin.Context, modu return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module) } +func (controller *ProxyController) authModuleIdentifiersPresent(c *gin.Context, module AuthModuleType) bool { + switch module { + case ForwardAuth: + _, host := controller.getHeader(c, "x-forwarded-host") + _, uri := controller.getHeader(c, "x-forwarded-uri") + return host || uri + case AuthRequest: + _, ok := controller.getHeader(c, "x-original-url") + return ok + case ExtAuthz: + return strings.TrimSpace(c.Query("path")) != "" + default: + return false + } +} + +func (controller *ProxyController) ensureNoMultipleAuthModules(c *gin.Context, authModules []AuthModuleType) error { + present := 0 + + for _, module := range authModules { + if controller.authModuleIdentifiersPresent(c, module) { + present++ + } + } + + if present > 1 { + controller.log.App.Warn().Msg("Request carries headers for multiple auth modules, possible spoofing attempt, denying") + return fmt.Errorf("conflicting auth module headers") + } + + return nil +} + func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) { var req Proxy @@ -540,32 +573,28 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy) } - ctxs := make(map[AuthModuleType]ProxyContext) + err = controller.ensureNoMultipleAuthModules(c, authModules) + + if err != nil { + return ProxyContext{}, err + } + + var ctx *ProxyContext for _, module := range authModules { controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module) - ctx, err := controller.getContextFromAuthModule(c, module) + authModuleCtx, err := controller.getContextFromAuthModule(c, module) if err != nil { controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err) continue } controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module) - ctxs[module] = ctx - } - - if len(ctxs) == 0 { - return ProxyContext{}, fmt.Errorf("no auth module context found") + ctx = &authModuleCtx + break } - if len(ctxs) > 1 { - controller.log.App.Warn().Msg("Multiple auth module contexts found, something is wrong in your proxy config or someone is trying to spoof the request, denying") - return ProxyContext{}, fmt.Errorf("multiple auth module contexts found") - } - - var ctx ProxyContext - - for _, c := range ctxs { - ctx = c + if ctx == nil { + return ProxyContext{}, fmt.Errorf("failed to get context from any auth module") } // Parse the raw path to populate the cleaned path used for ACLs @@ -593,5 +622,5 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext ctx.IsBrowser = isBrowser ctx.ProxyType = proxy - return ctx, nil + return *ctx, nil } diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index b63ced50..c7dee667 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -213,7 +213,7 @@ func TestProxyController(t *testing.T) { description: "Ensure forward auth fallback for envoy", middlewares: []gin.HandlerFunc{}, run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { - req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil) + req := httptest.NewRequest("HEAD", "/api/auth/envoy", nil) req.Host = "" req.Header.Set("x-forwarded-host", "test.example.com") req.Header.Set("x-forwarded-proto", "https") From cf5d5cab6e424b0b599ced2d816dd83ebb7ec419 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 21 Aug 2026 13:26:02 +0300 Subject: [PATCH 03/10] feat: add option to disable auth module fallbacks --- .env.example | 2 ++ internal/controller/proxy_controller.go | 15 +++++++++++++-- internal/model/config.go | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 770a7e97..abc91238 100644 --- a/.env.example +++ b/.env.example @@ -227,6 +227,8 @@ TINYAUTH_LDAP_GROUPCACHETTL=900 # Enable the OAuth bridge, uses a new way to format OAuth user information. TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false +# Disable the fallback to forward_auth modules when auth_request or ext_authz fail. +TINYAUTH_EXPERIMENTAL_DISABLEAUTHMODULEFALLBACK=false # tailscale config diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 57b56e4d..93f8d6a0 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -57,6 +57,7 @@ type ProxyContext struct { type ProxyController struct { log *logger.Logger runtime *model.RuntimeConfig + config *model.Config acls *service.AccessControlsService auth *service.AuthService policyEngine *service.PolicyEngine @@ -67,6 +68,7 @@ type ProxyControllerInput struct { Log *logger.Logger RuntimeConfig *model.RuntimeConfig + Config *model.Config RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` ACLsService *service.AccessControlsService AuthService *service.AuthService @@ -77,6 +79,7 @@ func NewProxyController(i ProxyControllerInput) *ProxyController { controller := &ProxyController{ log: i.Log, runtime: i.RuntimeConfig, + config: i.Config, acls: i.ACLsService, auth: i.AuthService, policyEngine: i.PolicyEngine, @@ -486,9 +489,17 @@ func (controller *ProxyController) determineAuthModules(proxy ProxyType) []AuthM case Traefik, Caddy: return []AuthModuleType{ForwardAuth} case Envoy: - return []AuthModuleType{ExtAuthz, ForwardAuth} + authModules := []AuthModuleType{ExtAuthz} + if !controller.config.Experimental.DisableAuthModuleFallback { + authModules = append(authModules, ForwardAuth) + } + return authModules case Nginx: - return []AuthModuleType{AuthRequest, ForwardAuth} + authModules := []AuthModuleType{AuthRequest} + if !controller.config.Experimental.DisableAuthModuleFallback { + authModules = append(authModules, ForwardAuth) + } + return authModules default: return []AuthModuleType{} } diff --git a/internal/model/config.go b/internal/model/config.go index 5b077fc5..55c66a38 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -239,7 +239,8 @@ type LogStreamConfig struct { } type ExperimentalConfig struct { - OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"` + OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"` + DisableAuthModuleFallback bool `description:"Disable the fallback to forward_auth modules when auth_request or ext_authz fail." yaml:"disableAuthModuleFallback,omitempty"` } type TailscaleConfig struct { From 4dc12c677c568ce5cacdf0637c8a31e2ffa461ba Mon Sep 17 00:00:00 2001 From: Stavros Date: Sat, 22 Aug 2026 19:59:53 +0300 Subject: [PATCH 04/10] fix: allow for undescores and leading hyphens in domain validator (#1088) --- pkg/validators/domain_validator.go | 10 +++++++++- pkg/validators/domain_validator_test.go | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/pkg/validators/domain_validator.go b/pkg/validators/domain_validator.go index d41d82b3..612c52ae 100644 --- a/pkg/validators/domain_validator.go +++ b/pkg/validators/domain_validator.go @@ -119,7 +119,15 @@ func (v *DomainValidator) getHostname(hostname string) (string, error) { if net.ParseIP(hostname) != nil { return "", fmt.Errorf("ip addresses are not supported") } - hostname, err := idna.Lookup.ToASCII(hostname) + i := idna.New( + idna.MapForLookup(), + idna.Transitional(false), + idna.BidiRule(), + idna.StrictDomainName(false), + idna.CheckHyphens(false), + idna.CheckJoiners(false), + ) + hostname, err := i.ToASCII(hostname) if err != nil { return "", fmt.Errorf("failed to convert hostname to ascii: %w", err) } diff --git a/pkg/validators/domain_validator_test.go b/pkg/validators/domain_validator_test.go index aa7587f1..0b3b81e1 100644 --- a/pkg/validators/domain_validator_test.go +++ b/pkg/validators/domain_validator_test.go @@ -50,6 +50,16 @@ func TestDomainValidator_SafeHostname(t *testing.T) { input: "https://example.com", expected: "example.com", }, + { + description: "Domain with underscores should pass", + input: "https://my_domain.com", + expected: "my_domain.com", + }, + { + description: "Domain with leading hyphen should pass", + input: "https://-my-domain.com", + expected: "-my-domain.com", + }, { description: "Domain without scheme should parse if scheme is disabled", input: "example.com", @@ -108,7 +118,7 @@ func TestDomainValidator_SafeHostname(t *testing.T) { }, { description: "Invalid IDNA domain should fail", - input: "ab--cd.example.com", + input: "xn--r-kva.example.com", errorFunc: func(t *testing.T, e error) { assert.ErrorContains(t, e, "invalid label") }, @@ -196,7 +206,7 @@ func TestDomainValidator_Validate(t *testing.T) { }, { description: "Failure to format expected domain should fail", - expected: "ab--cd.example.com", + expected: "xn--r-kva.example.com", actual: "example.com", errorFunc: func(t *testing.T, e error) { assert.ErrorContains(t, e, "idna: invalid label") @@ -205,7 +215,7 @@ func TestDomainValidator_Validate(t *testing.T) { { description: "Failure to format check domain should fail", expected: "example.com", - actual: "ab--cd.example.com", + actual: "xn--r-kva.example.com", errorFunc: func(t *testing.T, e error) { assert.ErrorContains(t, e, "idna: invalid label") }, From 61d372b2882fc4e6dc95ee01dd3fee3cd1b810a0 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sat, 22 Aug 2026 21:17:08 +0300 Subject: [PATCH 05/10] fix: don't use domain validator in acl matching logic --- internal/controller/oauth_controller.go | 4 +- internal/controller/proxy_controller_test.go | 1 + internal/service/access_controls_service.go | 41 +++++++++++++++---- .../service/access_controls_service_test.go | 33 +++++++-------- 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index fd6c2658..f48c3fde 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -294,7 +294,9 @@ func (controller *OAuthController) getCookieDomain() string { func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { v := validators.NewDomainValidator(validators.DomainValidatorOptions{ - WithPort: true, + WithPort: true, + WithScheme: true, + AllowedSchemes: []string{"https", "http"}, }) _, err := v.SafeHostname(controller.runtime.AppURL) diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index c7dee667..eff30935 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -978,6 +978,7 @@ func TestProxyController(t *testing.T) { NewProxyController(ProxyControllerInput{ Log: log, RuntimeConfig: &runtime, + Config: &cfg, RouterGroup: group, ACLsService: aclsService, AuthService: authService, diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index f8816a1f..6e9a3bb8 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -2,11 +2,13 @@ package service import ( "errors" + "fmt" + "net" "strings" + "unicode" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/utils/logger" - "github.com/tinyauthapp/tinyauth/pkg/validators" "go.uber.org/dig" ) @@ -37,8 +39,29 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic } } +func (service *AccessControlsService) ensureAscii(str string) bool { + for i := 0; i < len(str); i++ { + if str[i] > unicode.MaxASCII { + return false + } + } + return true +} + +func (service *AccessControlsService) normalizeDomain(domain string) string { + if host, _, err := net.SplitHostPort(domain); err == nil { + domain = host + } + domain = strings.TrimRight(domain, ".") + return strings.ToLower(domain) +} + func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) { - v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) + if !service.ensureAscii(domain) { + return nil, errors.New("domain contains non-ascii characters") + } + + normalizedDomain := service.normalizeDomain(domain) var domainMatch *model.App var nameMatch *model.App @@ -46,16 +69,18 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator locatorFunc := func(name string, app *model.App) bool { if app.Config.Domain != "" { - err := v.Validate(app.Config.Domain, domain) - if err == nil { + if !service.ensureAscii(app.Config.Domain) { + service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping") + return false + } + if normalizedDomain == service.normalizeDomain(app.Config.Domain) { service.log.App.Debug().Str("name", name).Msg("Found matching container by domain") domainMatch = app return true - } else if !errors.Is(err, validators.ErrHostnameMismatch) { - service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed") } + return false } - if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) { + if strings.HasPrefix(normalizedDomain, strings.ToLower(name+".")) { service.log.App.Debug().Str("name", name).Msg("Found matching container by app name") nameMatch = app nameMatchedApps = append(nameMatchedApps, name) @@ -79,7 +104,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator } if len(nameMatchedApps) > 1 { - service.log.App.Warn().Str("domain", domain).Strs("apps", nameMatchedApps).Msg("Multiple apps matched domain by name, app names must be unique, using last match") + return nil, fmt.Errorf("multiple apps matched domain by name, app names must be unique") } service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name") diff --git a/internal/service/access_controls_service_test.go b/internal/service/access_controls_service_test.go index 30415933..846d6a9f 100644 --- a/internal/service/access_controls_service_test.go +++ b/internal/service/access_controls_service_test.go @@ -4,6 +4,7 @@ import ( "errors" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/utils/logger" @@ -35,10 +36,11 @@ func TestAccessControlsService(t *testing.T) { log.Init() tests := []struct { - name string - domain string - acls map[string]model.App - want *model.App + name string + domain string + acls map[string]model.App + want *model.App + errorFunc func(t *testing.T, e error) }{ { name: "returns ACLs for domain", @@ -65,20 +67,11 @@ func TestAccessControlsService(t *testing.T) { want: &model.App{Config: model.AppConfig{Domain: "example.com"}}, }, { - name: "returns ACLs for non-ascii domain", + name: "returns error for non-ascii domain", domain: "bücher.example.com", - acls: map[string]model.App{ - "foo": {Config: model.AppConfig{Domain: "bücher.example.com"}}, - }, - want: &model.App{Config: model.AppConfig{Domain: "bücher.example.com"}}, - }, - { - name: "returns ACLs for punycode domain and non-ascii config", - domain: "bücher.example.com", - acls: map[string]model.App{ - "foo": {Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}}, + errorFunc: func(t *testing.T, e error) { + assert.ErrorContains(t, e, "domain contains non-ascii characters") }, - want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}}, }, { name: "returns ACLs with case-insensitive matching", @@ -122,6 +115,10 @@ func TestAccessControlsService(t *testing.T) { LabelProvider: mock, }) app, err := acls.getACLs(test.domain, mock.Lookup) + if test.errorFunc != nil { + test.errorFunc(t, err) + return + } require.NoError(t, err) require.Equal(t, test.want, app) }) @@ -137,6 +134,10 @@ func TestAccessControlsService(t *testing.T) { }, }) app, err := acls.lookupStaticACLs(test.domain) + if test.errorFunc != nil { + test.errorFunc(t, err) + return + } require.NoError(t, err) require.Equal(t, test.want, app) }) From 8614f8768b7fc0d7032e9befb425c185ace24171 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 23 Aug 2026 00:58:11 +0300 Subject: [PATCH 06/10] fix: no need for idna conversion in domain validator --- internal/utils/app_utils.go | 11 +++++- internal/utils/app_utils_test.go | 9 ++++- pkg/validators/domain_validator.go | 18 ++-------- pkg/validators/domain_validator_test.go | 48 ------------------------- 4 files changed, 20 insertions(+), 66 deletions(-) diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index 3bc3546a..7c168423 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -38,7 +38,16 @@ func SafeParseAppURL(str string) (string, error) { return "", fmt.Errorf("ip addresses not allowed") } - hostname, err = idna.Lookup.ToASCII(hostname) + i := idna.New( + idna.MapForLookup(), + idna.Transitional(false), + idna.BidiRule(), + idna.StrictDomainName(false), + idna.CheckHyphens(true), + idna.CheckJoiners(false), + ) + + hostname, err = i.ToASCII(hostname) if err != nil { return "", fmt.Errorf("failed to convert hostname to ascii: %w", err) diff --git a/internal/utils/app_utils_test.go b/internal/utils/app_utils_test.go index 8c9e9bc5..6dbe4492 100644 --- a/internal/utils/app_utils_test.go +++ b/internal/utils/app_utils_test.go @@ -43,6 +43,13 @@ func TestSafeParseAPPURL(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expected, result) + // Underscores + appURL = "http://sub_tinyauth.app" + expected = "http://sub_tinyauth.app" + result, err = utils.SafeParseAppURL(appURL) + assert.NoError(t, err) + assert.Equal(t, expected, result) + // Lowercase appURL = "HTTP://SUb.tinyAUth.aPP" expected = "http://sub.tinyauth.app" @@ -66,7 +73,7 @@ func TestSafeParseAPPURL(t *testing.T) { assert.ErrorContains(t, err, "invalid url") // Invalid punycode - appURL = "http://ab--cd.example.com" + appURL = "http://xn--h-kva.example.com" _, err = utils.SafeParseAppURL(appURL) assert.ErrorContains(t, err, "failed to convert hostname to ascii") diff --git a/pkg/validators/domain_validator.go b/pkg/validators/domain_validator.go index 612c52ae..cf2ce57c 100644 --- a/pkg/validators/domain_validator.go +++ b/pkg/validators/domain_validator.go @@ -11,8 +11,6 @@ import ( "net" "net/url" "strings" - - "golang.org/x/net/idna" ) // Errors @@ -114,23 +112,11 @@ func (v *DomainValidator) getURL(i string) (*url.URL, error) { } func (v *DomainValidator) getHostname(hostname string) (string, error) { - hostname = strings.ToLower(hostname) - hostname = strings.TrimSuffix(hostname, ".") if net.ParseIP(hostname) != nil { return "", fmt.Errorf("ip addresses are not supported") } - i := idna.New( - idna.MapForLookup(), - idna.Transitional(false), - idna.BidiRule(), - idna.StrictDomainName(false), - idna.CheckHyphens(false), - idna.CheckJoiners(false), - ) - hostname, err := i.ToASCII(hostname) - if err != nil { - return "", fmt.Errorf("failed to convert hostname to ascii: %w", err) - } + hostname = strings.ToLower(hostname) + hostname = strings.TrimSuffix(hostname, ".") return hostname, nil } diff --git a/pkg/validators/domain_validator_test.go b/pkg/validators/domain_validator_test.go index 0b3b81e1..b47c52ec 100644 --- a/pkg/validators/domain_validator_test.go +++ b/pkg/validators/domain_validator_test.go @@ -50,16 +50,6 @@ func TestDomainValidator_SafeHostname(t *testing.T) { input: "https://example.com", expected: "example.com", }, - { - description: "Domain with underscores should pass", - input: "https://my_domain.com", - expected: "my_domain.com", - }, - { - description: "Domain with leading hyphen should pass", - input: "https://-my-domain.com", - expected: "-my-domain.com", - }, { description: "Domain without scheme should parse if scheme is disabled", input: "example.com", @@ -111,18 +101,6 @@ func TestDomainValidator_SafeHostname(t *testing.T) { assert.ErrorContains(t, e, "ip addresses are not supported") }, }, - { - description: "Domains with unicode characters should be allowed", - input: "bücher.example.com", - expected: "xn--bcher-kva.example.com", - }, - { - description: "Invalid IDNA domain should fail", - input: "xn--r-kva.example.com", - errorFunc: func(t *testing.T, e error) { - assert.ErrorContains(t, e, "invalid label") - }, - }, { description: "With port enabled without any port should work", options: DomainValidatorOptions{WithPort: true}, @@ -204,22 +182,6 @@ func TestDomainValidator_Validate(t *testing.T) { expected: "https://example.com:443", actual: "https://example.com:443", }, - { - description: "Failure to format expected domain should fail", - expected: "xn--r-kva.example.com", - actual: "example.com", - errorFunc: func(t *testing.T, e error) { - assert.ErrorContains(t, e, "idna: invalid label") - }, - }, - { - description: "Failure to format check domain should fail", - expected: "example.com", - actual: "xn--r-kva.example.com", - errorFunc: func(t *testing.T, e error) { - assert.ErrorContains(t, e, "idna: invalid label") - }, - }, { description: "Valid domains with matching schemes and ports should pass", options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}, WithPort: true}, @@ -246,16 +208,6 @@ func TestDomainValidator_Validate(t *testing.T) { actual: "example.com", expected: "example.com", }, - { - description: "Unicode valid domains should pass", - expected: "xn--bcher-kva.example.com", - actual: "bücher.example.com", - }, - { - description: "Unicode valid domains should pass (reverse)", - expected: "bücher.example.com", - actual: "xn--bcher-kva.example.com", - }, { description: "Non matching hostnames should fail", expected: "example.com", From 9da7d3c7bea7703d09fccc207c9e5f9322d4f7b0 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 23 Aug 2026 12:18:18 +0300 Subject: [PATCH 07/10] fix: check for header spoofing regardless of fallbacks state --- internal/controller/proxy_controller.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go index 93f8d6a0..7349a2ca 100644 --- a/internal/controller/proxy_controller.go +++ b/internal/controller/proxy_controller.go @@ -484,19 +484,19 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont }, nil } -func (controller *ProxyController) determineAuthModules(proxy ProxyType) []AuthModuleType { +func (controller *ProxyController) determineAuthModules(proxy ProxyType, fallbacks bool) []AuthModuleType { switch proxy { case Traefik, Caddy: return []AuthModuleType{ForwardAuth} case Envoy: authModules := []AuthModuleType{ExtAuthz} - if !controller.config.Experimental.DisableAuthModuleFallback { + if fallbacks { authModules = append(authModules, ForwardAuth) } return authModules case Nginx: authModules := []AuthModuleType{AuthRequest} - if !controller.config.Experimental.DisableAuthModuleFallback { + if fallbacks { authModules = append(authModules, ForwardAuth) } return authModules @@ -578,13 +578,13 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext controller.log.App.Debug().Msgf("Determined proxy type: %v", proxy) - authModules := controller.determineAuthModules(proxy) + authModules := controller.determineAuthModules(proxy, !controller.config.Experimental.DisableAuthModuleFallback) if len(authModules) == 0 { return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy) } - err = controller.ensureNoMultipleAuthModules(c, authModules) + err = controller.ensureNoMultipleAuthModules(c, controller.determineAuthModules(proxy, true)) if err != nil { return ProxyContext{}, err From 447b8410ff5bb03eb69fc8718037d9592a2a31c1 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 23 Aug 2026 17:05:38 +0300 Subject: [PATCH 08/10] fix: verify domain in name matching --- internal/service/access_controls_service.go | 9 ++- .../service/access_controls_service_test.go | 67 ++++++++++++++++--- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 6e9a3bb8..6f58fa7f 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -19,6 +19,7 @@ type LabelProvider interface { type AccessControlsService struct { log *logger.Logger config *model.Config + runtime *model.RuntimeConfig labelProvider LabelProvider } @@ -27,6 +28,7 @@ type AccessControlServiceInput struct { Log *logger.Logger Config *model.Config + Runtime *model.RuntimeConfig LabelProvider LabelProvider `optional:"true"` } @@ -35,6 +37,7 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic return &AccessControlsService{ log: i.Log, config: i.Config, + runtime: i.Runtime, labelProvider: i.LabelProvider, } } @@ -68,6 +71,10 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator var nameMatchedApps []string locatorFunc := func(name string, app *model.App) bool { + if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain { + service.log.App.Debug().Str("name", name).Msg("Domain does not match runtime cookie domain, skipping") + return false + } if app.Config.Domain != "" { if !service.ensureAscii(app.Config.Domain) { service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping") @@ -104,7 +111,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator } if len(nameMatchedApps) > 1 { - return nil, fmt.Errorf("multiple apps matched domain by name, app names must be unique") + return nil, fmt.Errorf("domain matched multiple apps by name prefix, use explicit domain config") } service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name") diff --git a/internal/service/access_controls_service_test.go b/internal/service/access_controls_service_test.go index 846d6a9f..1e773a15 100644 --- a/internal/service/access_controls_service_test.go +++ b/internal/service/access_controls_service_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/utils/logger" ) @@ -35,6 +36,8 @@ func TestAccessControlsService(t *testing.T) { log := logger.NewLogger().WithTestConfig() log.Init() + _, runtime := test.CreateTestConfigs(t) + tests := []struct { name string domain string @@ -44,6 +47,14 @@ func TestAccessControlsService(t *testing.T) { }{ { name: "returns ACLs for domain", + domain: "app.example.com", + acls: map[string]model.App{ + "foo": {Config: model.AppConfig{Domain: "app.example.com"}}, + }, + want: &model.App{Config: model.AppConfig{Domain: "app.example.com"}}, + }, + { + name: "returns ACLs for root domain", domain: "example.com", acls: map[string]model.App{ "foo": {Config: model.AppConfig{Domain: "example.com"}}, @@ -103,6 +114,27 @@ func TestAccessControlsService(t *testing.T) { acls: map[string]model.App{}, want: nil, }, + { + name: "App in domain not matching with the cookie domain should return nothing with name matching", + domain: "foo.bad_example.com", + acls: map[string]model.App{ + "foo": { + Path: model.AppPath{Allow: "/foo"}, + }, + }, + want: nil, + }, + { + name: "App in domain not matching with the cookie domain should return nothing with domain matching", + domain: "foo.bad_example.com", + acls: map[string]model.App{ + "foo": { + Path: model.AppPath{Allow: "/foo"}, + Config: model.AppConfig{Domain: "foo.bad_example.com"}, + }, + }, + want: nil, + }, } // run once for a mock provider @@ -111,6 +143,7 @@ func TestAccessControlsService(t *testing.T) { mock := newMockProvider(test.acls, false) acls := NewAccessControlsService(AccessControlServiceInput{ Log: log, + Runtime: &runtime, Config: &model.Config{}, LabelProvider: mock, }) @@ -128,7 +161,8 @@ func TestAccessControlsService(t *testing.T) { for _, test := range tests { t.Run(test.name+"(staticACLs)", func(t *testing.T) { acls := NewAccessControlsService(AccessControlServiceInput{ - Log: log, + Log: log, + Runtime: &runtime, Config: &model.Config{ Apps: test.acls, }, @@ -146,16 +180,32 @@ func TestAccessControlsService(t *testing.T) { // get acls should return an error when the provider fails mock := newMockProvider(map[string]model.App{}, true) acls := NewAccessControlsService(AccessControlServiceInput{ - Log: log, - Config: &model.Config{}, + Log: log, + Runtime: &runtime, + Config: &model.Config{}, }) _, err := acls.getACLs("example.com", mock.Lookup) - require.Error(t, err) + assert.Error(t, err) + + // get acls should return an error when multiple apps with the same domain exist + acls = NewAccessControlsService(AccessControlServiceInput{ + Log: log, + Runtime: &runtime, + Config: &model.Config{ + Apps: map[string]model.App{ + "foo": {Path: model.AppPath{Allow: "/foo"}}, + "foo.bar": {Path: model.AppPath{Allow: "/bar"}}, + }, + }, + }) + _, err = acls.GetAccessControls("foo.bar.example.com") + assert.ErrorContains(t, err, "domain matched multiple apps by name prefix, use explicit domain config") // get access controls should get acls from // static when static acls are configured acls = NewAccessControlsService(AccessControlServiceInput{ - Log: log, + Log: log, + Runtime: &runtime, Config: &model.Config{ Apps: map[string]model.App{ "foo": {Config: model.AppConfig{Domain: "foo.example.com"}}, @@ -164,12 +214,12 @@ func TestAccessControlsService(t *testing.T) { }) app, err := acls.GetAccessControls("foo.example.com") require.NoError(t, err) - require.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app) + assert.Equal(t, &model.App{Config: model.AppConfig{Domain: "foo.example.com"}}, app) // should return nil for no apps app, err = acls.GetAccessControls("bar.example.com") require.NoError(t, err) - require.Nil(t, app) + assert.Nil(t, app) // Should use label provider if available mock = newMockProvider(map[string]model.App{ @@ -179,10 +229,11 @@ func TestAccessControlsService(t *testing.T) { }, false) acls = NewAccessControlsService(AccessControlServiceInput{ Log: log, + Runtime: &runtime, Config: &model.Config{}, LabelProvider: mock, }) app, err = acls.GetAccessControls("bar.example.com") require.NoError(t, err) - require.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app) + assert.Equal(t, &model.App{Config: model.AppConfig{Domain: "bar.example.com"}}, app) } From 1f1abbf64d38ce1c85060fcad66eea15cb12de39 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 23 Aug 2026 17:37:46 +0300 Subject: [PATCH 09/10] fix: fail acl lookup when input domain doesn't match cookie domain --- internal/controller/proxy_controller_test.go | 1 + internal/service/access_controls_service.go | 8 ++++---- internal/service/access_controls_service_test.go | 6 ++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index eff30935..fd06ae39 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -918,6 +918,7 @@ func TestProxyController(t *testing.T) { aclsService := service.NewAccessControlsService(service.AccessControlServiceInput{ Log: log, Config: &cfg, + Runtime: &runtime, LabelProvider: nil, }) diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 6f58fa7f..922926bd 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -66,15 +66,15 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator normalizedDomain := service.normalizeDomain(domain) + if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain { + return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain) + } + var domainMatch *model.App var nameMatch *model.App var nameMatchedApps []string locatorFunc := func(name string, app *model.App) bool { - if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain { - service.log.App.Debug().Str("name", name).Msg("Domain does not match runtime cookie domain, skipping") - return false - } if app.Config.Domain != "" { if !service.ensureAscii(app.Config.Domain) { service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping") diff --git a/internal/service/access_controls_service_test.go b/internal/service/access_controls_service_test.go index 1e773a15..c5d00b7c 100644 --- a/internal/service/access_controls_service_test.go +++ b/internal/service/access_controls_service_test.go @@ -123,6 +123,9 @@ func TestAccessControlsService(t *testing.T) { }, }, want: nil, + errorFunc: func(t *testing.T, e error) { + assert.ErrorContains(t, e, "domain does not match cookie domain") + }, }, { name: "App in domain not matching with the cookie domain should return nothing with domain matching", @@ -134,6 +137,9 @@ func TestAccessControlsService(t *testing.T) { }, }, want: nil, + errorFunc: func(t *testing.T, e error) { + assert.ErrorContains(t, e, "domain does not match cookie domain") + }, }, } From 19b851ad5320ba423efc22a4e8d21e0fff7f81a6 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 24 Aug 2026 13:16:55 +0300 Subject: [PATCH 10/10] fix: rabbit comments --- pkg/validators/domain_validator.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/validators/domain_validator.go b/pkg/validators/domain_validator.go index cf2ce57c..9d0503f8 100644 --- a/pkg/validators/domain_validator.go +++ b/pkg/validators/domain_validator.go @@ -112,11 +112,11 @@ func (v *DomainValidator) getURL(i string) (*url.URL, error) { } func (v *DomainValidator) getHostname(hostname string) (string, error) { + hostname = strings.ToLower(hostname) + hostname = strings.TrimRight(hostname, ".") if net.ParseIP(hostname) != nil { return "", fmt.Errorf("ip addresses are not supported") } - hostname = strings.ToLower(hostname) - hostname = strings.TrimSuffix(hostname, ".") return hostname, nil }