fix(apiserver): match JWT-excluded paths exactly, wildcard only for /* (bug-1962)

Cause: isExcludedPath (middleware/validatehandler.go) dropped the last
character of every excluded entry and prefix-matched, so /ping excused any
path starting with /pin (e.g. /pinX) from JWT validation, while
/swagger/*any (trimmed to /swagger/*an) never matched.

Fix: entries match exactly; an entry whose last segment is a gin catch-all
(/swagger/*any) matches any path under the part before the "*".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-10 10:23:05 +00:00
parent d720132c8c
commit 387a421c57
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
2 changed files with 76 additions and 14 deletions

View file

@ -44,23 +44,30 @@ func ValidateHandler() gin.HandlerFunc {
}
}
func isExcludedPath(path string) bool {
// jwtExcludedPaths are the routes that skip JWT validation. An entry is
// matched exactly, unless its last segment is a gin catch-all ("*name", as in
// the route "/swagger/*any"): then any path under the part before the "*"
// matches (bug-1962).
var jwtExcludedPaths = []string{
"/ping",
"/xce/v1/auth/logon",
"/swagger/*any",
"/xce/v1/auth/logout",
}
allowed := false
// define some excluded paths
excludedPaths := []string{
"/ping",
"/xce/v1/auth/logon",
"/swagger/*any",
"/xce/v1/auth/logout",
}
for _, excludedPath := range excludedPaths {
if strings.HasPrefix(path, excludedPath[:len(excludedPath)-1]) { // 去掉最后的 *
allowed = true
break
func isExcludedPath(path string) bool {
for _, excludedPath := range jwtExcludedPaths {
if i := strings.LastIndex(excludedPath, "/*"); i >= 0 {
if strings.HasPrefix(path, excludedPath[:i+1]) {
return true
}
continue
}
if path == excludedPath {
return true
}
}
return allowed
return false
}
// Token validation middleware

View file

@ -150,3 +150,58 @@ func TestIsValidToken_PinsSigningAlgorithm(t *testing.T) {
})
}
}
// bug-1962: exact excluded entries match only themselves; only the wildcard
// entry (/swagger/*any) matches by prefix.
func TestIsExcludedPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"/ping", true},
{"/xce/v1/auth/logon", true},
{"/xce/v1/auth/logout", true},
{"/swagger/index.html", true},
{"/swagger/doc.json", true},
// prefix look-alikes must not skip JWT validation
{"/pinX", false},
{"/pin", false},
{"/ping/x", false},
{"/pingpong", false},
{"/xce/v1/auth/logonX", false},
{"/xce/v1/auth/logo", false},
{"/xce/v1/auth/logout/x", false},
{"/swaggerX", false},
{"/xce/v1/jobs", false},
{"/", false},
}
for _, tt := range tests {
if got := isExcludedPath(tt.path); got != tt.want {
t.Errorf("isExcludedPath(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
// bug-1962: a request to a path that only shares a prefix with an excluded
// entry must still be rejected without a token; the excluded entry itself
// still needs none.
func TestValidateHandler_PrefixLookAlikeNeedsToken(t *testing.T) {
setTestAuthConfig(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(ValidateHandler())
r.GET("/pinX", func(ctx *gin.Context) { ctx.String(http.StatusOK, "reached") })
r.GET("/ping", func(ctx *gin.Context) { ctx.String(http.StatusOK, "pong") })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/pinX", nil))
if strings.Contains(w.Body.String(), "reached") {
t.Errorf("/pinX without a token reached the handler (status %d)", w.Code)
}
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ping", nil))
if w.Code != http.StatusOK || w.Body.String() != "pong" {
t.Errorf("/ping without a token: status %d body %q, want 200 pong", w.Code, w.Body.String())
}
}