Root cause
Authentication & access control
feat(setting): add scoped access token schema and validation
How AI contributed
Flawed AI-written codeEch0 version 4.3.4 and earlier fails to reliably enforce scoped access token (least-privilege) restrictions on several privileged admin routes. Multiple privileged endpoints (e.g., /api/inbox, /api/panel/comments, /api/backup/export) omit scope checks and authorize based only on the user's admin role, and the backup export handler discards token scope metadata entirely. An attacker holding a deliberately limited (low-scope) admin access token can reach broader privileged functionality than intended, including reading the inbox and exporting a full database backup ZIP archive. Fixed in 4.4.3.
Ech0's scoped access tokens promised least privilege but privileged authorization was split among JWT authentication, opt-in RequireScopes route middleware, and service-level checks of the underlying account's IsAdmin bit. A deliberately narrow admin access token, such as echo:read, therefore authenticated to privileged routes that omitted RequireScopes, including /api/inbox and panel-comment routes. The public /api/backup/export path additionally reparsed a query JWT and rebuilt viewer state from only claims.Userid, discarding token type, scopes, audience, and JTI, so the backup service saw an unrestricted admin identity and returned the database/log ZIP.
Root cause
feat(setting): add scoped access token schema and validation
Fix
refactor(router): add middleware for authorization scopes on various routes
Fix by L1nSn0w · no AI marker found
@@ -0,0 +1,48 @@+package service++import (+ "testing"++ authModel "github.com/lin-snow/ech0/internal/model/auth"+ model "github.com/lin-snow/ech0/internal/model/setting"+ userModel "github.com/lin-snow/ech0/internal/model/user"+)++func TestCreateAccessToken_RejectsUnknownScope(t *testing.T) {+ user := userModel.User{IsAdmin: true}+ dto := &model.AccessTokenSettingDto{+ Name: "bad-scope",+ Expiry: model.EIGHT_HOUR_EXPIRY,+ Scopes: []string{"admin:root"},+ Audience: authModel.AudiencePublic,+ }+ if err := validateAccessTokenRequest(user, dto); err == nil {+ t.Fatal("expected error for unknown scope")+ }+}++func TestCreateAccessToken_RejectsUnknownAudience(t *testing.T) {+ user := userModel.User{IsAdmin: true}+ dto := &model.AccessTokenSettingDto{+ Name: "bad-audience",+ Expiry: model.EIGHT_HOUR_EXPIRY,+ Scopes: []string{authModel.ScopeEchoRead},+ Audience: "unknown-client",+ }+ if err := validateAccessTokenRequest(user, dto); err == nil {+ t.Fatal("expected error for unknown audience")+ }+}++func TestCreateAccessToken_RejectsAdminScopeForNonAdminUser(t *testing.T) {+ user := userModel.User{IsAdmin: false}+ dto := &model.AccessTokenSettingDto{+ Name: "bad-admin-scope",+ Expiry: model.EIGHT_HOUR_EXPIRY,+ Scopes: []string{authModel.ScopeAdminSettings},+ Audience: authModel.AudiencePublic,+ }+ if err := validateAccessTokenRequest(user, dto); err == nil {+ t.Fatal("expected error for admin scope on non-admin user")+ }+}@@ -2,12 +2,17 @@ package service import ( "context"+ "encoding/json" "errors"+ "strings" "time" + authModel "github.com/lin-snow/ech0/internal/model/auth" commonModel "github.com/lin-snow/ech0/internal/model/common" model "github.com/lin-snow/ech0/internal/model/setting"+ userModel "github.com/lin-snow/ech0/internal/model/user" jwtUtil "github.com/lin-snow/ech0/internal/util/jwt"+ uuidUtil "github.com/lin-snow/ech0/internal/util/uuid" "github.com/lin-snow/ech0/pkg/viewer" ) @@ -63,9 +68,19 @@ func (settingService *SettingService) CreateAccessToken( if !user.IsAdmin { return "", errors.New(commonModel.NO_PERMISSION_DENIED) }+ if err := validateAccessTokenRequest(user, newToken); err != nil {+ return "", err+ } name := newToken.Name expiry := newToken.Expiry+ audience := newToken.Audience+ scopes := normalizeScopes(newToken.Scopes)+ scopeJSON, err := json.Marshal(scopes)+ if err != nil {+ return "", err+ }+ jti := uuidUtil.MustNewV7() var expiryDuration time.Duration switch expiry {@@ -80,7 +95,7 @@ func (settingService *SettingService) CreateAccessToken( } // 生成jwt令牌- claims := jwtUtil.CreateClaimsWithExpiry(user, int64(expiryDuration))+ claims := jwtUtil.CreateAccessClaimsWithExpiry(user, int64(expiryDuration), scopes, audience, jti) tokenString, err := jwtUtil.GenerateToken(claims) if err != nil { return "", err@@ -100,6 +115,10 @@ func (settingService *SettingService) CreateAccessToken( UserID: user.ID, Token: tokenString, Name: name,+ TokenType: authModel.TokenTypeAccess,+ Scopes: string(scopeJSON),+ Audience: audience,+ JTI: jti, Expiry: expiryPtr, CreatedAt: time.Now().UTC(), }@@ -113,6 +132,43 @@ func (settingService *SettingService) CreateAccessToken( return tokenString, nil } +func validateAccessTokenRequest(user userModel.User, dto *model.AccessTokenSettingDto) error {+ if dto == nil {+ return errors.New(commonModel.INVALID_PARAMS_BODY)+ }+ if strings.TrimSpace(dto.Name) == "" {+ return errors.New(commonModel.INVALID_PARAMS_BODY)+ }+ if !authModel.IsValidAudience(dto.Audience) {+ return errors.New(commonModel.INVALID_PARAMS_BODY)+ }+ if len(dto.Scopes) == 0 {+ return errors.New(commonModel.INVALID_PARAMS_BODY)+ }+ for _, scope := range dto.Scopes {+ if !authModel.IsValidScope(scope) {+ return errors.New(commonModel.INVALID_PARAMS_BODY)+ }+ }+ if authModel.HasAdminScope(dto.Scopes) && !user.IsAdmin {+ return errors.New(commonModel.NO_PERMISSION_DENIED)+ }+ return nil+}++func normalizeScopes(scopes []string) []string {+ seen := make(map[string]struct{}, len(scopes))+ result := make([]string, 0, len(scopes))+ for _, scope := range scopes {@@ -72,8 +72,10 @@ type WebhookDto struct { } type AccessTokenSettingDto struct {- Name string `json:"name"` // 访问令牌名称- Expiry string `json:"expiry"` // 访问令牌过期策略(8_hours/1_month/never)+ Name string `json:"name"` // 访问令牌名称+ Expiry string `json:"expiry"` // 访问令牌过期策略(8_hours/1_month/never)+ Scopes []string `json:"scopes"` // 访问令牌权限范围+ Audience string `json:"audience"` // 访问令牌受众(public-client/cli/integration) } type BackupScheduleDto struct {@@ -1,15 +1,10 @@ package handler import (- "context"- "strings"- "github.com/gin-gonic/gin" res "github.com/lin-snow/ech0/internal/handler/response" commonModel "github.com/lin-snow/ech0/internal/model/common" service "github.com/lin-snow/ech0/internal/service/backup"- jwtUtil "github.com/lin-snow/ech0/internal/util/jwt"- "github.com/lin-snow/ech0/pkg/viewer" ) type BackupHandler struct {@@ -35,27 +30,7 @@ func NewBackupHandler(backupService service.Service) *BackupHandler { // @Router /backup/export [get] func (backupHandler *BackupHandler) ExportBackup() gin.HandlerFunc { return res.Execute(func(ctx *gin.Context) res.Response {- token := ctx.Query("token")- if token == "" {- return res.Response{- Msg: commonModel.INVALID_REQUEST_BODY,- }- }-- token = strings.Trim(token, `"`) // 去掉可能的双引号-- // 使用 JWT Util进行处理- claims, err := jwtUtil.ParseToken(token)- if err != nil {- return res.Response{- Msg: commonModel.TOKEN_NOT_VALID,- Err: err,- }- }-- // 从 Claims中提取 UserID,注入 viewer 上下文供 service 鉴权。- reqCtx := viewer.WithContext(context.Background(), viewer.NewUserViewer(claims.Userid))- if err := backupHandler.backupService.ExportBackup(ctx, reqCtx); err != nil {+ if err := backupHandler.backupService.ExportBackup(ctx, ctx.Request.Context()); err != nil { return res.Response{ Msg: "", Err: err,@@ -1,12 +1,36 @@ 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"+) // setupInboxRoutes 配置收件箱相关路由 func setupInboxRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {- appRouterGroup.AuthRouterGroup.GET("/inbox", h.InboxHandler.GetInboxList())- appRouterGroup.AuthRouterGroup.GET("/inbox/unread", h.InboxHandler.GetUnreadInbox())- appRouterGroup.AuthRouterGroup.PUT("/inbox/:id/read", h.InboxHandler.MarkInboxAsRead())- appRouterGroup.AuthRouterGroup.DELETE("/inbox/:id", h.InboxHandler.DeleteInbox())- appRouterGroup.AuthRouterGroup.DELETE("/inbox", h.InboxHandler.ClearInbox())+ appRouterGroup.AuthRouterGroup.GET(+ "/inbox",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.InboxHandler.GetInboxList(),+ )+ appRouterGroup.AuthRouterGroup.GET(+ "/inbox/unread",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.InboxHandler.GetUnreadInbox(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/inbox/:id/read",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.InboxHandler.MarkInboxAsRead(),+ )+ appRouterGroup.AuthRouterGroup.DELETE(+ "/inbox/:id",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.InboxHandler.DeleteInbox(),+ )+ appRouterGroup.AuthRouterGroup.DELETE(+ "/inbox",+ middleware.RequireScopes(authModel.ScopeAdminSettings),+ h.InboxHandler.ClearInbox(),+ ) }@@ -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 47540bc3def1f6b45789c33081953e8a49bf783777fbc8e016c2ca355f04963c · Fix 1f184b2143f832591f19e7b6ce6d14a4edd92a31196c31587216e76ecca63592
Advisory references