GitHub Advisoryadvisory · Go

GHSA-328g-jx67-v94g 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

HIGH Published 2026-09-22 Modified 2026-09-22 Official page ↗
Aliases
CVE-2026-77560
Ecosystem
Go
Packages
github.com/tinyauthapp/tinyauth
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N
Weaknesses
CWE-178, CWE-636, CWE-863
Reviewed
2026-09-22

Affected packages

• Go github.com/tinyauthapp/tinyauth: < 1.0.1-0.20260720133915-80bc87188ec3 → fixed in 1.0.1-0.20260720133915-80bc87188ec3

Description

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 | `` 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`: ```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`: ```go func (docker *DockerService) GetLabels(appDomain string) (config.App, error) { if !docker.isConnected { return config.App{}, nil // 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`: ```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": ```go 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 that becomes rea…

Related

Alias · CVE (1)

  • CVE-2026-77560 Tinyauth is an authentication and authorization server. vulnerability

Weakness · CWE (3)

  • CWE-178 Improper Handling of Case Sensitivity weakness
  • CWE-636 Not Failing Securely weakness
  • CWE-863 Incorrect Authorization weakness

References