Skip to content

Commit 847d832

Browse files
authored
fix: auth module selection (#1089)
1 parent be48d71 commit 847d832

11 files changed

Lines changed: 247 additions & 96 deletions

‎.env.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ TINYAUTH_LDAP_GROUPCACHETTL=900
235235

236236
# Enable the OAuth bridge, uses a new way to format OAuth user information.
237237
TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false
238+
# Disable the fallback to forward_auth modules when auth_request or ext_authz fail.
239+
TINYAUTH_EXPERIMENTAL_DISABLEAUTHMODULEFALLBACK=false
238240

239241
# tailscale config
240242

‎internal/controller/oauth_controller.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,9 @@ func (controller *OAuthController) getCookieDomain() string {
294294

295295
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
296296
v := validators.NewDomainValidator(validators.DomainValidatorOptions{
297-
WithPort: true,
297+
WithPort: true,
298+
WithScheme: true,
299+
AllowedSchemes: []string{"https", "http"},
298300
})
299301

300302
_, err := v.SafeHostname(controller.runtime.AppURL)

‎internal/controller/proxy_controller.go‎

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type ProxyContext struct {
5757
type ProxyController struct {
5858
log *logger.Logger
5959
runtime *model.RuntimeConfig
60+
config *model.Config
6061
acls *service.AccessControlsService
6162
auth *service.AuthService
6263
policyEngine *service.PolicyEngine
@@ -67,6 +68,7 @@ type ProxyControllerInput struct {
6768

6869
Log *logger.Logger
6970
RuntimeConfig *model.RuntimeConfig
71+
Config *model.Config
7072
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
7173
ACLsService *service.AccessControlsService
7274
AuthService *service.AuthService
@@ -77,6 +79,7 @@ func NewProxyController(i ProxyControllerInput) *ProxyController {
7779
controller := &ProxyController{
7880
log: i.Log,
7981
runtime: i.RuntimeConfig,
82+
config: i.Config,
8083
acls: i.ACLsService,
8184
auth: i.AuthService,
8285
policyEngine: i.PolicyEngine,
@@ -465,6 +468,10 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
465468
// We get the path from the query string
466469
path := c.Query("path")
467470

471+
if strings.TrimSpace(path) == "" {
472+
return ProxyContext{}, errors.New("path not found")
473+
}
474+
468475
// For envoy we need to support every method
469476
method := c.Request.Method
470477

@@ -477,14 +484,22 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
477484
}, nil
478485
}
479486

480-
func (controller *ProxyController) determineAuthModules(proxy ProxyType) []AuthModuleType {
487+
func (controller *ProxyController) determineAuthModules(proxy ProxyType, fallbacks bool) []AuthModuleType {
481488
switch proxy {
482489
case Traefik, Caddy:
483490
return []AuthModuleType{ForwardAuth}
484491
case Envoy:
485-
return []AuthModuleType{ExtAuthz, ForwardAuth}
492+
authModules := []AuthModuleType{ExtAuthz}
493+
if fallbacks {
494+
authModules = append(authModules, ForwardAuth)
495+
}
496+
return authModules
486497
case Nginx:
487-
return []AuthModuleType{AuthRequest, ForwardAuth}
498+
authModules := []AuthModuleType{AuthRequest}
499+
if fallbacks {
500+
authModules = append(authModules, ForwardAuth)
501+
}
502+
return authModules
488503
default:
489504
return []AuthModuleType{}
490505
}
@@ -514,6 +529,39 @@ func (controller *ProxyController) getContextFromAuthModule(c *gin.Context, modu
514529
return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module)
515530
}
516531

532+
func (controller *ProxyController) authModuleIdentifiersPresent(c *gin.Context, module AuthModuleType) bool {
533+
switch module {
534+
case ForwardAuth:
535+
_, host := controller.getHeader(c, "x-forwarded-host")
536+
_, uri := controller.getHeader(c, "x-forwarded-uri")
537+
return host || uri
538+
case AuthRequest:
539+
_, ok := controller.getHeader(c, "x-original-url")
540+
return ok
541+
case ExtAuthz:
542+
return strings.TrimSpace(c.Query("path")) != ""
543+
default:
544+
return false
545+
}
546+
}
547+
548+
func (controller *ProxyController) ensureNoMultipleAuthModules(c *gin.Context, authModules []AuthModuleType) error {
549+
present := 0
550+
551+
for _, module := range authModules {
552+
if controller.authModuleIdentifiersPresent(c, module) {
553+
present++
554+
}
555+
}
556+
557+
if present > 1 {
558+
controller.log.App.Warn().Msg("Request carries headers for multiple auth modules, possible spoofing attempt, denying")
559+
return fmt.Errorf("conflicting auth module headers")
560+
}
561+
562+
return nil
563+
}
564+
517565
func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) {
518566
var req Proxy
519567

@@ -530,26 +578,34 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
530578

531579
controller.log.App.Debug().Msgf("Determined proxy type: %v", proxy)
532580

533-
authModules := controller.determineAuthModules(proxy)
581+
authModules := controller.determineAuthModules(proxy, !controller.config.Experimental.DisableAuthModuleFallback)
534582

535583
if len(authModules) == 0 {
536584
return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy)
537585
}
538586

539-
var ctx ProxyContext
587+
err = controller.ensureNoMultipleAuthModules(c, controller.determineAuthModules(proxy, true))
588+
589+
if err != nil {
590+
return ProxyContext{}, err
591+
}
592+
593+
var ctx *ProxyContext
540594

541595
for _, module := range authModules {
542596
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", module)
543-
ctx, err = controller.getContextFromAuthModule(c, module)
544-
if err == nil {
545-
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
546-
break
597+
authModuleCtx, err := controller.getContextFromAuthModule(c, module)
598+
if err != nil {
599+
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
600+
continue
547601
}
548-
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
602+
controller.log.App.Debug().Msgf("Successfully got context from auth module %v", module)
603+
ctx = &authModuleCtx
604+
break
549605
}
550606

551-
if err != nil {
552-
return ProxyContext{}, err
607+
if ctx == nil {
608+
return ProxyContext{}, fmt.Errorf("failed to get context from any auth module")
553609
}
554610

555611
// Parse the raw path to populate the cleaned path used for ACLs
@@ -577,5 +633,5 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
577633

578634
ctx.IsBrowser = isBrowser
579635
ctx.ProxyType = proxy
580-
return ctx, nil
636+
return *ctx, nil
581637
}

‎internal/controller/proxy_controller_test.go‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ func TestProxyController(t *testing.T) {
213213
description: "Ensure forward auth fallback for envoy",
214214
middlewares: []gin.HandlerFunc{},
215215
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
216-
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
216+
req := httptest.NewRequest("HEAD", "/api/auth/envoy", nil)
217217
req.Host = ""
218218
req.Header.Set("x-forwarded-host", "test.example.com")
219219
req.Header.Set("x-forwarded-proto", "https")
@@ -261,7 +261,7 @@ func TestProxyController(t *testing.T) {
261261
description: "Ensure extauthz with envoy non browser returns json",
262262
middlewares: []gin.HandlerFunc{},
263263
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
264-
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
264+
req := httptest.NewRequest("HEAD", "/api/auth/envoy", nil)
265265
req.Header.Set("x-forwarded-host", "test.example.com")
266266
req.Header.Set("x-forwarded-proto", "https")
267267
req.Header.Set("x-forwarded-uri", "/hello")
@@ -877,6 +877,32 @@ func TestProxyController(t *testing.T) {
877877
assert.Equal(t, "bar", recorder.Header().Get("x-foo"))
878878
},
879879
},
880+
{
881+
description: "Forward auth and auth request headers should fail for nginx",
882+
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
883+
req := httptest.NewRequest("GET", "/api/auth/nginx", nil)
884+
req.Header.Set("x-forwarded-host", "foo.example.com")
885+
req.Header.Set("x-forwarded-proto", "https")
886+
req.Header.Set("x-forwarded-uri", "/foo?bar=foo")
887+
req.Header.Set("x-original-url", "https://foo.example.com/foo?bar=foo")
888+
router.ServeHTTP(recorder, req)
889+
890+
assert.Equal(t, http.StatusBadRequest, recorder.Code)
891+
},
892+
},
893+
{
894+
description: "Forward auth and ext authz headers should fail for envoy",
895+
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
896+
req := httptest.NewRequest("HEAD", "/api/auth/envoy?path=/hello", nil)
897+
req.Host = "foo.example.com"
898+
req.Header.Set("x-forwarded-host", "foo.example.com")
899+
req.Header.Set("x-forwarded-proto", "https")
900+
req.Header.Set("x-forwarded-uri", "/foo?bar=foo")
901+
router.ServeHTTP(recorder, req)
902+
903+
assert.Equal(t, http.StatusBadRequest, recorder.Code)
904+
},
905+
},
880906
}
881907

882908
store := memory.New()
@@ -892,6 +918,7 @@ func TestProxyController(t *testing.T) {
892918
aclsService := service.NewAccessControlsService(service.AccessControlServiceInput{
893919
Log: log,
894920
Config: &cfg,
921+
Runtime: &runtime,
895922
LabelProvider: nil,
896923
})
897924

@@ -952,6 +979,7 @@ func TestProxyController(t *testing.T) {
952979
NewProxyController(ProxyControllerInput{
953980
Log: log,
954981
RuntimeConfig: &runtime,
982+
Config: &cfg,
955983
RouterGroup: group,
956984
ACLsService: aclsService,
957985
AuthService: authService,

‎internal/model/config.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@ type LogStreamConfig struct {
239239
}
240240

241241
type ExperimentalConfig struct {
242-
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
242+
OAuthBridgeEnabled bool `description:"Enable the OAuth bridge, uses a new way to format OAuth user information." yaml:"oauthBridgeEnabled,omitempty"`
243+
DisableAuthModuleFallback bool `description:"Disable the fallback to forward_auth modules when auth_request or ext_authz fail." yaml:"disableAuthModuleFallback,omitempty"`
243244
}
244245

245246
type TailscaleConfig struct {

‎internal/service/access_controls_service.go‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package service
22

33
import (
44
"errors"
5+
"fmt"
6+
"net"
57
"strings"
8+
"unicode"
69

710
"github.com/tinyauthapp/tinyauth/internal/model"
811
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
9-
"github.com/tinyauthapp/tinyauth/pkg/validators"
1012
"go.uber.org/dig"
1113
)
1214

@@ -17,6 +19,7 @@ type LabelProvider interface {
1719
type AccessControlsService struct {
1820
log *logger.Logger
1921
config *model.Config
22+
runtime *model.RuntimeConfig
2023
labelProvider LabelProvider
2124
}
2225

@@ -25,6 +28,7 @@ type AccessControlServiceInput struct {
2528

2629
Log *logger.Logger
2730
Config *model.Config
31+
Runtime *model.RuntimeConfig
2832
LabelProvider LabelProvider `optional:"true"`
2933
}
3034

@@ -33,29 +37,57 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
3337
return &AccessControlsService{
3438
log: i.Log,
3539
config: i.Config,
40+
runtime: i.Runtime,
3641
labelProvider: i.LabelProvider,
3742
}
3843
}
3944

45+
func (service *AccessControlsService) ensureAscii(str string) bool {
46+
for i := 0; i < len(str); i++ {
47+
if str[i] > unicode.MaxASCII {
48+
return false
49+
}
50+
}
51+
return true
52+
}
53+
54+
func (service *AccessControlsService) normalizeDomain(domain string) string {
55+
if host, _, err := net.SplitHostPort(domain); err == nil {
56+
domain = host
57+
}
58+
domain = strings.TrimRight(domain, ".")
59+
return strings.ToLower(domain)
60+
}
61+
4062
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
41-
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
63+
if !service.ensureAscii(domain) {
64+
return nil, errors.New("domain contains non-ascii characters")
65+
}
66+
67+
normalizedDomain := service.normalizeDomain(domain)
68+
69+
if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain {
70+
return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain)
71+
}
4272

4373
var domainMatch *model.App
4474
var nameMatch *model.App
4575
var nameMatchedApps []string
4676

4777
locatorFunc := func(name string, app *model.App) bool {
4878
if app.Config.Domain != "" {
49-
err := v.Validate(app.Config.Domain, domain)
50-
if err == nil {
79+
if !service.ensureAscii(app.Config.Domain) {
80+
service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping")
81+
return false
82+
}
83+
if normalizedDomain == service.normalizeDomain(app.Config.Domain) {
5184
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
5285
domainMatch = app
5386
return true
54-
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
55-
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
5687
}
88+
return false
5789
}
58-
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
90+
if strings.HasPrefix(normalizedDomain, strings.ToLower(name+".")) {
5991
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
6092
nameMatch = app
6193
nameMatchedApps = append(nameMatchedApps, name)
@@ -79,7 +111,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
79111
}
80112

81113
if len(nameMatchedApps) > 1 {
82-
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")
114+
return nil, fmt.Errorf("domain matched multiple apps by name prefix, use explicit domain config")
83115
}
84116

85117
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")

0 commit comments

Comments
 (0)