From da9bb918f7e255ff09d940fb4297f6ba5e983233 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 16:56:09 +0000 Subject: [PATCH] fix(api): tighten public path auth bypass matching Co-Authored-By: Virgil --- authentik.go | 2 +- authentik_test.go | 33 +++++++++++++++++++++++++++++++++ middleware.go | 26 +++++++++++++++++++++++++- middleware_test.go | 25 +++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/authentik.go b/authentik.go index fa08217..9e8b4eb 100644 --- a/authentik.go +++ b/authentik.go @@ -148,7 +148,7 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc { // Skip public paths. path := c.Request.URL.Path for p := range public { - if strings.HasPrefix(path, p) { + if isPublicPath(path, p) { c.Next() return } diff --git a/authentik_test.go b/authentik_test.go index ab6c4d8..0422100 100644 --- a/authentik_test.go +++ b/authentik_test.go @@ -221,6 +221,27 @@ func TestHealthBypassesAuthentik_Good(t *testing.T) { } } +func TestPublicPaths_Good_SimilarPrefixDoesNotBypassAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{ + TrustedProxy: true, + PublicPaths: []string{"/public"}, + } + e, _ := api.New(api.WithAuthentik(cfg)) + e.Register(&publicPrefixGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/publicity/secure", nil) + req.Header.Set("X-authentik-username", "alice") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for /publicity/secure with auth header, got %d: %s", w.Code, w.Body.String()) + } +} + func TestGetUser_Good_NilContext(t *testing.T) { gin.SetMode(gin.TestMode) @@ -458,3 +479,15 @@ func (g *groupRequireGroup) RegisterRoutes(rg *gin.RouterGroup) { c.JSON(200, api.OK("admin panel")) }) } + +// publicPrefixGroup provides a route that should still be processed by auth +// middleware even though its path shares a prefix with a public path. +type publicPrefixGroup struct{} + +func (g *publicPrefixGroup) Name() string { return "public-prefix" } +func (g *publicPrefixGroup) BasePath() string { return "/publicity" } +func (g *publicPrefixGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/secure", api.RequireAuth(), func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("protected")) + }) +} diff --git a/middleware.go b/middleware.go index bbd50f5..4ad6d66 100644 --- a/middleware.go +++ b/middleware.go @@ -42,7 +42,7 @@ func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc { return func(c *gin.Context) { // Check whether the request path should bypass authentication. for _, path := range skip { - if strings.HasPrefix(c.Request.URL.Path, path) { + if isPublicPath(c.Request.URL.Path, path) { c.Next() return } @@ -64,6 +64,30 @@ func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc { } } +// isPublicPath reports whether requestPath should bypass auth for publicPath. +// It matches the exact path and any nested subpath, but not sibling prefixes +// such as /swaggerx when the public path is /swagger. +func isPublicPath(requestPath, publicPath string) bool { + if publicPath == "" { + return false + } + + normalized := strings.TrimRight(publicPath, "/") + if normalized == "" { + normalized = "/" + } + + if requestPath == normalized { + return true + } + + if normalized == "/" { + return true + } + + return strings.HasPrefix(requestPath, normalized+"/") +} + // requestIDMiddleware ensures every response carries an X-Request-ID header. // If the client sends one, it is preserved; otherwise a random 16-byte hex // string is generated. The ID is also stored in the Gin context as "request_id". diff --git a/middleware_test.go b/middleware_test.go index 2146b2e..558fd8d 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -27,6 +27,16 @@ func (m *mwTestGroup) RegisterRoutes(rg *gin.RouterGroup) { }) } +type swaggerLikeGroup struct{} + +func (g *swaggerLikeGroup) Name() string { return "swagger-like" } +func (g *swaggerLikeGroup) BasePath() string { return "/swaggerx" } +func (g *swaggerLikeGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/secret", func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("classified")) + }) +} + type requestIDTestGroup struct { gotID *string } @@ -151,6 +161,21 @@ func TestBearerAuth_Good_HealthBypassesAuth(t *testing.T) { } } +func TestBearerAuth_Bad_SimilarPrefixDoesNotBypassAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBearerAuth("s3cret")) + e.Register(&swaggerLikeGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/swaggerx/secret", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for /swaggerx/secret, got %d", w.Code) + } +} + // ── Request ID ────────────────────────────────────────────────────────── func TestRequestID_Good_GeneratedWhenMissing(t *testing.T) {