Files
tinyauth/internal/service/access_controls_service.go
T
ContreandClaude Sonnet 4.6 e1b1e722e7 fix(acl): only let a label provider define ACLs for domains it routes
The app name fallback matches any domain that starts with the app name,
so an app named myapp served on myapp.example.com also defined the ACLs
of myapp.evil.com. Behind a proxy with a catch-all route, a request can
be authorized against the wrong app that way.

Label providers now receive the domain being authorized. The Kubernetes
provider keeps the hosts of every Ingress, HTTPRoute and GRPCRoute it
watches and withholds the apps of the resources that do not route the
domain, which bounds the name fallback to the hosts a resource actually
serves. Wildcard hostnames keep matching as a suffix, so nested
subdomains stay resolvable by app name.

Container labels carry no routing information, so the Docker provider
cannot narrow its results down and keeps yielding every app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 12:18:02 +02:00

128 lines
3.6 KiB
Go

package service
import (
"errors"
"strings"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"
)
// LabelProvider looks up the apps it knows about for the given domain. A
// provider that knows which hosts its apps are served on MUST only yield the
// ones that are actually served on domain, so that an unrelated app cannot
// claim it by name.
type LabelProvider interface {
Lookup(domain string, locator func(name string, app *model.App) bool) error
}
type AccessControlsService struct {
log *logger.Logger
config *model.Config
labelProvider LabelProvider
}
type AccessControlServiceInput struct {
dig.In
Log *logger.Logger
Config *model.Config
LabelProvider LabelProvider `optional:"true"`
}
func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsService {
return &AccessControlsService{
log: i.Log,
config: i.Config,
labelProvider: i.LabelProvider,
}
}
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{})
var domainMatch *model.App
var nameMatch *model.App
var nameMatchedApps []string
locatorFunc := func(name string, app *model.App) bool {
if app.Config.Domain != "" {
err := v.Validate(app.Config.Domain, domain)
if err == nil {
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
domainMatch = app
return true
} else if !errors.Is(err, validators.ErrHostnameMismatch) {
service.log.App.Debug().Str("name", name).Err(err).Msg("Domain validation failed")
}
}
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) {
service.log.App.Debug().Str("name", name).Msg("Found matching container by app name")
nameMatch = app
nameMatchedApps = append(nameMatchedApps, name)
}
return false
}
err := lookup(locatorFunc)
if err != nil {
return nil, err
}
if domainMatch != nil {
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by domain")
return domainMatch, nil
}
if nameMatch == nil {
service.log.App.Debug().Str("domain", domain).Msg("No match found for domain, skipping")
return nil, nil
}
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")
}
service.log.App.Debug().Str("domain", domain).Msg("Found matching app by app name")
return nameMatch, nil
}
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
for app, config := range service.config.Apps {
if ok := locator(app, &config); ok {
return nil
}
}
return nil
})
}
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config
app, err := service.lookupStaticACLs(domain)
// Will never return an error here, but we need to check it
if err != nil {
return nil, err
}
if app != nil {
service.log.App.Debug().Msg("Using static ACLs for app")
return app, nil
}
// If we have a label provider configured, try to get ACLs from it
if service.labelProvider != nil {
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
return service.labelProvider.Lookup(domain, locator)
})
}
// No labels
return nil, nil
}