Compare commits

...
5 Commits
9 changed files with 178 additions and 46 deletions
+2
View File
@@ -227,6 +227,8 @@ TINYAUTH_LDAP_GROUPCACHETTL=900
# Enable the OAuth bridge, uses a new way to format OAuth user information. # Enable the OAuth bridge, uses a new way to format OAuth user information.
TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false TINYAUTH_EXPERIMENTAL_OAUTHBRIDGEENABLED=false
# Disable the fallback to forward_auth modules when auth_request or ext_authz fail.
TINYAUTH_EXPERIMENTAL_DISABLEAUTHMODULEFALLBACK=false
# tailscale config # tailscale config
+3 -1
View File
@@ -294,7 +294,9 @@ func (controller *OAuthController) getCookieDomain() string {
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{ v := validators.NewDomainValidator(validators.DomainValidatorOptions{
WithPort: true, WithPort: true,
WithScheme: true,
AllowedSchemes: []string{"https", "http"},
}) })
_, err := v.SafeHostname(controller.runtime.AppURL) _, err := v.SafeHostname(controller.runtime.AppURL)
+70 -14
View File
@@ -57,6 +57,7 @@ type ProxyContext struct {
type ProxyController struct { type ProxyController struct {
log *logger.Logger log *logger.Logger
runtime *model.RuntimeConfig runtime *model.RuntimeConfig
config *model.Config
acls *service.AccessControlsService acls *service.AccessControlsService
auth *service.AuthService auth *service.AuthService
policyEngine *service.PolicyEngine policyEngine *service.PolicyEngine
@@ -67,6 +68,7 @@ type ProxyControllerInput struct {
Log *logger.Logger Log *logger.Logger
RuntimeConfig *model.RuntimeConfig RuntimeConfig *model.RuntimeConfig
Config *model.Config
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
ACLsService *service.AccessControlsService ACLsService *service.AccessControlsService
AuthService *service.AuthService AuthService *service.AuthService
@@ -77,6 +79,7 @@ func NewProxyController(i ProxyControllerInput) *ProxyController {
controller := &ProxyController{ controller := &ProxyController{
log: i.Log, log: i.Log,
runtime: i.RuntimeConfig, runtime: i.RuntimeConfig,
config: i.Config,
acls: i.ACLsService, acls: i.ACLsService,
auth: i.AuthService, auth: i.AuthService,
policyEngine: i.PolicyEngine, policyEngine: i.PolicyEngine,
@@ -465,6 +468,10 @@ func (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyCont
// We get the path from the query string // We get the path from the query string
path := c.Query("path") path := c.Query("path")
if strings.TrimSpace(path) == "" {
return ProxyContext{}, errors.New("path not found")
}
// For envoy we need to support every method // For envoy we need to support every method
method := c.Request.Method method := c.Request.Method
@@ -482,9 +489,17 @@ func (controller *ProxyController) determineAuthModules(proxy ProxyType) []AuthM
case Traefik, Caddy: case Traefik, Caddy:
return []AuthModuleType{ForwardAuth} return []AuthModuleType{ForwardAuth}
case Envoy: case Envoy:
return []AuthModuleType{ExtAuthz, ForwardAuth} authModules := []AuthModuleType{ExtAuthz}
if !controller.config.Experimental.DisableAuthModuleFallback {
authModules = append(authModules, ForwardAuth)
}
return authModules
case Nginx: case Nginx:
return []AuthModuleType{AuthRequest, ForwardAuth} authModules := []AuthModuleType{AuthRequest}
if !controller.config.Experimental.DisableAuthModuleFallback {
authModules = append(authModules, ForwardAuth)
}
return authModules
default: default:
return []AuthModuleType{} return []AuthModuleType{}
} }
@@ -514,6 +529,39 @@ func (controller *ProxyController) getContextFromAuthModule(c *gin.Context, modu
return ProxyContext{}, fmt.Errorf("unsupported auth module: %v", module) 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) { func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) {
var req Proxy var req Proxy
@@ -536,22 +584,30 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy) return ProxyContext{}, fmt.Errorf("no auth modules supported for proxy: %v", req.Proxy)
} }
var ctx ProxyContext err = controller.ensureNoMultipleAuthModules(c, authModules)
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
}
controller.log.App.Debug().Msgf("Failed to get context from auth module %v: %v", module, err)
}
if err != nil { if err != nil {
return ProxyContext{}, err return ProxyContext{}, err
} }
var ctx *ProxyContext
for _, module := range authModules {
controller.log.App.Debug().Msgf("Trying to get context from auth module %v", 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)
ctx = &authModuleCtx
break
}
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 // Parse the raw path to populate the cleaned path used for ACLs
upath, err := url.Parse(ctx.PathRaw) upath, err := url.Parse(ctx.PathRaw)
@@ -577,5 +633,5 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
ctx.IsBrowser = isBrowser ctx.IsBrowser = isBrowser
ctx.ProxyType = proxy ctx.ProxyType = proxy
return ctx, nil return *ctx, nil
} }
+29 -2
View File
@@ -213,7 +213,7 @@ func TestProxyController(t *testing.T) {
description: "Ensure forward auth fallback for envoy", description: "Ensure forward auth fallback for envoy",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { 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.Host = ""
req.Header.Set("x-forwarded-host", "test.example.com") req.Header.Set("x-forwarded-host", "test.example.com")
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
@@ -261,7 +261,7 @@ func TestProxyController(t *testing.T) {
description: "Ensure extauthz with envoy non browser returns json", description: "Ensure extauthz with envoy non browser returns json",
middlewares: []gin.HandlerFunc{}, middlewares: []gin.HandlerFunc{},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) { 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-host", "test.example.com")
req.Header.Set("x-forwarded-proto", "https") req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/hello") 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")) 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() store := memory.New()
@@ -952,6 +978,7 @@ func TestProxyController(t *testing.T) {
NewProxyController(ProxyControllerInput{ NewProxyController(ProxyControllerInput{
Log: log, Log: log,
RuntimeConfig: &runtime, RuntimeConfig: &runtime,
Config: &cfg,
RouterGroup: group, RouterGroup: group,
ACLsService: aclsService, ACLsService: aclsService,
AuthService: authService, AuthService: authService,
+2 -1
View File
@@ -239,7 +239,8 @@ type LogStreamConfig struct {
} }
type ExperimentalConfig 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 { type TailscaleConfig struct {
+33 -8
View File
@@ -2,11 +2,13 @@ package service
import ( import (
"errors" "errors"
"fmt"
"net"
"strings" "strings"
"unicode"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig" "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) { 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 domainMatch *model.App
var nameMatch *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 { locatorFunc := func(name string, app *model.App) bool {
if app.Config.Domain != "" { if app.Config.Domain != "" {
err := v.Validate(app.Config.Domain, domain) if !service.ensureAscii(app.Config.Domain) {
if err == nil { 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") service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
domainMatch = app domainMatch = app
return true 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") service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
nameMatch = app nameMatch = app
nameMatchedApps = append(nameMatchedApps, name) nameMatchedApps = append(nameMatchedApps, name)
@@ -79,7 +104,7 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
} }
if len(nameMatchedApps) > 1 { 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") service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
@@ -35,10 +36,11 @@ func TestAccessControlsService(t *testing.T) {
log.Init() log.Init()
tests := []struct { tests := []struct {
name string name string
domain string domain string
acls map[string]model.App acls map[string]model.App
want *model.App want *model.App
errorFunc func(t *testing.T, e error)
}{ }{
{ {
name: "returns ACLs for domain", name: "returns ACLs for domain",
@@ -65,20 +67,11 @@ func TestAccessControlsService(t *testing.T) {
want: &model.App{Config: model.AppConfig{Domain: "example.com"}}, 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", domain: "bücher.example.com",
acls: map[string]model.App{ errorFunc: func(t *testing.T, e error) {
"foo": {Config: model.AppConfig{Domain: "bücher.example.com"}}, assert.ErrorContains(t, e, "domain contains non-ascii characters")
}, },
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"}},
},
want: &model.App{Config: model.AppConfig{Domain: "xn--bcher-kva.example.com"}},
}, },
{ {
name: "returns ACLs with case-insensitive matching", name: "returns ACLs with case-insensitive matching",
@@ -122,6 +115,10 @@ func TestAccessControlsService(t *testing.T) {
LabelProvider: mock, LabelProvider: mock,
}) })
app, err := acls.getACLs(test.domain, mock.Lookup) app, err := acls.getACLs(test.domain, mock.Lookup)
if test.errorFunc != nil {
test.errorFunc(t, err)
return
}
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, test.want, app) require.Equal(t, test.want, app)
}) })
@@ -137,6 +134,10 @@ func TestAccessControlsService(t *testing.T) {
}, },
}) })
app, err := acls.lookupStaticACLs(test.domain) app, err := acls.lookupStaticACLs(test.domain)
if test.errorFunc != nil {
test.errorFunc(t, err)
return
}
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, test.want, app) require.Equal(t, test.want, app)
}) })
+9 -1
View File
@@ -119,7 +119,15 @@ func (v *DomainValidator) getHostname(hostname string) (string, error) {
if net.ParseIP(hostname) != nil { if net.ParseIP(hostname) != nil {
return "", fmt.Errorf("ip addresses are not supported") 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 { if err != nil {
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err) return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
} }
+13 -3
View File
@@ -50,6 +50,16 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
input: "https://example.com", input: "https://example.com",
expected: "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", description: "Domain without scheme should parse if scheme is disabled",
input: "example.com", input: "example.com",
@@ -108,7 +118,7 @@ func TestDomainValidator_SafeHostname(t *testing.T) {
}, },
{ {
description: "Invalid IDNA domain should fail", description: "Invalid IDNA domain should fail",
input: "ab--cd.example.com", input: "xn--r-kva.example.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "invalid label") assert.ErrorContains(t, e, "invalid label")
}, },
@@ -196,7 +206,7 @@ func TestDomainValidator_Validate(t *testing.T) {
}, },
{ {
description: "Failure to format expected domain should fail", description: "Failure to format expected domain should fail",
expected: "ab--cd.example.com", expected: "xn--r-kva.example.com",
actual: "example.com", actual: "example.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "idna: invalid label") 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", description: "Failure to format check domain should fail",
expected: "example.com", expected: "example.com",
actual: "ab--cd.example.com", actual: "xn--r-kva.example.com",
errorFunc: func(t *testing.T, e error) { errorFunc: func(t *testing.T, e error) {
assert.ErrorContains(t, e, "idna: invalid label") assert.ErrorContains(t, e, "idna: invalid label")
}, },