-
-
Notifications
You must be signed in to change notification settings - Fork 249
refactor: move domain check into small helper util #1000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
| ) | ||
|
|
||
|
|
@@ -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+".") { | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
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 |
||
|
|
||
| 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") | ||
|
|
||
| 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" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 edit: Maybe still return |
||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
📍 Affects 3 files
🤖 Prompt for AI Agents |
||
|
|
||
| // 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()) | ||
| } | ||
There was a problem hiding this comment.
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 rawdomainvalue, while the exact-match path (Validate→formatHostname) lowercases before comparing. A mixed-caseHostheader (e.g.FOO.example.com) would fail this fallback even though it should match app"foo".🐛 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents