fix(apiserver): abort the request on every middleware failure (bug-1949)
Cause: ReturnError only writes the response and never aborts the gin chain; RequirePermission (token error) and WhitelistIPMiddleware (invalid client IP) returned after ReturnError without ctx.Abort(), so the route handler still ran after the rejection. Fix: ctx.Abort() after ReturnError at both sites (and, for clarity, on the ValidateHandler body-check path, which validateRequestBody already aborted). RequirePermission now answers a bad token with 401 instead of 500. ReturnError stays non-aborting (documented): handlers such as ControlHost call it from helpers and then test ctx.IsAborted(), so a global abort would change handler flows, and it adds nothing in handlers. Tests: TestRequirePermission_AbortsOnInvalidToken, TestWhitelistIPMiddleware_AbortsOnFailure. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
42dd1e7a5e
commit
42a402a5b4
6 changed files with 105 additions and 2 deletions
48
src/frontend/apiserver/controllers/rbac_abort_test.go
Normal file
48
src/frontend/apiserver/controllers/rbac_abort_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"apiserver/utils"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bug-1949: a RequirePermission failure must stop the request before the
|
||||||
|
// handler runs.
|
||||||
|
func TestRequirePermission_AbortsOnInvalidToken(t *testing.T) {
|
||||||
|
resetRBACState()
|
||||||
|
prev := utils.AuthenticationYamlConfig
|
||||||
|
utils.AuthenticationYamlConfig = &utils.Authentication{
|
||||||
|
Token: utils.TokenConfig{Type: "jwt", SecretKey: "rbac-abort-test-secret", TokenExpireTime: 3600},
|
||||||
|
}
|
||||||
|
utils.Log = zap.NewNop()
|
||||||
|
t.Cleanup(func() { utils.AuthenticationYamlConfig = prev })
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
handlerRan := false
|
||||||
|
r.GET("/protected", RequirePermission("host:view"), func(ctx *gin.Context) {
|
||||||
|
handlerRan = true
|
||||||
|
ReturnSuccess(ctx, http.StatusOK, "success", nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, header := range []string{"", "Bearer not-a-jwt"} {
|
||||||
|
handlerRan = false
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
|
if header != "" {
|
||||||
|
req.Header.Set("Authorization", header)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if handlerRan {
|
||||||
|
t.Errorf("Authorization %q: handler ran after RequirePermission failed (status %d)", header, w.Code)
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("Authorization %q: status = %d, want %d", header, w.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -117,7 +117,8 @@ func RequirePermission(perm string) gin.HandlerFunc {
|
||||||
token := ctx.GetHeader("Authorization")
|
token := ctx.GetHeader("Authorization")
|
||||||
logonUser, err := GetUsernameFromToken(token)
|
logonUser, err := GetUsernameFromToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
ReturnError(ctx, common.ERROR_AUTH_CHECK_TOKEN_CODE, err.Error(), nil)
|
||||||
|
ctx.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Debug("Logon user is ", zap.String("logonUser", logonUser))
|
logger.Debug("Logon user is ", zap.String("logonUser", logonUser))
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ func ReturnSuccess(ctx *gin.Context, code int, msg interface{}, data interface{}
|
||||||
ctx.JSON(200, json)
|
ctx.JSON(200, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReturnError writes the error envelope. It does not abort the gin chain:
|
||||||
|
// a middleware that rejects a request must call ctx.Abort() after it
|
||||||
|
// (bug-1949); handlers just return.
|
||||||
func ReturnError(ctx *gin.Context, code int, msg interface{}, details interface{}) {
|
func ReturnError(ctx *gin.Context, code int, msg interface{}, details interface{}) {
|
||||||
json := &JsonErrStruct{
|
json := &JsonErrStruct{
|
||||||
Code: code,
|
Code: code,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ func ValidateHandler() gin.HandlerFunc {
|
||||||
if err != nil || !isValidBody {
|
if err != nil || !isValidBody {
|
||||||
controllers.ReturnError(ctx, common.INVALID_PARAMS_CODE,
|
controllers.ReturnError(ctx, common.INVALID_PARAMS_CODE,
|
||||||
common.ERROR_READ_REQUEST_BODY_MSG+": "+err.Error(), nil)
|
common.ERROR_READ_REQUEST_BODY_MSG+": "+err.Error(), nil)
|
||||||
|
ctx.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ func WhitelistIPMiddleware(allowedIPs []string) gin.HandlerFunc {
|
||||||
if clientIP == nil {
|
if clientIP == nil {
|
||||||
logger.Error("invalid client IP", zap.String("clientIP", clientIPStr))
|
logger.Error("invalid client IP", zap.String("clientIP", clientIPStr))
|
||||||
controllers.ReturnError(ctx, common.INVALID_PARAMS_CODE, common.ERROR_INVALID_CLIENT_IP_MSG, nil)
|
controllers.ReturnError(ctx, common.INVALID_PARAMS_CODE, common.ERROR_INVALID_CLIENT_IP_MSG, nil)
|
||||||
|
ctx.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,55 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"apiserver/utils"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bug-1949: every IP-whitelist failure must stop the request before the
|
||||||
|
// handler runs.
|
||||||
|
func TestWhitelistIPMiddleware_AbortsOnFailure(t *testing.T) {
|
||||||
|
prevLog := utils.Log
|
||||||
|
utils.Log = zap.NewNop()
|
||||||
|
t.Cleanup(func() { utils.Log = prevLog })
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"unparsable client IP", "not-an-address", http.StatusBadRequest},
|
||||||
|
{"client IP not whitelisted", "192.0.2.10:4000", http.StatusForbidden},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
handlerRan := false
|
||||||
|
r.Use(WhitelistIPMiddleware([]string{"10.0.0.0/8"}))
|
||||||
|
r.GET("/ping", func(ctx *gin.Context) {
|
||||||
|
handlerRan = true
|
||||||
|
ctx.String(http.StatusOK, "pong")
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
req.RemoteAddr = tt.remoteAddr
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if handlerRan {
|
||||||
|
t.Errorf("handler ran after the IP whitelist rejected %q (status %d)", tt.remoteAddr, w.Code)
|
||||||
|
}
|
||||||
|
if w.Code != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPathMatchesPattern(t *testing.T) {
|
func TestPathMatchesPattern(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue