Skip to content
Open
Show file tree
Hide file tree
Changes from all 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+".") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

App-name subdomain fallback is case-sensitive, unlike the exact-domain match.

strings.HasPrefix(domain, app+".") compares the raw domain value, while the exact-match path (ValidateformatHostname) lowercases before comparing. A mixed-case Host header (e.g. FOO.example.com) would fail this fallback even though it should match app "foo".

🐛 Proposed fix
-		if strings.HasPrefix(domain, app+".") {
+		if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(app)+".") {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if strings.HasPrefix(domain, app+".") {
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(app)+".") {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/access_controls_service.go` at line 56, Update the app-name
subdomain fallback in the access-control validation flow to compare normalized
lowercase host and app values, matching the behavior of Validate via
formatHostname. Preserve the existing prefix boundary using app+"." so
mixed-case hosts such as FOO.example.com match the configured app correctly.

service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = &config
}
}

return nameMatch
return nameMatch, nil
}
Comment on lines +39 to 63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

One bad app config can abort unrelated ACL lookups.

  • v.Validate(config.Config.Domain, domain) now returns early on non-match parse/format errors, so a single malformed app domain can make lookups for other valid apps fail nondeterministically as map iteration order changes.
  • The app-name fallback is also case-sensitive (strings.HasPrefix(domain, app+".")), so mixed-case hostnames miss the fallback match.

Treat invalid configured domains as non-matches here, or validate them at config load so request-time lookup can’t fail on one bad entry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/access_controls_service.go` around lines 39 - 63, Update
lookupStaticACLs so validation errors for individual configured domains are
treated as non-matches rather than returned, allowing iteration to continue and
valid apps to match regardless of map order; also make the app-name fallback
comparison case-insensitive when checking the domain prefix against app+".".
Preserve direct domain matches and return nameMatch only after all apps are
evaluated.


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"

@Rycochet Rycochet Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is assuming that only http/s is coming in - the code is written pretty agnostically, so wouldn't it be safer to actively check for http to give port 80, and throw an error for unknown scheme otherwise?

edit: Maybe still return 80, but also log it - not sure on the best choice...

}

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 on lines +99 to +147

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Two security-relevant call sites branch on raw Validate error text instead of typed errors. Validate only returns fmt.Errorf-built strings, so both consumers must guess the failure category via prefix-matching the message; any future wording tweak in the validator silently breaks that control flow with no compile-time signal.

  • pkg/validators/domain_validator.go#L99-L147: expose sentinel/typed errors (e.g. ErrSchemeMismatch, ErrPortMismatch, ErrHostnameMismatch, ErrInvalidInput) from Validate/getURL so callers can use errors.Is/errors.As instead of string matching.
  • internal/controller/oauth_controller.go#L334-L338: replace strings.HasPrefix(err.Error(), "expected port") || strings.HasPrefix(err.Error(), "expected scheme") || err.Error() == "input url is invalid" with errors.Is checks against the new sentinel errors.
  • internal/service/access_controls_service.go#L49: replace !strings.HasPrefix(err.Error(), "expected") with an errors.Is/errors.As check against the "hostname mismatch" sentinel to distinguish a genuine non-match from a real parsing/config failure.
📍 Affects 3 files
  • pkg/validators/domain_validator.go#L99-L147 (this comment)
  • internal/controller/oauth_controller.go#L334-L338
  • internal/service/access_controls_service.go#L49-L49
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/validators/domain_validator.go` around lines 99 - 147, Introduce exported
sentinel or typed errors in DomainValidator.Validate/getURL for invalid input,
scheme mismatch, port mismatch, and hostname mismatch, wrapping them while
preserving useful details. In internal/controller/oauth_controller.go lines
334-338, replace error-message prefix checks with errors.Is/errors.As against
the new errors; in internal/service/access_controls_service.go line 49,
recognize only the hostname-mismatch error and treat other validation errors as
failures.


// 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