GHSA-FCRW-F7GG-6G9F

Vulnerability from github – Published: 2026-07-24 21:14 – Updated: 2026-08-12 18:57
VLAI
Summary
Budibase: SSO OAuth2 Token Leakage via User Metadata Endpoints to Power-Role Users
Details

Summary

The /api/users/metadata and /api/users/metadata/:id endpoints in @budibase/server return full global user profiles to any user with POWER role or above. For SSO-authenticated users (OIDC, Google), the response includes oauth2.accessToken and oauth2.refreshToken fields, leaking identity provider credentials to other users who should not have access to them.

Details

When a user authenticates via SSO (OIDC or Google), the OAuth2 tokens are stored in the global CouchDB user document at packages/backend-core/src/auth/auth.ts:170-173:

dbUser.oauth2 = {
  ...dbUser.oauth2,
  ...details,
}
await db.put(dbUser)

The user metadata endpoints are protected by PermissionType.USER, PermissionLevel.READ (packages/server/src/api/routes/user.ts:11), which maps to the POWER permission set (packages/backend-core/src/security/permissions.ts:99).

Path 1 — List all users (GET /api/users/metadata): 1. controller.fetchMetadata → sdk.users.fetchMetadata() (packages/server/src/sdk/users/utils.ts:78) 2. → getGlobalUsers() → getRawGlobalUsers() (packages/server/src/utilities/global.ts:101-122) — strips only password and forceResetPassword 3. → processUser() (packages/server/src/utilities/global.ts:15-76) — strips only password and roles 4. The oauth2 field containing accessToken and refreshToken is never removed

Path 2 — Single user (GET /api/users/metadata/:id): 1. controller.findMetadata → getFullUser() (packages/server/src/utilities/users.ts:6) 2. → getGlobalUser() → getRawGlobalUser() (packages/server/src/utilities/global.ts:90-92) — raw CouchDB fetch, no field stripping at all 3. → processUser() — strips only password and roles 4. Same result: oauth2 tokens are returned

There is no output sanitization middleware on these routes that would strip sensitive fields before the response reaches the client.

PoC

Prerequisites: A Budibase instance with at least one SSO-authenticated user (OIDC or Google) and a separate user with POWER role in an app.

Step 1 — As the POWER user, list all users:

curl -s -X GET 'http://localhost:10000/api/users/metadata' \
  -H 'Cookie: budibase:auth=<power-user-jwt>' \
  -H 'x-budibase-app-id: app_<appid>' | jq '.[].oauth2'

Expected output: null for all users (tokens should not be exposed)

Actual output: For SSO users, the response includes:

{
  "accessToken": "ya29.a0ARrdaM...",
  "refreshToken": "1//0eXxXxXxXx..."
}

Step 2 — Fetch a specific SSO user's profile:

curl -s -X GET 'http://localhost:10000/api/users/metadata/ro_ta_users_us_<sso-user-id>' \
  -H 'Cookie: budibase:auth=<power-user-jwt>' \
  -H 'x-budibase-app-id: app_<appid>' | jq '.oauth2'

This also returns the full OAuth2 tokens.

Impact

  • A user with POWER role in any app can read all SSO users' OAuth2 access tokens and refresh tokens via the list endpoint, without needing to know individual user IDs.
  • Stolen access tokens can be used to access external identity provider resources (Google Workspace, Azure AD, Okta-protected services) as the victim user.
  • Refresh tokens allow indefinite token renewal, persisting access even after the original access token expires.
  • Additionally, admin, builder, tenantId, ssoId, and userGroups fields are leaked, revealing the full authorization topology of the instance.

Recommended Fix

Strip sensitive SSO fields in processUser() at packages/server/src/utilities/global.ts:15:

