GitHub Security Advisory: GHSA-4h9q-p5j4-xvvh
Package: github.com/lin-snow/ech0 (GO)
Severity: HIGH (CVSS 7.6)
CWE: CWE-285: Improper Authorization
Vulnerable versions: < 4.3.5
Patched in: 4.3.5

Summary: Ech0: Scoped admin access tokens can bypass least-privilege controls on privileged endpoints, including backup export

Details:
## Summary

Ech0 scoped access tokens do not reliably enforce least privilege: multiple privileged admin routes omit scope checks, and the backup export handler strips token scope metadata entirely, allowing a low-scope admin access token to reach broader admin functionality than intended.

## Impact

An attacker who obtains a deliberately limited access token for an admin account can use that token to access privileged functionality outside its assigned scope. Confirmed impact includes access to `/api/inbox` with a token scoped only for `echo:read` and successful backup export via `/api/backup/export?token=...`, which returns a full ZIP archive. In practice, this turns a narrowly delegated API token into a broader privileged access and data exfiltration primitive.

## Details

The issue is caused by a split authorization model:

- `JWTAuthMiddleware()` authenticates the token and stores scope metadata in the viewer context
- `RequireScopes(...)` enforces least privilege, but only when a route explicitly adds it
- several privileged routes omit `RequireScopes(...)`
- multiple service methods then authorize using only `user.IsAdmin`

`internal/middleware/scope.go` shows that scope enforcement is opt-in:

```go
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 { ... }
		if !containsValidAudience(v.Audience()) { ... }
		if !containsAllScopes(v.Scopes(), scopes) { ... }
		ctx.Next()
	}
}
```

Representative privileged routes omit `RequireScopes(...)`, for example `internal/router/inbox.go`:

```go
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())
}
```

Other source-confirmed unguarded privileged surfaces include:

- `/api/panel/comments*`
- `/api/addConnect`
- `/api/delConnect/:id`
- `/api/migration/*`
- `/api/backup/export`

Service-layer authorization often checks only admin role. For example, `internal/service/inbox/inbox.go`:

```go
func (inboxService *InboxService) ensureAdmin(ctx context.Context) error {
	userid := viewer.MustFromContext(ctx).UserID()
	user, err := inboxService.commonService.CommonGetUserByUserId(ctx, userid)
	if err != nil {
		return err
	}
	if !user.IsAdmin {
		return errors.New(commonModel.NO_PERMISSION_DENIED)
	}
	return nil
}
```

The backup export path is a stronger variant because it discards token metadata before authorization. `internal/handler/backup/backup.go` reparses a q