fix(api): tighten public path auth bypass matching

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 16:56:09 +00:00
parent c6034031a3
commit da9bb918f7
4 changed files with 84 additions and 2 deletions

View file

@ -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
}

View file

@ -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"))
})
}

View file

@ -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".

View file

@ -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) {