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
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
References
- https://github.com/tinyauthapp/tinyauth/security/advisories/GHSA-328g-jx67-v94g web
- https://nvd.nist.gov/vuln/detail/CVE-2026-77560 advisory
- https://github.com/tinyauthapp/tinyauth/pull/1000 web
- https://github.com/tinyauthapp/tinyauth/pull/1028 web
- https://github.com/tinyauthapp/tinyauth/commit/80bc87188ec3aabc5104c249eaa7b997973b9275 web
- https://github.com/tinyauthapp/tinyauth/commit/e75605b2c534ec83525a33603e16d76baca13399 web
- https://github.com/tinyauthapp/tinyauth package
- https://github.com/tinyauthapp/tinyauth/releases/tag/v5.1.2 web