feat: adopt domain validator in oauth controller and acls service

This commit is contained in:
Stavros
2026-07-14 00:37:22 +03:00
parent 3008ffad34
commit 0c1cc98fd3
5 changed files with 41 additions and 79 deletions
+28 -39
View File
@@ -3,7 +3,6 @@ package controller
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -12,6 +11,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"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"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -311,57 +311,46 @@ func (controller *OAuthController) getCookieDomain() string {
} }
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
u, err := url.Parse(redirectURI) v := validators.NewDomainValidator(validators.DomainValidatorOptions{
WithScheme: true,
WithPort: true,
})
_, err := v.SafeHostname(controller.runtime.AppURL)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse redirect URI") controller.log.App.Error().Err(err).Msg("App URL is invalid, cannot validate redirect URI")
return false return false
} }
if u.Scheme == "" || u.Host == "" { err = v.Validate(redirectURI, controller.runtime.AppURL)
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
return false
}
au, err := url.Parse(controller.runtime.AppURL) if err == nil {
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
return false
}
if u.Scheme != au.Scheme {
controller.log.App.Warn().Msg("Redirect URI scheme does not match app URL scheme")
return false
}
getEffectivePort := func(u *url.URL) string {
if u.Port() != "" {
return u.Port()
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
if getEffectivePort(u) != getEffectivePort(au) {
controller.log.App.Warn().Msg("Redirect URI port does not match app URL port")
return false
}
nu := strings.TrimSuffix(u.Hostname(), ".")
nau := strings.TrimSuffix(au.Hostname(), ".")
if strings.EqualFold(nu, nau) {
return true return true
} }
controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI")
if strings.HasPrefix(err.Error(), "expected port") ||
strings.HasPrefix(err.Error(), "expected scheme") ||
err.Error() == "input url is invalid" {
return false
}
if !controller.config.Auth.SubdomainsEnabled { if !controller.config.Auth.SubdomainsEnabled {
return false return false
} }
if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) { v = validators.NewDomainValidator(validators.DomainValidatorOptions{})
hostname, err := v.SafeHostname(redirectURI)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get safe hostname from redirect URI")
return false
}
if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) {
return true return true
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestOAuthControllerIsRedirectSafe(t *testing.T) { func TestOAuthController_isRedirectSafe(t *testing.T) {
log := logger.NewLogger().WithTestConfig() log := logger.NewLogger().WithTestConfig()
log.Init() log.Init()
+8 -37
View File
@@ -1,12 +1,11 @@
package service package service
import ( import (
"fmt"
"net/url"
"strings" "strings"
"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"
) )
@@ -40,13 +39,17 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) { func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
var nameMatch *model.App var nameMatch *model.App
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
// First try to find a matching app by domain, then fallback to matching by app name (subdomain) // First try to find a matching app by domain, then fallback to matching by app name (subdomain)
for app, config := range service.config.Apps { for app, config := range service.config.Apps {
match, err := service.checkDomain(domain, config.Config.Domain) err := v.Validate(config.Config.Domain, domain)
if err != nil { // Maybe not the best way to check if a match succeeded, but expected is only
// used on match errors and not parsing errors
if err != nil && !strings.HasPrefix(err.Error(), "expected") {
return nil, err return nil, err
} }
if match { if err == nil {
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain") service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
return &config, nil return &config, nil
} }
@@ -80,35 +83,3 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A
// no labels // no labels
return nil, nil return nil, nil
} }
func (service *AccessControlsService) checkDomain(check, target string) (bool, error) {
// Domains don't have a scheme, so we use a mock one
cu, err := url.Parse("tinyauth://" + check)
if err != nil {
return false, fmt.Errorf("failed to parse check domain: %w", err)
}
if cu.Host == "" {
return false, fmt.Errorf("check domain is empty")
}
tu, err := url.Parse("tinyauth://" + target)
if err != nil {
return false, fmt.Errorf("failed to parse target domain: %w", err)
}
if tu.Host == "" {
return false, fmt.Errorf("target domain is empty")
}
// non-dns check url
ndcu := strings.TrimSuffix(cu.Hostname(), ".")
// non-dns target url
ndtu := strings.TrimSuffix(tu.Hostname(), ".")
// Strip out the port
return strings.EqualFold(ndcu, ndtu), nil
}
@@ -92,7 +92,8 @@ func TestLookupStaticACLs(t *testing.T) {
Config: &model.Config{Apps: tt.apps}, Config: &model.Config{Apps: tt.apps},
LabelProvider: nil, LabelProvider: nil,
}) })
got := svc.lookupStaticACLs(tt.domain) got, err := svc.lookupStaticACLs(tt.domain)
require.NoError(t, err)
if tt.expectNil { if tt.expectNil {
assert.Nil(t, got) assert.Nil(t, got)
return return
+2 -1
View File
@@ -23,6 +23,7 @@ type DomainValidatorOptions struct {
// Ensure domains have the same port. // Ensure domains have the same port.
WithPort bool WithPort bool
// Specify a list of allowed schemes IF WithScheme is set to true. // Specify a list of allowed schemes IF WithScheme is set to true.
// Leave empty to allow any scheme.
AllowedSchemes []string AllowedSchemes []string
} }
@@ -64,7 +65,7 @@ func (v *DomainValidator) getURL(i string) (*url.URL, error) {
if u.Scheme == "tinyauth" { if u.Scheme == "tinyauth" {
return nil, fmt.Errorf("input url is missing scheme") return nil, fmt.Errorf("input url is missing scheme")
} }
if !slices.Contains(v.opts.AllowedSchemes, u.Scheme) { if len(v.opts.AllowedSchemes) > 0 && !slices.Contains(v.opts.AllowedSchemes, u.Scheme) {
return nil, fmt.Errorf("scheme %s not allowed", u.Scheme) return nil, fmt.Errorf("scheme %s not allowed", u.Scheme)
} }
} }