Résumé
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
Détails de l’avis
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(commit479f1657812b7bf01438607464dedaa148155301); root cause also present onmainHEAD - 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
Références
- https://github.com/advisories/GHSA-328g-jx67-v94g
- https://github.com/tinyauthapp/tinyauth/security/advisories/GHSA-328g-jx67-v94g
- https://nvd.nist.gov/vuln/detail/CVE-2026-77560
- https://github.com/tinyauthapp/tinyauth/pull/1000
- https://github.com/tinyauthapp/tinyauth/pull/1028
- https://github.com/tinyauthapp/tinyauth/commit/80bc87188ec3aabc5104c249eaa7b997973b9275
- https://github.com/tinyauthapp/tinyauth/commit/e75605b2c534ec83525a33603e16d76baca13399
- https://github.com/tinyauthapp/tinyauth/releases/tag/v5.1.2
Vulnérabilités liées
Tout Supply chain →- HIGHCVE-2026-61788
@bytebase/dbhub's read-only mode does not prevent database writes
- MEDIUMCVE-2026-84303
gRPC-Go: xDS RBAC HTTP Filter bypass via mixed-case Header Matching and gRFC A41 validation evasion
- MEDIUMCVE-2026-96628
Arcane: Environment access token bypasses the admin-required authorization gate on `/api/debug/pprof`
- MEDIUMCVE-2026-58494
Wasmtime: WASI hard links bypass wasmtime-wasi's FilePerms for destination
- MEDIUMCVE-2026-102832
JupyterLab: Command link trust prompt in JupyterLab can trust a document other than the one clicked
- MEDIUMCVE-2026-103028
SFTPGo source-side file-pattern filter bypass during copy operations