Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 22 additions & 30 deletions internal/controller/oauth_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package controller
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"

Expand All @@ -12,6 +11,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -311,54 +311,46 @@ func (controller *OAuthController) getCookieDomain() string {
}

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 {
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
}

if u.Scheme == "" || u.Host == "" {
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
return false
err = v.Validate(redirectURI, controller.runtime.AppURL)

if err == nil {
return true
}

au, err := url.Parse(controller.runtime.AppURL)
controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI")

if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
if strings.HasPrefix(err.Error(), "expected port") ||
strings.HasPrefix(err.Error(), "expected scheme") ||
err.Error() == "input url is invalid" {
return false
}

if u.Scheme != au.Scheme {
controller.log.App.Warn().Msg("Redirect URI scheme does not match app URL scheme")
if !controller.config.Auth.SubdomainsEnabled {
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
}
v = validators.NewDomainValidator(validators.DomainValidatorOptions{})

if strings.EqualFold(u.Hostname(), au.Hostname()) {
return true
}
hostname, err := v.SafeHostname(redirectURI)

if !controller.config.Auth.SubdomainsEnabled {
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get safe hostname from redirect URI")
return false
}

if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) {
return true
}

Expand Down
2 changes: 1 addition & 1 deletion internal/controller/oauth_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)

func TestOAuthControllerIsRedirectSafe(t *testing.T) {
func TestOAuthController_isRedirectSafe(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()

Expand Down
25 changes: 19 additions & 6 deletions internal/service/access_controls_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"
)

Expand Down Expand Up @@ -35,27 +36,39 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
}
}

func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
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)
for app, config := range service.config.Apps {
if config.Config.Domain == domain {
err := v.Validate(config.Config.Domain, domain)
// 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
}
if err == nil {
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
return &config
return &config, nil
}
if strings.SplitN(domain, ".", 2)[0] == app {
if strings.HasPrefix(domain, app+".") {
Comment thread
steveiliop56 marked this conversation as resolved.
Outdated
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = &config
}
}

return nameMatch
return nameMatch, nil
}
Comment thread
steveiliop56 marked this conversation as resolved.
Outdated

func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config
app := service.lookupStaticACLs(domain)
app, err := service.lookupStaticACLs(domain)

if err != nil {
return nil, err
}

if app != nil {
service.log.App.Debug().Msg("Using static ACLs for app")
Expand Down
3 changes: 2 additions & 1 deletion internal/service/access_controls_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ func TestLookupStaticACLs(t *testing.T) {
Config: &model.Config{Apps: tt.apps},
LabelProvider: nil,
})
got := svc.lookupStaticACLs(tt.domain)
got, err := svc.lookupStaticACLs(tt.domain)
require.NoError(t, err)
if tt.expectNil {
assert.Nil(t, got)
return
Expand Down
160 changes: 160 additions & 0 deletions pkg/validators/domain_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Package validators provides validators for various types of data.
//
// Domain validator is a simple utility that ensures two domains are exact
// matches while ensuring that techniques used to bypass such checks do
// not impact the validation.

package validators

import (
"fmt"
"net"
"net/url"
"slices"
"strings"

"golang.org/x/net/idna"
)

// DomainValidatorOptions is a set of options for DomainValidator.
type DomainValidatorOptions struct {
// Ensure domains have the same scheme.
WithScheme bool
// Ensure domains have the same port.
WithPort bool
// Specify a list of allowed schemes IF WithScheme is set to true.
// Leave empty to allow any scheme.
AllowedSchemes []string
}

// DomainValidator is a simple utility that ensures two domains are exact
// matches while ensuring that techniques used to bypass such checks do
// not impact the validation.
type DomainValidator struct {
opts DomainValidatorOptions
}

// NewDomainValidator creates a new DomainValidator.
func NewDomainValidator(opts DomainValidatorOptions) *DomainValidator {
return &DomainValidator{
opts: opts,
}
}

func (v *DomainValidator) getURL(i string) (*url.URL, error) {
u, err := url.Parse(i)

if !v.opts.WithScheme && (err != nil || u.Host == "") {
u, err = url.Parse("tinyauth://" + i)
}

if err != nil {
return nil, fmt.Errorf("failed to parse input url: %w", err)
}

if u.Host == "" {
return nil, fmt.Errorf("input url is invalid")
}

if v.opts.WithPort && !v.opts.WithScheme && u.Port() == "" {
return nil, fmt.Errorf("port validation is enabled but port is missing in input url and schemes are not enabled")
}

if v.opts.WithScheme {
// Empty scheme means that we parsed the url with the tinyauth:// placeholder
if u.Scheme == "tinyauth" {
return nil, fmt.Errorf("input url is missing 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 u, nil
}

func (v *DomainValidator) getEffectivePort(u *url.URL) string {
if u.Port() != "" {
return u.Port()
}
if u.Scheme == "https" {
return "443"
}
return "80"
Comment thread
steveiliop56 marked this conversation as resolved.
Outdated
}

func (v *DomainValidator) formatHostname(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")
}
hostname, err := idna.Lookup.ToASCII(hostname)
if err != nil {
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
}
return hostname, nil
}

// Validate ensures that two domains are exact matches with the
// options defined in the DomainValidatorOptions. It ensures that the
// inputs are proper URLs and contain a host. It lowercases the hostnames
// and removes the trailing dot. Finally, it checks that the hostnames are
// equal unless WithScheme or WithPort is set to true where it also
// validates the scheme and port respectively.
func (v *DomainValidator) Validate(expected, actual string) error {
eu, err := v.getURL(expected)

if err != nil {
return err
}

au, err := v.getURL(actual)

if err != nil {
return err
}

if v.opts.WithScheme {
if eu.Scheme != au.Scheme {
return fmt.Errorf("expected scheme %s, got %s", eu.Scheme, au.Scheme)
}
}

if v.opts.WithPort {
if v.getEffectivePort(eu) != v.getEffectivePort(au) {
return fmt.Errorf("expected port %s, got %s", v.getEffectivePort(eu), v.getEffectivePort(au))
}
}

euf, err := v.formatHostname(eu.Hostname())

if err != nil {
return err
}

auf, err := v.formatHostname(au.Hostname())

if err != nil {
return err
}

if euf != auf {
return fmt.Errorf("expected hostname %s, got %s", euf, auf)
}

return nil
}
Comment thread
steveiliop56 marked this conversation as resolved.

// SafeHostname uses the internal validation for domains that Validator uses
// to parse a hostname. It ensures the input URL is a valid URL, that a host
// is present and that the hostname is lowercased and without a trailing dot.
func (v *DomainValidator) SafeHostname(input string) (string, error) {
u, err := v.getURL(input)

if err != nil {
return "", err
}

return v.formatHostname(u.Hostname())
}
Loading
Loading