Root cause
Authentication & access control introduced
feat(authz): enforce route-level scope checks for access tokens
How AI contributed: Direct introductionPublished Apr 10, 2026 in
lin-snow/Ech0All 3 findings in this repository
Ech0 before 4.4.3 protects the PUT /user endpoint with the profile:read scope, a read-only scope, but allows write operations including password changes. An attacker with an admin's profile:read access token can change the admin's password and login to obtain an unrestricted session token that bypasses all scope enforcement.
A scoped access token carrying only profile:read is accepted by PUT /user because that mutating route is guarded by RequireScopes(ScopeProfileRead). UpdateUser uses the token viewer's administrator identity and permits a supplied password to replace that administrator's password. The attacker can then log in with the replacement password; login issues a session JWT, and RequireScopes deliberately bypasses scope checks for session tokens, yielding unrestricted administrator access.
Root cause
feat(authz): enforce route-level scope checks for access tokens
Fix
refactor: remove inbox-related functionality and references
Fix by L1nSn0w · no AI marker found
Case facts
@@ -3,6 +3,7 @@ package router import ( "github.com/lin-snow/ech0/internal/handler" "github.com/lin-snow/ech0/internal/middleware"+ authModel "github.com/lin-snow/ech0/internal/model/auth" ) // setupUserRoutes 设置用户路由@@ -26,22 +27,64 @@ func setupUserRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { ) // Auth- appRouterGroup.AuthRouterGroup.GET("/allusers", h.UserHandler.GetAllUsers())- appRouterGroup.AuthRouterGroup.GET("/user", h.UserHandler.GetUserInfo())- appRouterGroup.AuthRouterGroup.PUT("/user", h.UserHandler.UpdateUser())- appRouterGroup.AuthRouterGroup.DELETE("/user/:id", h.UserHandler.DeleteUser())- appRouterGroup.AuthRouterGroup.PUT("/user/admin/:id", h.UserHandler.UpdateUserAdmin())- appRouterGroup.AuthRouterGroup.POST("/oauth/:provider/bind", h.UserHandler.OAuthBind())- appRouterGroup.AuthRouterGroup.GET("/oauth/info", h.UserHandler.GetOAuthInfo())+ appRouterGroup.AuthRouterGroup.GET(+ "/allusers",+ middleware.RequireScopes(authModel.ScopeAdminUser),+ h.UserHandler.GetAllUsers(),+ )+ appRouterGroup.AuthRouterGroup.GET(+ "/user",+ middleware.RequireScopes(authModel.ScopeProfileRead),+ h.UserHandler.GetUserInfo(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/user",+ middleware.RequireScopes(authModel.ScopeProfileRead),+ h.UserHandler.UpdateUser(),+ )+ appRouterGroup.AuthRouterGroup.DELETE(+ "/user/:id",+ middleware.RequireScopes(authModel.ScopeAdminUser),+ h.UserHandler.DeleteUser(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/user/admin/:id",+ middleware.RequireScopes(authModel.ScopeAdminUser),+ h.UserHandler.UpdateUserAdmin(),+ )+ appRouterGroup.AuthRouterGroup.POST(+ "/oauth/:provider/bind",+ middleware.RequireScopes(authModel.ScopeProfileRead),+ h.UserHandler.OAuthBind(),+ )+ appRouterGroup.AuthRouterGroup.GET(+ "/oauth/info",+ middleware.RequireScopes(authModel.ScopeProfileRead),+ h.UserHandler.GetOAuthInfo(),+ ) appRouterGroup.AuthRouterGroup.POST( "/passkey/register/begin",+ middleware.RequireScopes(authModel.ScopeProfileRead), h.UserHandler.PasskeyRegisterBeginV2(), ) appRouterGroup.AuthRouterGroup.POST( "/passkey/register/finish",+ middleware.RequireScopes(authModel.ScopeProfileRead), h.UserHandler.PasskeyRegisterFinishV2(), )- appRouterGroup.AuthRouterGroup.GET("/passkeys", h.UserHandler.ListPasskeys())- appRouterGroup.AuthRouterGroup.DELETE("/passkeys/:id", h.UserHandler.DeletePasskey())- appRouterGroup.AuthRouterGroup.PUT("/passkeys/:id", h.UserHandler.UpdatePasskeyDeviceName())+ appRouterGroup.AuthRouterGroup.GET(+ "/passkeys",+ middleware.RequireScopes(authModel.ScopeProfileRead),+ h.UserHandler.ListPasskeys(),+ )+ appRouterGroup.AuthRouterGroup.DELETE(+ "/passkeys/:id",+ middleware.RequireScopes(authModel.ScopeProfileRead),+ h.UserHandler.DeletePasskey(),+ )+ appRouterGroup.AuthRouterGroup.PUT(+ "/passkeys/:id",+ middleware.RequireScopes(authModel.ScopeProfileRead),+ h.UserHandler.UpdatePasskeyDeviceName(),+ ) }@@ -16,6 +16,7 @@ const ( ScopeConnectRead = "connect:read" ScopeConnectWrite = "connect:write" ScopeProfileRead = "profile:read"+ ScopeProfileWrite = "profile:write" ScopeAdminSettings = "admin:settings" ScopeAdminUser = "admin:user" ScopeAdminToken = "admin:token"@@ -36,6 +37,7 @@ var validScopes = map[string]struct{}{ ScopeConnectRead: {}, ScopeConnectWrite: {}, ScopeProfileRead: {},+ ScopeProfileWrite: {}, ScopeAdminSettings: {}, ScopeAdminUser: {}, ScopeAdminToken: {},@@ -74,6 +74,65 @@ func TestRequireScopes_ReturnsAudienceForbiddenCode(t *testing.T) { } } +func TestRequireScopes_ProfileReadCannotAccessProfileWrite(t *testing.T) {+ gin.SetMode(gin.TestMode)+ r := gin.New()+ r.Use(func(c *gin.Context) {+ viewer.AttachToRequest(+ &c.Request,+ viewer.NewUserViewerWithToken(+ "user-1",+ authModel.TokenTypeAccess,+ []string{authModel.ScopeProfileRead},+ []string{authModel.AudiencePublic},+ "jti-profile-write-test",+ ),+ )+ c.Next()+ })+ r.PUT("/user", RequireScopes(authModel.ScopeProfileWrite), func(c *gin.Context) {+ c.Status(http.StatusOK)+ })++ req := httptest.NewRequest(http.MethodPut, "/user", nil)+ rec := httptest.NewRecorder()+ r.ServeHTTP(rec, req)+ if rec.Code != http.StatusForbidden {+ t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)+ }+ if got := parseErrorCode(rec.Body.Bytes()); got != commonModel.ErrCodeScopeForbidden {+ t.Fatalf("expected error code %s, got %s", commonModel.ErrCodeScopeForbidden, got)+ }+}++func TestRequireScopes_ProfileWriteAllowsAccess(t *testing.T) {+ gin.SetMode(gin.TestMode)+ r := gin.New()+ r.Use(func(c *gin.Context) {+ viewer.AttachToRequest(+ &c.Request,+ viewer.NewUserViewerWithToken(+ "user-1",+ authModel.TokenTypeAccess,+ []string{authModel.ScopeProfileWrite},+ []string{authModel.AudiencePublic},+ "jti-profile-write-ok",+ ),+ )+ c.Next()+ })+ r.PUT("/user", RequireScopes(authModel.ScopeProfileWrite), func(c *gin.Context) {+ c.Status(http.StatusOK)+ })++ req := httptest.NewRequest(http.MethodPut, "/user", nil)+ rec := httptest.NewRecorder()+ r.ServeHTTP(rec, req)+ if rec.Code != http.StatusOK {+ t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)+ }+}+ func parseErrorCode(body []byte) string { var payload struct { ErrorCode string `json:"error_code"`@@ -39,7 +39,7 @@ func setupUserRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { ) appRouterGroup.AuthRouterGroup.PUT( "/user",- middleware.RequireScopes(authModel.ScopeProfileRead),+ middleware.RequireScopes(authModel.ScopeProfileWrite), h.UserHandler.UpdateUser(), ) appRouterGroup.AuthRouterGroup.DELETE(@@ -54,7 +54,7 @@ func setupUserRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { ) appRouterGroup.AuthRouterGroup.POST( "/oauth/:provider/bind",- middleware.RequireScopes(authModel.ScopeProfileRead),+ middleware.RequireScopes(authModel.ScopeProfileWrite), h.UserHandler.OAuthBind(), ) appRouterGroup.AuthRouterGroup.GET(@@ -64,12 +64,12 @@ func setupUserRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { ) appRouterGroup.AuthRouterGroup.POST( "/passkey/register/begin",- middleware.RequireScopes(authModel.ScopeProfileRead),+ middleware.RequireScopes(authModel.ScopeProfileWrite), h.UserHandler.PasskeyRegisterBeginV2(), ) appRouterGroup.AuthRouterGroup.POST( "/passkey/register/finish",- middleware.RequireScopes(authModel.ScopeProfileRead),+ middleware.RequireScopes(authModel.ScopeProfileWrite), h.UserHandler.PasskeyRegisterFinishV2(), ) appRouterGroup.AuthRouterGroup.GET(@@ -79,12 +79,12 @@ func setupUserRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { ) appRouterGroup.AuthRouterGroup.DELETE( "/passkeys/:id",- middleware.RequireScopes(authModel.ScopeProfileRead),+ middleware.RequireScopes(authModel.ScopeProfileWrite), h.UserHandler.DeletePasskey(), ) appRouterGroup.AuthRouterGroup.PUT( "/passkeys/:id",- middleware.RequireScopes(authModel.ScopeProfileRead),+ middleware.RequireScopes(authModel.ScopeProfileWrite), h.UserHandler.UpdatePasskeyDeviceName(), ) }AI-assisted change a6b0caf576157ad0b855229585984f44422f0b63ea68e5183d0cb204cfd87ade · Fix 8a3746d6ebf9d82ed213b8b49a969062df885759399b6d56bba6d04096d3cb9c