Summary

Tinyauth: forward-auth per-app ACL is matched case-sensitively against the (case-insensitive) hostname, letting an authenticated user reach apps they are not on the allowlist for

Advisory details

tinyauth: forward-auth per-app ACL is matched case-sensitively against the (case-insensitive) hostname, letting an authenticated user reach apps they are not on the allowlist for

GitHub Advisory Details (form fields — paste-ready)

Affected products

Field Value
Ecosystem Other (self-hosted) / Go
Package name github.com/steveiliop56/tinyauth (forward-auth middleware)
Affected versions < 5.1.2
Patched versions 5.1.2

Advisory details

Field Value
Title tinyauth forward-auth authorization bypass: per-app ACL host matching is case-sensitive while hostnames are case-insensitive, so a mixed-case host defeats users/groups/ip allowlists and fails open
  • Status: Runtime-confirmed (local lab, 127.0.0.1 only)
  • Target: steveiliop56/tinyauth v5.0.7 (commit 479f1657812b7bf01438607464dedaa148155301); root cause also present on main HEAD
  • Component: internal/service/access_controls_service.go (lookupStaticACLs / GetAccessControls), internal/service/docker_service.go (GetLabels), internal/controller/proxy_controller.go (proxyHandler)
  • Class: Broken access control / authorization bypass across the per-app trust boundary

Summary

tinyauth is a forward-auth service: a reverse proxy (Traefik/Caddy/nginx/Envoy) calls GET /api/auth/<proxy> on every request and only forwards the request upstream if tinyauth returns 200. tinyauth decides which per-app access rules apply by looking up the forwarded hostname (the app) in its ACL set — the static apps: config and/or Docker labels. Each app can restrict access with users.allow / users.block, oauth.whitelist, oauth.groups / ldap.groups, and ip.allow. These allowlists are the entire authorization model that separates one protected app from another for a shared pool of authenticated users.

The hostname → ACL lookup is performed with case-sensitive Go string comparisons (config.Config.Domain == domain and strings.SplitN(domain, ".", 2)[0] == app). Hostnames, however, are case-insensitive everywhere else in the stack: DNS, HTTP Host-header routing, and TLS SNI all treat immich.example.com and IMMICH.example.com as the same host, so a reverse proxy routes both to the same backend. When a request arrives with a mixed-case host, the proxy still routes it to the intended app and faithfully forwards the mixed-case value in X-Forwarded-Host (or X-Original-URL for nginx, or Host for Envoy), but tinyauth's case-sensitive lookup misses the app's ACL entry.

On a miss, tinyauth does not fail closed. GetAccessControls falls back to DockerService.GetLabels, which returns an empty config.App{} with no error whenever nothing matches (or Docker is not connected). The proxy handler then evaluates that empty App: IsAuthEnabled → true, CheckIP (no allow/block) → allowed, IsUserAllowed with an empty users.allow → CheckFilter("", …) → true, and the group check with empty required groups → true. The net result is that any already-authenticated user is authorized (200 Authenticated) for an app whose ACL was supposed to exclude them — simply by upper-casing (or otherwise re-casing) one letter of the hostname. This defeats the per-app users/groups/ip allowlist for every proxy integration.

Affected code (v5.0.7, commit 479f1657…)

The ACL lookup uses case-sensitive equality — internal/service/access_controls_service.go:

func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {
	for app, config := range acls.static {
		if config.Config.Domain == domain {              // case-sensitive ==
			return config, nil
		}
		if strings.SplitN(domain, ".", 2)[0] == app {    // case-sensitive ==
			return config, nil
		}
	}
	return config.App{}, errors.New("no results")
}

func (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {
	app, err := acls.lookupStaticACLs(domain)
	if err == nil {
		return app, nil
	}
	// Fallback to Docker labels
	return acls.docker.GetLabels(domain)
}

The Docker-label fallback has the same case-sensitive comparisons and, critically, returns an empty App with a nil error when nothing matches (fail open) — internal/service/docker_service.go:

func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
	if !docker.isConnected {
		return config.App{}, nil            // <-- empty App, no error
	}
	...
	for _, ctr := range containers {
		...
		for appName, appLabels := range labels.Apps {
			if appLabels.Config.Domain == appDomain { ... }        // case-sensitive
			if strings.SplitN(appDomain, ".", 2)[0] == appName { ... } // case-sensitive
		}
	}
	return config.App{}, nil                // <-- no match -> empty App, no error
}

The forward-auth verdict is built from that (possibly empty) App, and an empty App authorizes any logged-in user — internal/controller/proxy_controller.go and internal/service/auth_service.go:

// proxyHandler: host comes straight from X-Forwarded-Host, no normalization
acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
...
if userContext.IsLoggedIn {
	userAllowed := controller.auth.IsUserAllowed(c, userContext, acls)   // empty acls -> true
	...
	c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
	c.JSON(200, gin.H{"status": 200, "message": "Authenticated"})
}

// IsUserAllowed with an empty App:
func (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {
	if context.OAuth {
		return utils.CheckFilter(acls.OAuth.Whitelist, context.Email) // CheckFilter("", …) == true
	}
	if acls.Users.Block != "" { ... }                                  // "" -> skipped
	return utils.CheckFilter(acls.Users.Allow, context.Username)       // CheckFilter("", …) == true
}

utils.CheckFilter returns true for an empty filter, so an empty users.allow means "everyone is allowed":

func CheckFilter(filter string, str string) bool {
	if len(strings.TrimSpace(filter)) == 0 {
		return true          // empty allowlist -> allow all
	}
	...
}

The forwarded host is used verbatim: getForwardAuthContext reads x-forwarded-host, getAuthRequestContext parses x-original-url, getExtAuthzContext uses c.Request.Host — none of them lower-cases or canonicalizes the host before it reaches GetAccessControls.

Attacker model / precondition

The attacker is a legitimately authenticated but low-privileged user of the tinyauth instance — they hold a valid session (or valid credentials) for their own account, exactly the normal state of any user in a multi-app SSO deployment. They are simply not on the users.allow / group / IP allowlist of some other app protected by the same tinyauth. tinyauth does not offer self-registration, so a valid account is required; this is an authorization (not authentication) bypass, hence PR:L. An unauthenticated visitor is still redirected to the login page.

Trigger: send the request to the protected app with a hostname that routes identically but differs as a byte string from the configured ACL key — the simplest being a case change (IMMICH.example.com for immich.example.com). Reverse proxies match Host rules case-insensitively (RFC 3986 §3.2.2 / RFC 4343), so the request is still routed to the intended backend, and the proxy forwards the mixed-case host to tinyauth in X-Forwarded-Host / X-Original-URL / Host. Equivalent host encodings that route the same but bypass the string compare include a trailing FQDN dot (immich.example.com.) and, for by-domain rules, an added port. The bypass applies to all four proxy integrations (Traefik/Caddy → X-Forwarded-Host; nginx → X-Original-URL; Envoy → Host).

What bounds severity: the attacker must already have a valid account, and the concrete confidentiality/integrity impact depends on the specific app th

References