From 411ac9c68a92c34694a614fa32d55d49732084a8 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 21 Oct 2026 18:43:54 +0200 Subject: [PATCH 1/7] fix: refine request context handling --- internal/bootstrap/router_bootstrap.go | 8 +++- internal/bootstrap/router_bootstrap_test.go | 17 +++++++ internal/middleware/context_middleware.go | 27 +++++++++-- .../middleware/context_middleware_test.go | 46 ++++++++++++++++++- 4 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 internal/bootstrap/router_bootstrap_test.go diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index ae82c2d3..3e5da7c9 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -17,12 +17,18 @@ import ( "github.com/gin-gonic/gin" ) +func configureRouterEngine(engine *gin.Engine) { + // Platform headers must not bypass configured proxy trust. + engine.TrustedPlatform = "" + engine.Use(gin.Recovery()) +} + func (app *BootstrapApp) setupRouter() error { // we don't want gin debug mode gin.SetMode(gin.ReleaseMode) engine := gin.New() - engine.Use(gin.Recovery()) + configureRouterEngine(engine) if len(app.config.Auth.TrustedProxies) > 0 { err := engine.SetTrustedProxies(app.config.Auth.TrustedProxies) diff --git a/internal/bootstrap/router_bootstrap_test.go b/internal/bootstrap/router_bootstrap_test.go new file mode 100644 index 00000000..3c9048ef --- /dev/null +++ b/internal/bootstrap/router_bootstrap_test.go @@ -0,0 +1,17 @@ +package bootstrap + +import ( + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestConfigureRouterEngineTrustedPlatform(t *testing.T) { + engine := gin.New() + engine.TrustedPlatform = gin.PlatformGoogleAppEngine + + configureRouterEngine(engine) + + assert.Empty(t, engine.TrustedPlatform) +} diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 38955d17..bf65d4b4 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net" "net/http" "strings" "time" @@ -79,7 +80,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { uuid, err := c.Cookie(m.runtime.SessionCookieName) if err == nil { - userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.ClientIP()) + userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.ClientIP(), c.RemoteIP()) if err == nil { if cookie != nil { @@ -129,10 +130,11 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { // Lastly check if we have a tailscale session to add if m.tailscale != nil { - tailscaleContext, err := m.tailscaleWhois(c.ClientIP()) + ip := tailscaleClientIP(c.ClientIP(), c.RemoteIP()) + tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { - m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", c.ClientIP(), err) + m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", ip, err) } if tailscaleContext != nil { @@ -148,7 +150,21 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } } -func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip string) (*model.UserContext, *http.Cookie, error) { +func tailscaleClientIP(clientIP, peerIP string) string { + parsedClientIP := net.ParseIP(clientIP) + + if parsedClientIP == nil { + return "" + } + + if parsedClientIP.Equal(net.ParseIP(peerIP)) { + return "" + } + + return clientIP +} + +func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid, clientIP, peerIP string) (*model.UserContext, *http.Cookie, error) { session, err := m.auth.GetSession(ctx, uuid) if err != nil { @@ -184,6 +200,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain) } case model.ProviderTailscale: + ip := tailscaleClientIP(clientIP, peerIP) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { @@ -363,7 +380,7 @@ func (m *ContextMiddleware) isIgnorePath(path string) bool { } func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext, error) { - if m.tailscale == nil { + if m.tailscale == nil || ip == "" { return nil, nil } diff --git a/internal/middleware/context_middleware_test.go b/internal/middleware/context_middleware_test.go index f2193cc9..b2aeef75 100644 --- a/internal/middleware/context_middleware_test.go +++ b/internal/middleware/context_middleware_test.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "net" "net/http" "net/http/httptest" "testing" @@ -71,7 +72,7 @@ func TestContextMiddleware(t *testing.T) { }, }, { - description: "Valid session cookie sets authenticated local context", + description: "Valid local session cookie authenticates with unusable client addresses", run: func(t *testing.T, args runArgs) { uuid := "session-valid-local" seedSession(t, args.queries, repository.CreateSessionParams{ @@ -83,6 +84,8 @@ func TestContextMiddleware(t *testing.T) { }) req := httptest.NewRequest("GET", "/api/test", nil) + req.RemoteAddr = "invalid" + req.Header.Set("X-Forwarded-For", "invalid") req.AddCookie(&http.Cookie{Name: "tinyauth-session", Value: uuid}) userCtx, _ := args.do(req) @@ -355,3 +358,44 @@ func TestContextMiddleware(t *testing.T) { }) } } + +func TestTailscaleClientIP(t *testing.T) { + tests := []struct { + description string + peer string + proxies []string + forwarded string + unix bool + expected string + }{ + {description: "Trusted forwarded client", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}, forwarded: "100.101.0.2", expected: "100.101.0.2"}, + {description: "No trusted proxies", peer: "100.101.0.1:1234", forwarded: "100.101.0.2"}, + {description: "Mismatched trusted proxy", peer: "100.101.0.1:1234", proxies: []string{"192.0.2.1"}, forwarded: "100.101.0.2"}, + {description: "Missing forwarding", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}}, + {description: "Malformed forwarding", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}, forwarded: "invalid"}, + {description: "Mapped IPv4 peer equality", peer: "[::ffff:100.101.0.1]:1234", proxies: []string{"100.101.0.1"}, forwarded: "100.101.0.1"}, + {description: "Expanded IPv6 peer equality", peer: "[fd7a:115c:a1e0:0:0:0:0:1]:1234", proxies: []string{"fd7a:115c:a1e0::1"}, forwarded: "fd7a:115c:a1e0::1"}, + {description: "Invalid address", peer: "invalid"}, + {description: "Unix forwarded client", peer: "@", forwarded: "100.101.0.2", unix: true, expected: "100.101.0.2"}, + {description: "Unix missing forwarding", peer: "@", unix: true}, + {description: "Unix malformed forwarding", peer: "@", forwarded: "invalid", unix: true}, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + c, engine := gin.CreateTestContext(httptest.NewRecorder()) + require.NoError(t, engine.SetTrustedProxies(tc.proxies)) + + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + c.Request.RemoteAddr = tc.peer + c.Request.Header.Set("X-Forwarded-For", tc.forwarded) + + if tc.unix { + ctx := context.WithValue(c.Request.Context(), http.LocalAddrContextKey, &net.UnixAddr{Name: "/tmp/tinyauth-test.sock", Net: "unix"}) + c.Request = c.Request.WithContext(ctx) + } + + assert.Equal(t, tc.expected, tailscaleClientIP(c.ClientIP(), c.RemoteIP())) + }) + } +} From e7a7adb51cf3ac850976ed3ac49c00d920ccc1e8 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 30 Oct 2026 21:41:04 +0100 Subject: [PATCH 2/7] fix: preserve direct tailscale connections Co-Authored-By: Codex --- .github/workflows/ci.yml | 2 +- Makefile | 8 +++--- .../context_middleware_test.go | 13 +++++---- internal/middleware/context_middleware.go | 27 ++++++++++++++++--- 4 files changed, 36 insertions(+), 14 deletions(-) rename internal/middleware/{ => __tests__}/context_middleware_test.go (95%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc33272d..57a99e59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: run: cp -r frontend/dist internal/assets/dist - name: Run tests - run: go test -coverprofile=coverage.txt -v ./... + run: go test -coverprofile=coverage.txt -v ./... ./internal/middleware/__tests__ - name: Upload coverage reports to Codecov uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 diff --git a/Makefile b/Makefile index 2534a1d6..a5413b3c 100644 --- a/Makefile +++ b/Makefile @@ -69,15 +69,15 @@ binary-linux-arm64: # Go test test: - go test -v ./... + go test -v ./... ./internal/middleware/__tests__ # Go vet vet: - go vet ./... + go vet ./... ./internal/middleware/__tests__ # Go race test-race: - go test -race ./... + go test -race ./... ./internal/middleware/__tests__ # Development dev: @@ -122,4 +122,4 @@ lint-webui: # Format the code fmt: - go fmt ./... \ No newline at end of file + go fmt ./... diff --git a/internal/middleware/context_middleware_test.go b/internal/middleware/__tests__/context_middleware_test.go similarity index 95% rename from internal/middleware/context_middleware_test.go rename to internal/middleware/__tests__/context_middleware_test.go index b2aeef75..47a63012 100644 --- a/internal/middleware/context_middleware_test.go +++ b/internal/middleware/__tests__/context_middleware_test.go @@ -1,4 +1,4 @@ -package middleware +package middleware_test import ( "context" @@ -12,6 +12,7 @@ import ( "github.com/steveiliop56/ding" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tinyauthapp/tinyauth/internal/middleware" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository/memory" @@ -324,7 +325,7 @@ func TestContextMiddleware(t *testing.T) { require.NoError(t, err) - contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{ + contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareInput{ Log: log, RuntimeConfig: &runtime, AuthService: authService, @@ -369,10 +370,12 @@ func TestTailscaleClientIP(t *testing.T) { expected string }{ {description: "Trusted forwarded client", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}, forwarded: "100.101.0.2", expected: "100.101.0.2"}, - {description: "No trusted proxies", peer: "100.101.0.1:1234", forwarded: "100.101.0.2"}, - {description: "Mismatched trusted proxy", peer: "100.101.0.1:1234", proxies: []string{"192.0.2.1"}, forwarded: "100.101.0.2"}, + {description: "Direct client", peer: "100.101.0.1:1234", expected: "100.101.0.1"}, + {description: "No trusted proxies", peer: "100.101.0.1:1234", forwarded: "100.101.0.2", expected: "100.101.0.1"}, + {description: "Mismatched trusted proxy", peer: "100.101.0.1:1234", proxies: []string{"192.0.2.1"}, forwarded: "100.101.0.2", expected: "100.101.0.1"}, {description: "Missing forwarding", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}}, {description: "Malformed forwarding", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}, forwarded: "invalid"}, + {description: "Trusted proxy network", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.0/24"}}, {description: "Mapped IPv4 peer equality", peer: "[::ffff:100.101.0.1]:1234", proxies: []string{"100.101.0.1"}, forwarded: "100.101.0.1"}, {description: "Expanded IPv6 peer equality", peer: "[fd7a:115c:a1e0:0:0:0:0:1]:1234", proxies: []string{"fd7a:115c:a1e0::1"}, forwarded: "fd7a:115c:a1e0::1"}, {description: "Invalid address", peer: "invalid"}, @@ -395,7 +398,7 @@ func TestTailscaleClientIP(t *testing.T) { c.Request = c.Request.WithContext(ctx) } - assert.Equal(t, tc.expected, tailscaleClientIP(c.ClientIP(), c.RemoteIP())) + assert.Equal(t, tc.expected, middleware.TailscaleClientIP(c.ClientIP(), c.RemoteIP(), tc.proxies)) }) } } diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index bf65d4b4..5360f92a 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -130,7 +130,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { // Lastly check if we have a tailscale session to add if m.tailscale != nil { - ip := tailscaleClientIP(c.ClientIP(), c.RemoteIP()) + ip := TailscaleClientIP(c.ClientIP(), c.RemoteIP(), m.config.Auth.TrustedProxies) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { @@ -150,20 +150,39 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } } -func tailscaleClientIP(clientIP, peerIP string) string { +func TailscaleClientIP(clientIP, peerIP string, trustedProxies []string) string { parsedClientIP := net.ParseIP(clientIP) if parsedClientIP == nil { return "" } - if parsedClientIP.Equal(net.ParseIP(peerIP)) { + parsedPeerIP := net.ParseIP(peerIP) + + if parsedClientIP.Equal(parsedPeerIP) && isTrustedProxy(parsedPeerIP, trustedProxies) { return "" } return clientIP } +func isTrustedProxy(ip net.IP, trustedProxies []string) bool { + for _, trustedProxy := range trustedProxies { + parsedTrustedProxy := net.ParseIP(trustedProxy) + + if parsedTrustedProxy != nil && parsedTrustedProxy.Equal(ip) { + return true + } + + _, network, err := net.ParseCIDR(trustedProxy) + if err == nil && network.Contains(ip) { + return true + } + } + + return false +} + func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid, clientIP, peerIP string) (*model.UserContext, *http.Cookie, error) { session, err := m.auth.GetSession(ctx, uuid) @@ -200,7 +219,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid, clientIP, peer userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain) } case model.ProviderTailscale: - ip := tailscaleClientIP(clientIP, peerIP) + ip := TailscaleClientIP(clientIP, peerIP, m.config.Auth.TrustedProxies) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { From c93b26b2b20c43d4424bfe1373c83c5fb6e51482 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 30 Oct 2026 21:44:07 +0100 Subject: [PATCH 3/7] refactor: simplify router initialization Co-Authored-By: Codex --- internal/bootstrap/router_bootstrap.go | 12 ++++-------- internal/bootstrap/router_bootstrap_test.go | 17 ----------------- 2 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 internal/bootstrap/router_bootstrap_test.go diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index 3e5da7c9..d00302c8 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -17,18 +17,14 @@ import ( "github.com/gin-gonic/gin" ) -func configureRouterEngine(engine *gin.Engine) { - // Platform headers must not bypass configured proxy trust. - engine.TrustedPlatform = "" - engine.Use(gin.Recovery()) -} - func (app *BootstrapApp) setupRouter() error { // we don't want gin debug mode gin.SetMode(gin.ReleaseMode) engine := gin.New() - configureRouterEngine(engine) + // Platform headers must not bypass configured proxy trust. + engine.TrustedPlatform = "" + engine.Use(gin.Recovery()) if len(app.config.Auth.TrustedProxies) > 0 { err := engine.SetTrustedProxies(app.config.Auth.TrustedProxies) @@ -45,7 +41,7 @@ func (app *BootstrapApp) setupRouter() error { return fmt.Errorf("failed to set trusted proxies: %w", err) } - app.log.App.Warn().Msg("Trusted proxies are not configured, IP access controls will NOT work") + app.log.App.Warn().Msg("Trusted proxies are not configured, features relying on forwarded client IPs will NOT work") } middlewareProvideFor := []any{ diff --git a/internal/bootstrap/router_bootstrap_test.go b/internal/bootstrap/router_bootstrap_test.go deleted file mode 100644 index 3c9048ef..00000000 --- a/internal/bootstrap/router_bootstrap_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package bootstrap - -import ( - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" -) - -func TestConfigureRouterEngineTrustedPlatform(t *testing.T) { - engine := gin.New() - engine.TrustedPlatform = gin.PlatformGoogleAppEngine - - configureRouterEngine(engine) - - assert.Empty(t, engine.TrustedPlatform) -} From d5d010824b647fba09abe4f7db12ce1c6d65f1f4 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 30 Oct 2026 21:50:34 +0100 Subject: [PATCH 4/7] fix: fail closed for tailscale proxy fallback Co-Authored-By: Codex --- .github/workflows/ci.yml | 2 +- internal/bootstrap/router_bootstrap.go | 2 +- .../__tests__/context_middleware_test.go | 4 +-- internal/middleware/context_middleware.go | 25 +++---------------- 4 files changed, 8 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57a99e59..227b41bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: run: cp -r frontend/dist internal/assets/dist - name: Run tests - run: go test -coverprofile=coverage.txt -v ./... ./internal/middleware/__tests__ + run: go test -coverpkg=github.com/tinyauthapp/tinyauth/internal/middleware -coverprofile=coverage.txt -v ./... ./internal/middleware/__tests__ - name: Upload coverage reports to Codecov uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index d00302c8..031811fa 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -41,7 +41,7 @@ func (app *BootstrapApp) setupRouter() error { return fmt.Errorf("failed to set trusted proxies: %w", err) } - app.log.App.Warn().Msg("Trusted proxies are not configured, features relying on forwarded client IPs will NOT work") + app.log.App.Warn().Msg("Trusted proxies are not configured, only direct client connections can safely use IP-based features") } middlewareProvideFor := []any{ diff --git a/internal/middleware/__tests__/context_middleware_test.go b/internal/middleware/__tests__/context_middleware_test.go index 47a63012..0632e33c 100644 --- a/internal/middleware/__tests__/context_middleware_test.go +++ b/internal/middleware/__tests__/context_middleware_test.go @@ -372,7 +372,7 @@ func TestTailscaleClientIP(t *testing.T) { {description: "Trusted forwarded client", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}, forwarded: "100.101.0.2", expected: "100.101.0.2"}, {description: "Direct client", peer: "100.101.0.1:1234", expected: "100.101.0.1"}, {description: "No trusted proxies", peer: "100.101.0.1:1234", forwarded: "100.101.0.2", expected: "100.101.0.1"}, - {description: "Mismatched trusted proxy", peer: "100.101.0.1:1234", proxies: []string{"192.0.2.1"}, forwarded: "100.101.0.2", expected: "100.101.0.1"}, + {description: "Unlisted proxy in proxy mode", peer: "100.101.0.1:1234", proxies: []string{"192.0.2.1"}, forwarded: "100.101.0.2"}, {description: "Missing forwarding", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}}, {description: "Malformed forwarding", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}, forwarded: "invalid"}, {description: "Trusted proxy network", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.0/24"}}, @@ -398,7 +398,7 @@ func TestTailscaleClientIP(t *testing.T) { c.Request = c.Request.WithContext(ctx) } - assert.Equal(t, tc.expected, middleware.TailscaleClientIP(c.ClientIP(), c.RemoteIP(), tc.proxies)) + assert.Equal(t, tc.expected, middleware.TailscaleClientIP(c.ClientIP(), c.RemoteIP(), len(tc.proxies) > 0)) }) } } diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 5360f92a..63d3c3a7 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -130,7 +130,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { // Lastly check if we have a tailscale session to add if m.tailscale != nil { - ip := TailscaleClientIP(c.ClientIP(), c.RemoteIP(), m.config.Auth.TrustedProxies) + ip := TailscaleClientIP(c.ClientIP(), c.RemoteIP(), m.runtime.TrustedProxiesConfigured) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { @@ -150,7 +150,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } } -func TailscaleClientIP(clientIP, peerIP string, trustedProxies []string) string { +func TailscaleClientIP(clientIP, peerIP string, trustedProxiesConfigured bool) string { parsedClientIP := net.ParseIP(clientIP) if parsedClientIP == nil { @@ -159,30 +159,13 @@ func TailscaleClientIP(clientIP, peerIP string, trustedProxies []string) string parsedPeerIP := net.ParseIP(peerIP) - if parsedClientIP.Equal(parsedPeerIP) && isTrustedProxy(parsedPeerIP, trustedProxies) { + if trustedProxiesConfigured && parsedClientIP.Equal(parsedPeerIP) { return "" } return clientIP } -func isTrustedProxy(ip net.IP, trustedProxies []string) bool { - for _, trustedProxy := range trustedProxies { - parsedTrustedProxy := net.ParseIP(trustedProxy) - - if parsedTrustedProxy != nil && parsedTrustedProxy.Equal(ip) { - return true - } - - _, network, err := net.ParseCIDR(trustedProxy) - if err == nil && network.Contains(ip) { - return true - } - } - - return false -} - func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid, clientIP, peerIP string) (*model.UserContext, *http.Cookie, error) { session, err := m.auth.GetSession(ctx, uuid) @@ -219,7 +202,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid, clientIP, peer userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain) } case model.ProviderTailscale: - ip := TailscaleClientIP(clientIP, peerIP, m.config.Auth.TrustedProxies) + ip := TailscaleClientIP(clientIP, peerIP, m.runtime.TrustedProxiesConfigured) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { From ef1a77a583e57e84e6fac53f15dada9b2634d701 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 30 Oct 2026 21:54:16 +0100 Subject: [PATCH 5/7] test: restore colocated middleware tests Co-Authored-By: Codex --- .github/workflows/ci.yml | 2 +- Makefile | 6 +++--- internal/middleware/context_middleware.go | 6 +++--- .../middleware/{__tests__ => }/context_middleware_test.go | 7 +++---- 4 files changed, 10 insertions(+), 11 deletions(-) rename internal/middleware/{__tests__ => }/context_middleware_test.go (97%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 227b41bf..dc33272d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: run: cp -r frontend/dist internal/assets/dist - name: Run tests - run: go test -coverpkg=github.com/tinyauthapp/tinyauth/internal/middleware -coverprofile=coverage.txt -v ./... ./internal/middleware/__tests__ + run: go test -coverprofile=coverage.txt -v ./... - name: Upload coverage reports to Codecov uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 diff --git a/Makefile b/Makefile index a5413b3c..6044328b 100644 --- a/Makefile +++ b/Makefile @@ -69,15 +69,15 @@ binary-linux-arm64: # Go test test: - go test -v ./... ./internal/middleware/__tests__ + go test -v ./... # Go vet vet: - go vet ./... ./internal/middleware/__tests__ + go vet ./... # Go race test-race: - go test -race ./... ./internal/middleware/__tests__ + go test -race ./... # Development dev: diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 63d3c3a7..0f92005a 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -130,7 +130,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { // Lastly check if we have a tailscale session to add if m.tailscale != nil { - ip := TailscaleClientIP(c.ClientIP(), c.RemoteIP(), m.runtime.TrustedProxiesConfigured) + ip := tailscaleClientIP(c.ClientIP(), c.RemoteIP(), m.runtime.TrustedProxiesConfigured) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { @@ -150,7 +150,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } } -func TailscaleClientIP(clientIP, peerIP string, trustedProxiesConfigured bool) string { +func tailscaleClientIP(clientIP, peerIP string, trustedProxiesConfigured bool) string { parsedClientIP := net.ParseIP(clientIP) if parsedClientIP == nil { @@ -202,7 +202,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid, clientIP, peer userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain) } case model.ProviderTailscale: - ip := TailscaleClientIP(clientIP, peerIP, m.runtime.TrustedProxiesConfigured) + ip := tailscaleClientIP(clientIP, peerIP, m.runtime.TrustedProxiesConfigured) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { diff --git a/internal/middleware/__tests__/context_middleware_test.go b/internal/middleware/context_middleware_test.go similarity index 97% rename from internal/middleware/__tests__/context_middleware_test.go rename to internal/middleware/context_middleware_test.go index 0632e33c..ca5f8818 100644 --- a/internal/middleware/__tests__/context_middleware_test.go +++ b/internal/middleware/context_middleware_test.go @@ -1,4 +1,4 @@ -package middleware_test +package middleware import ( "context" @@ -12,7 +12,6 @@ import ( "github.com/steveiliop56/ding" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/tinyauthapp/tinyauth/internal/middleware" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository/memory" @@ -325,7 +324,7 @@ func TestContextMiddleware(t *testing.T) { require.NoError(t, err) - contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareInput{ + contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{ Log: log, RuntimeConfig: &runtime, AuthService: authService, @@ -398,7 +397,7 @@ func TestTailscaleClientIP(t *testing.T) { c.Request = c.Request.WithContext(ctx) } - assert.Equal(t, tc.expected, middleware.TailscaleClientIP(c.ClientIP(), c.RemoteIP(), len(tc.proxies) > 0)) + assert.Equal(t, tc.expected, tailscaleClientIP(c.ClientIP(), c.RemoteIP(), len(tc.proxies) > 0)) }) } } From 42d246c3b8a8d3ea379763c3877d8085632bee21 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 30 Oct 2026 21:57:49 +0100 Subject: [PATCH 6/7] fix: clarify trusted proxy warning Co-Authored-By: Codex --- internal/bootstrap/router_bootstrap.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index 031811fa..688e419a 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -41,7 +41,7 @@ func (app *BootstrapApp) setupRouter() error { return fmt.Errorf("failed to set trusted proxies: %w", err) } - app.log.App.Warn().Msg("Trusted proxies are not configured, only direct client connections can safely use IP-based features") + app.log.App.Warn().Msg("Trusted proxies are not configured, IP access controls will NOT work and only direct client connections can safely use IP-based authentication") } middlewareProvideFor := []any{ From dd9d3bee1898fb5f808593a590e4df4d6ed8b6d6 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 30 Oct 2026 22:04:42 +0100 Subject: [PATCH 7/7] fix: reject exhausted trusted proxy chains Co-Authored-By: Codex --- internal/middleware/context_middleware.go | 29 ++++++++++++++++--- .../middleware/context_middleware_test.go | 4 ++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 0f92005a..0d1bbac3 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -130,7 +130,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { // Lastly check if we have a tailscale session to add if m.tailscale != nil { - ip := tailscaleClientIP(c.ClientIP(), c.RemoteIP(), m.runtime.TrustedProxiesConfigured) + ip := tailscaleClientIP(c.ClientIP(), c.RemoteIP(), m.config.Auth.TrustedProxies) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { @@ -150,22 +150,43 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } } -func tailscaleClientIP(clientIP, peerIP string, trustedProxiesConfigured bool) string { +func tailscaleClientIP(clientIP, peerIP string, trustedProxies []string) string { parsedClientIP := net.ParseIP(clientIP) if parsedClientIP == nil { return "" } + if len(trustedProxies) == 0 { + return clientIP + } + parsedPeerIP := net.ParseIP(peerIP) - if trustedProxiesConfigured && parsedClientIP.Equal(parsedPeerIP) { + if parsedClientIP.Equal(parsedPeerIP) || isTrustedProxy(parsedClientIP, trustedProxies) { return "" } return clientIP } +func isTrustedProxy(ip net.IP, trustedProxies []string) bool { + for _, trustedProxy := range trustedProxies { + parsedTrustedProxy := net.ParseIP(trustedProxy) + + if parsedTrustedProxy != nil && parsedTrustedProxy.Equal(ip) { + return true + } + + _, network, err := net.ParseCIDR(trustedProxy) + if err == nil && network.Contains(ip) { + return true + } + } + + return false +} + func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid, clientIP, peerIP string) (*model.UserContext, *http.Cookie, error) { session, err := m.auth.GetSession(ctx, uuid) @@ -202,7 +223,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid, clientIP, peer userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain) } case model.ProviderTailscale: - ip := tailscaleClientIP(clientIP, peerIP, m.runtime.TrustedProxiesConfigured) + ip := tailscaleClientIP(clientIP, peerIP, m.config.Auth.TrustedProxies) tailscaleContext, err := m.tailscaleWhois(ip) if err != nil { diff --git a/internal/middleware/context_middleware_test.go b/internal/middleware/context_middleware_test.go index ca5f8818..f9a4a507 100644 --- a/internal/middleware/context_middleware_test.go +++ b/internal/middleware/context_middleware_test.go @@ -375,6 +375,8 @@ func TestTailscaleClientIP(t *testing.T) { {description: "Missing forwarding", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}}, {description: "Malformed forwarding", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1"}, forwarded: "invalid"}, {description: "Trusted proxy network", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.0/24"}}, + {description: "Exhausted exact trusted proxy chain", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.1", "100.101.0.2"}, forwarded: "100.101.0.2"}, + {description: "Exhausted trusted proxy network", peer: "100.101.0.1:1234", proxies: []string{"100.101.0.0/24"}, forwarded: "100.101.0.2"}, {description: "Mapped IPv4 peer equality", peer: "[::ffff:100.101.0.1]:1234", proxies: []string{"100.101.0.1"}, forwarded: "100.101.0.1"}, {description: "Expanded IPv6 peer equality", peer: "[fd7a:115c:a1e0:0:0:0:0:1]:1234", proxies: []string{"fd7a:115c:a1e0::1"}, forwarded: "fd7a:115c:a1e0::1"}, {description: "Invalid address", peer: "invalid"}, @@ -397,7 +399,7 @@ func TestTailscaleClientIP(t *testing.T) { c.Request = c.Request.WithContext(ctx) } - assert.Equal(t, tc.expected, tailscaleClientIP(c.ClientIP(), c.RemoteIP(), len(tc.proxies) > 0)) + assert.Equal(t, tc.expected, tailscaleClientIP(c.ClientIP(), c.RemoteIP(), tc.proxies)) }) } }