export async function processUser(
  user: ContextUser,
  opts: { appId?: string; groups?: UserGroup[] } = {}
) {
  if (!user || (!user.roles && !user.userGroups)) {
    return user
  }
  user = cloneDeep(user)
  delete user.password
+ delete (user as any).oauth2
+ delete (user as any).provider
+ delete (user as any).providerType
+ delete (user as any).thirdPartyProfile
+ delete (user as any).profile
+ delete (user as any).ssoId
+ delete (user as any).forceResetPassword
  // ... rest of function

Additionally, getRawGlobalUsers() at line 101 should also strip oauth2 alongside its existing password/forceResetPassword stripping for defense in depth.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@budibase/server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.39.25"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-73304"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-200"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-24T21:14:31Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe `/api/users/metadata` and `/api/users/metadata/:id` endpoints in `@budibase/server` return full global user profiles to any user with POWER role or above. For SSO-authenticated users (OIDC, Google), the response includes `oauth2.accessToken` and `oauth2.refreshToken` fields, leaking identity provider credentials to other users who should not have access to them.\n\n## Details\n\nWhen a user authenticates via SSO (OIDC or Google), the OAuth2 tokens are stored in the global CouchDB user document at `packages/backend-core/src/auth/auth.ts:170-173`:\n\n```typescript\ndbUser.oauth2 = {\n  ...dbUser.oauth2,\n  ...details,\n}\nawait db.put(dbUser)\n```\n\nThe user metadata endpoints are protected by `PermissionType.USER, PermissionLevel.READ` (`packages/server/src/api/routes/user.ts:11`), which maps to the POWER permission set (`packages/backend-core/src/security/permissions.ts:99`).\n\n**Path 1 \u2014 List all users** (`GET /api/users/metadata`):\n1. `controller.fetchMetadata` \u2192 `sdk.users.fetchMetadata()` (`packages/server/src/sdk/users/utils.ts:78`)\n2. \u2192 `getGlobalUsers()` \u2192 `getRawGlobalUsers()` (`packages/server/src/utilities/global.ts:101-122`) \u2014 strips only `password` and `forceResetPassword`\n3. \u2192 `processUser()` (`packages/server/src/utilities/global.ts:15-76`) \u2014 strips only `password` and `roles`\n4. The `oauth2` field containing `accessToken` and `refreshToken` is **never removed**\n\n**Path 2 \u2014 Single user** (`GET /api/users/metadata/:id`):\n1. `controller.findMetadata` \u2192 `getFullUser()` (`packages/server/src/utilities/users.ts:6`)\n2. \u2192 `getGlobalUser()` \u2192 `getRawGlobalUser()` (`packages/server/src/utilities/global.ts:90-92`) \u2014 raw CouchDB fetch, **no field stripping at all**\n3. \u2192 `processUser()` \u2014 strips only `password` and `roles`\n4. Same result: `oauth2` tokens are returned\n\nThere is no output sanitization middleware on these routes that would strip sensitive fields before the response reaches the client.\n\n## PoC\n\n**Prerequisites:** A Budibase instance with at least one SSO-authenticated user (OIDC or Google) and a separate user with POWER role in an app.\n\n**Step 1 \u2014 As the POWER user, list all users:**\n```bash\ncurl -s -X GET \u0027http://localhost:10000/api/users/metadata\u0027 \\\n  -H \u0027Cookie: budibase:auth=\u003cpower-user-jwt\u003e\u0027 \\\n  -H \u0027x-budibase-app-id: app_\u003cappid\u003e\u0027 | jq \u0027.[].oauth2\u0027\n```\n\n**Expected output:** `null` for all users (tokens should not be exposed)\n\n**Actual output:** For SSO users, the response includes:\n```json\n{\n  \"accessToken\": \"ya29.a0ARrdaM...\",\n  \"refreshToken\": \"1//0eXxXxXxXx...\"\n}\n```\n\n**Step 2 \u2014 Fetch a specific SSO user\u0027s profile:**\n```bash\ncurl -s -X GET \u0027http://localhost:10000/api/users/metadata/ro_ta_users_us_\u003csso-user-id\u003e\u0027 \\\n  -H \u0027Cookie: budibase:auth=\u003cpower-user-jwt\u003e\u0027 \\\n  -H \u0027x-budibase-app-id: app_\u003cappid\u003e\u0027 | jq \u0027.oauth2\u0027\n```\n\nThis also returns the full OAuth2 tokens.\n\n## Impact\n\n- A user with POWER role in any app can read **all SSO users\u0027 OAuth2 access tokens and refresh tokens** via the list endpoint, without needing to know individual user IDs.\n- Stolen access tokens can be used to access external identity provider resources (Google Workspace, Azure AD, Okta-protected services) as the victim user.\n- Refresh tokens allow indefinite token renewal, persisting access even after the original access token expires.\n- Additionally, `admin`, `builder`, `tenantId`, `ssoId`, and `userGroups` fields are leaked, revealing the full authorization topology of the instance.\n\n## Recommended Fix\n\nStrip sensitive SSO fields in `processUser()` at `packages/server/src/utilities/global.ts:15`:\n\n```typescript\nexport async function processUser(\n  user: ContextUser,\n  opts: { appId?: string; groups?: UserGroup[] } = {}\n) {\n  if (!user || (!user.roles \u0026\u0026 !user.userGroups)) {\n    return user\n  }\n  user = cloneDeep(user)\n  delete user.password\n+ delete (user as any).oauth2\n+ delete (user as any).provider\n+ delete (user as any).providerType\n+ delete (user as any).thirdPartyProfile\n+ delete (user as any).profile\n+ delete (user as any).ssoId\n+ delete (user as any).forceResetPassword\n  // ... rest of function\n```\n\nAdditionally, `getRawGlobalUsers()` at line 101 should also strip `oauth2` alongside its existing `password`/`forceResetPassword` stripping for defense in depth.",
  "id": "GHSA-fcrw-f7gg-6g9f",
  "modified": "2026-08-12T18:57:12Z",
  "published": "2026-07-24T21:14:31Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/security/advisories/GHSA-fcrw-f7gg-6g9f"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/pull/19110"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/commit/7b8ba11a2c8b233c35e7728dd752dba25ef919a4"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/commit/80a31f6c3354620aa90e50af8a2c614333084621"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Budibase/budibase"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/releases/tag/3.39.25"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": " Budibase: SSO OAuth2 Token Leakage via User Metadata Endpoints to Power-Role Users"
}



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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…