Root cause
Authentication & access control introduced
feat(authz): enforce route-level scope checks for access tokens
AI-assisted change: Cursor
How AI contributed
Direct introductionEch0 before 4.4.3 fails to enforce scope-based authorization on nine comment panel admin endpoints, allowing access tokens with minimal scopes to perform full comment moderation operations. Attackers with a limited-scope access token can list, approve, reject, delete comments, and modify comment system settings by directly accessing the unprotected panel endpoints.
An admin-issued access JWT carrying only an unrelated minimal scope such as echo:read passed JWTAuthMiddleware and reached all nine /api/panel/comments routes because those AuthRouterGroup registrations lacked RequireScopes. The comment service then checked only the database user's admin/owner role, not the access token's scopes, allowing the limited token to read comments and settings, moderate or delete comments, change comment settings, and send test email.
Root cause
feat(authz): enforce route-level scope checks for access tokens
AI-assisted change: Cursor
Fix
refactor(router): add middleware for authorization scopes on various routes
@@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "testing"+ "time" "github.com/gin-gonic/gin" "github.com/lin-snow/ech0/internal/database"@@ -22,6 +23,10 @@ import ( settingHandler "github.com/lin-snow/ech0/internal/handler/setting" userHandler "github.com/lin-snow/ech0/internal/handler/user" webHandler "github.com/lin-snow/ech0/internal/handler/web"+ "github.com/lin-snow/ech0/internal/middleware"+ authModel "github.com/lin-snow/ech0/internal/model/auth"+ userModel "github.com/lin-snow/ech0/internal/model/user"+ jwtUtil "github.com/lin-snow/ech0/internal/util/jwt" "gorm.io/driver/sqlite" "gorm.io/gorm" )@@ -85,6 +90,80 @@ func TestSetupRouter_AllUsersRouteProtected(t *testing.T) { } } +func TestSetupRouter_AccessTokenWithoutRequiredScopeGetsForbidden(t *testing.T) {+ gin.SetMode(gin.TestMode)+ initTestDatabase(t)+ engine := gin.New()++ api := engine.Group("/api")+ api.Use(middleware.NoCache(), middleware.JWTAuthMiddleware())+ api.PUT(+ "/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ func(ctx *gin.Context) { ctx.Status(http.StatusOK) },+ )++ user := userModel.User{ID: "u-1", Username: "scope-user"}+ token, err := jwtUtil.GenerateToken(+ jwtUtil.CreateAccessClaimsWithExpiry(+ user,+ int64(time.Hour),+ []string{authModel.ScopeEchoRead},+ authModel.AudiencePublic,+ "jti-read-only",+ ),+ )+ if err != nil {+ t.Fatalf("generate token failed: %v", err)+ }++ req := httptest.NewRequest(http.MethodPut, "/api/settings", nil)+ req.Header.Set("Authorization", "Bearer "+token)+ rec := httptest.NewRecorder()+ engine.ServeHTTP(rec, req)++ if rec.Code != http.StatusForbidden {+ t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)+ }+}++func TestSetupRouter_AccessTokenWithScopePasses(t *testing.T) {+ gin.SetMode(gin.TestMode)+ initTestDatabase(t)+ engine := gin.New()++ api := engine.Group("/api")+ api.Use(middleware.NoCache(), middleware.JWTAuthMiddleware())+ api.PUT(+ "/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ func(ctx *gin.Context) { ctx.Status(http.StatusOK) },+ )++ user := userModel.User{ID: "u-2", Username: "scope-admin"}+ token, err := jwtUtil.GenerateToken(+ jwtUtil.CreateAccessClaimsWithExpiry(+ user,+ int64(time.Hour),+ []string{authModel.ScopeAdminSettings},+ authModel.AudiencePublic,+ "jti-admin",+ ),+ )+ if err != nil {+ t.Fatalf("generate token failed: %v", err)+ }++ req := httptest.NewRequest(http.MethodPut, "/api/settings", nil)+ req.Header.Set("Authorization", "Bearer "+token)+ rec := httptest.NewRecorder()@@ -1,6 +1,10 @@ package router -import "github.com/lin-snow/ech0/internal/handler"+import (+ "github.com/lin-snow/ech0/internal/handler"+ "github.com/lin-snow/ech0/internal/middleware"+ authModel "github.com/lin-snow/ech0/internal/model/auth"+) // setupSettingRoutes 设置设置路由 func setupSettingRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {@@ -11,38 +15,105 @@ func setupSettingRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { appRouterGroup.PublicRouterGroup.GET("/agent/info", h.SettingHandler.GetAgentInfo()) // Auth- appRouterGroup.AuthRouterGroup.PUT("/settings", h.SettingHandler.UpdateSettings())+ appRouterGroup.AuthRouterGroup.PUT(+ "/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.UpdateSettings(),+ ) - appRouterGroup.AuthRouterGroup.GET("/s3/settings", h.SettingHandler.GetS3Settings())- appRouterGroup.AuthRouterGroup.PUT("/s3/settings", h.SettingHandler.UpdateS3Settings())+ appRouterGroup.AuthRouterGroup.GET(+ "/s3/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.GetS3Settings(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/s3/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.UpdateS3Settings(),+ ) - appRouterGroup.AuthRouterGroup.GET("/oauth2/settings", h.SettingHandler.GetOAuth2Settings())- appRouterGroup.AuthRouterGroup.PUT("/oauth2/settings", h.SettingHandler.UpdateOAuth2Settings())- appRouterGroup.AuthRouterGroup.GET("/passkey/settings", h.SettingHandler.GetPasskeySettings())- appRouterGroup.AuthRouterGroup.PUT("/passkey/settings", h.SettingHandler.UpdatePasskeySettings())+ appRouterGroup.AuthRouterGroup.GET(+ "/oauth2/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.GetOAuth2Settings(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/oauth2/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.UpdateOAuth2Settings(),+ )+ appRouterGroup.AuthRouterGroup.GET(+ "/passkey/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.GetPasskeySettings(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/passkey/settings",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.UpdatePasskeySettings(),+ ) - appRouterGroup.AuthRouterGroup.GET("/webhook", h.SettingHandler.GetWebhook())- appRouterGroup.AuthRouterGroup.POST("/webhook", h.SettingHandler.CreateWebhook())- appRouterGroup.AuthRouterGroup.PUT("/webhook/:id", h.SettingHandler.UpdateWebhook())- appRouterGroup.AuthRouterGroup.DELETE("/webhook/:id", h.SettingHandler.DeleteWebhook())- appRouterGroup.AuthRouterGroup.POST("/webhook/:id/test", h.SettingHandler.TestWebhook())+ appRouterGroup.AuthRouterGroup.GET(+ "/webhook",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.GetWebhook(),+ )+ appRouterGroup.AuthRouterGroup.POST(+ "/webhook",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.CreateWebhook(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/webhook/:id",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.UpdateWebhook(),+ )+ appRouterGroup.AuthRouterGroup.DELETE(+ "/webhook/:id",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.DeleteWebhook(),+ )+ appRouterGroup.AuthRouterGroup.POST(+ "/webhook/:id/test",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.SettingHandler.TestWebhook(),@@ -0,0 +1,71 @@+package middleware++import (+ "net/http"++ "github.com/gin-gonic/gin"+ i18nUtil "github.com/lin-snow/ech0/internal/i18n"+ authModel "github.com/lin-snow/ech0/internal/model/auth"+ commonModel "github.com/lin-snow/ech0/internal/model/common"+ errUtil "github.com/lin-snow/ech0/internal/util/err"+ "github.com/lin-snow/ech0/pkg/viewer"+)++func RequireScopes(scopes ...string) gin.HandlerFunc {+ return func(ctx *gin.Context) {+ v := viewer.MustFromContext(ctx.Request.Context())+ if v.TokenType() == authModel.TokenTypeSession {+ ctx.Next()+ return+ }+ if v.TokenType() != authModel.TokenTypeAccess {+ ctx.JSON(+ http.StatusUnauthorized,+ commonModel.FailWithLocalized[any](+ i18nUtil.Localize(i18nUtil.LocalizerFromGin(ctx), commonModel.MsgKeyAuthTokenInvalid, errUtil.HandleError(&commonModel.ServerError{+ Msg: commonModel.TOKEN_NOT_VALID,+ Err: nil,+ }), nil),+ commonModel.ErrCodeTokenInvalid,+ commonModel.MsgKeyAuthTokenInvalid,+ nil,+ ),+ )+ ctx.Abort()+ return+ }+ if !containsAllScopes(v.Scopes(), scopes) {+ ctx.JSON(+ http.StatusForbidden,+ commonModel.FailWithLocalized[any](+ i18nUtil.Localize(i18nUtil.LocalizerFromGin(ctx), commonModel.MsgKeyCommonRequestFailed, errUtil.HandleError(&commonModel.ServerError{+ Msg: commonModel.NO_PERMISSION_DENIED,+ Err: nil,+ }), nil),+ commonModel.ErrCodePermissionDenied,+ commonModel.MsgKeyCommonRequestFailed,+ nil,+ ),+ )+ ctx.Abort()+ return+ }+ ctx.Next()+ }+}++func containsAllScopes(actual, required []string) bool {+ if len(required) == 0 {+ return true+ }+ set := make(map[string]struct{}, len(actual))+ for _, scope := range actual {+ set[scope] = struct{}{}+ }+ for _, requiredScope := range required {+ if _, ok := set[requiredScope]; !ok {+ return false+ }+ }+ return true+}@@ -34,13 +34,49 @@ func setupCommentRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { ) // Admin Panel- appRouterGroup.AuthRouterGroup.GET("/panel/comments", h.CommentHandler.ListPanelComments())- appRouterGroup.AuthRouterGroup.GET("/panel/comments/:id", h.CommentHandler.GetCommentByID())- appRouterGroup.AuthRouterGroup.PATCH("/panel/comments/:id/status", h.CommentHandler.UpdateCommentStatus())- appRouterGroup.AuthRouterGroup.PATCH("/panel/comments/:id/hot", h.CommentHandler.UpdateCommentHot())- appRouterGroup.AuthRouterGroup.DELETE("/panel/comments/:id", h.CommentHandler.DeleteComment())- appRouterGroup.AuthRouterGroup.POST("/panel/comments/batch", h.CommentHandler.BatchAction())- appRouterGroup.AuthRouterGroup.GET("/panel/comments/settings", h.CommentHandler.GetCommentSetting())- appRouterGroup.AuthRouterGroup.PUT("/panel/comments/settings", h.CommentHandler.UpdateCommentSetting())- appRouterGroup.AuthRouterGroup.POST("/panel/comments/settings/test-email", h.CommentHandler.TestCommentEmail())+ appRouterGroup.AuthRouterGroup.GET(+ "/panel/comments",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.ListPanelComments(),+ )+ appRouterGroup.AuthRouterGroup.GET(+ "/panel/comments/:id",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.GetCommentByID(),+ )+ appRouterGroup.AuthRouterGroup.PATCH(+ "/panel/comments/:id/status",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.UpdateCommentStatus(),+ )+ appRouterGroup.AuthRouterGroup.PATCH(+ "/panel/comments/:id/hot",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.UpdateCommentHot(),+ )+ appRouterGroup.AuthRouterGroup.DELETE(+ "/panel/comments/:id",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.DeleteComment(),+ )+ appRouterGroup.AuthRouterGroup.POST(+ "/panel/comments/batch",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.BatchAction(),+ )+ appRouterGroup.AuthRouterGroup.GET(+ "/panel/comments/settings",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.GetCommentSetting(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/panel/comments/settings",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.UpdateCommentSetting(),+ )+ appRouterGroup.AuthRouterGroup.POST(+ "/panel/comments/settings/test-email",+ middleware.RequireScopes(authModel.ScopeCommentMod),+ h.CommentHandler.TestCommentEmail(),+ ) }AI-assisted change 399183084a2d40b202264a21ab5433cdc5f7fe24d78176e6ff7157324a3aecbb · Fix 7ac8f63d920d2fc93467d509e000c13530354a61dc284e2ffe3eebb8fbfbe0a5
Advisory references