{"id":"GHSA-pvcr-8mvp-w8qr","title":" Budibase: Chat-Link Handoff Identity Confusion (Same-Tenant Account-Link CSRF)","summary":" Budibase: Chat-Link Handoff Identity Confusion (Same-Tenant Account-Link CSRF)","severity":"high","cvss":7.7,"cwe":["CWE-285","CWE-345","CWE-352"],"vendor":"budibase","product":"@budibase/server","ecosystem":"npm","affected":["@budibase/server <= 3.38.1"],"published":"2026-07-24","updated":"2026-07-24","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-pvcr-8mvp-w8qr","references":[{"url":"https://github.com/Budibase/budibase/security/advisories/GHSA-pvcr-8mvp-w8qr"},{"url":"https://github.com/Budibase/budibase/pull/19194"},{"url":"https://github.com/Budibase/budibase/commit/362e6c654fb4da6e123c734333da2d6cbdcdb7ef"},{"url":"https://github.com/Budibase/budibase/releases/tag/3.39.30"},{"url":"https://github.com/advisories/GHSA-pvcr-8mvp-w8qr"}],"tags":["ghsa","npm"],"ingestedAt":"2026-07-24T22:40:27.481Z","slug":"GHSA-pvcr-8mvp-w8qr","body":"## Overview\n\n### Summary\n\nThe Budibase AI chat-link handoff flow (`GET/POST /api/chat-links/:instance/:token/handoff`) binds an **external chat identity** (Slack/Discord/MS Teams/Telegram) to a **Budibase user account**. The confirmation endpoint is on a **public route** (no CSRF middleware, no auth-group gate) and the only credential it checks is a `confirmationToken` that is **already rendered in plaintext into the HTML confirmation page** the victim views. There is no binding between the confirmation token and the requester's Budibase session at preparation time, and no CSRF token on the POST.\n\nConsequently, an attacker who creates a chat-link session for **their own** external chat identity (or any identity they can mint in their chat platform) can induce a victim Budibase user (same tenant) to submit the confirmation POST -> for example by sending them a link that auto-submits, or by XSS/CSRF on a co-tenanted page -> and the victim's `globalUserId` becomes bound to the attacker's external identity. The attacker then sends messages to the AI agent from their chat platform and is **acting as the victim user** inside Budibase automations/agent operations, inheriting the victim's permissions on agent operations, knowledge sources, and any downstream automation steps keyed off the linked identity.\n\n---\n\n### Affected\n\n| Component | Path | Lines |\n|---|---|---|\n| Public handoff routes (no auth-group middleware) | `packages/server/src/api/routes/chat.ts` | `23` (`GET /api/chat-links/:instance/:token/handoff`), `27` (`POST .../handoff`) |\n| Confirmation controller | `packages/server/src/api/controllers/ai/chatIdentityLinks.ts` | `124-177` (`confirmChatLinkSession`); the binding at `153-173` |\n| Confirmation token rendered into HTML | `packages/server/src/api/controllers/ai/chatIdentityLinks.ts` | `40-60` (`renderLinkConfirmationPage`); the hidden input at `55` |\n| Session creation (attacker side) | `packages/server/src/sdk/workspace/ai/chatIdentityLinks.ts` | `138-171` (`createChatIdentityLinkSession`), `173-188` (`prepareChatIdentityLinkSessionConfirmation`) |\n| Upsert of identity link | `packages/server/src/sdk/workspace/ai/chatIdentityLinks.ts` | `201-250` (`upsertChatIdentityLink`) |\n\n**Affected versions:** `master` at commit `3c8d1b4023`.\n\n**Reachable over HTTP by:** the GET and POST are on `publicRoutes` (no auth-group middleware). The POST requires `ctx.isAuthenticated` at runtime (`controllers/ai/chatIdentityLinks.ts:142`) -> so the victim must be logged into Budibase when the POST fires (achievable via standard CSRF if cookies are sent cross-origin, or via phishing that lures the victim to submit).\n\n---\n\n### Root cause\n\n**Issue 1 -> The handoff routes are public and unauthenticated at the middleware layer.**\n\n```ts\n// packages/server/src/api/routes/chat.ts:23, 27\npublicRoutes.get(\"/api/chat-links/:instance/:token/handoff\", ai.handoffChatLinkSession)\npublicRoutes.post(\"/api/chat-links/:instance/:token/handoff\", ai.confirmChatLinkSession)\n```\n\n`publicRoutes` has **no group middleware** (`endpointGroups/standard.ts:24-25` calls `endpointGroupList.group()` with no middleware and then `.lockMiddleware()`). The routes do not have a per-route `authorized(...)`. There is **no CSRF middleware** on these routes -> Budibase's CSRF synchroniser token is enforced only for state-changing verbs on authenticated routes that are not in `NO_CSRF_ENDPOINTS`; the public-routes path bypasses it.\n\n**Issue 2 -> The confirmation token is exposed to anyone who views the GET page.**\n\n```ts\n// packages/server/src/api/controllers/ai/chatIdentityLinks.ts:40-60\nconst renderLinkConfirmationPage = (session, action) => {\n  ...\n  return `<!doctype html>...\n    <form method=\"post\" action=\"${helpers.escapeHtml(action)}\">\n      <input type=\"hidden\" name=\"confirmationToken\" value=\"${helpers.escapeHtml(session.confirmationToken)}\">\n      <button type=\"submit\">Confirm</button>\n    </form>\n  ...`\n}\n```\n\nThe `confirmationToken` (a `newid()` UUIDv4 generated by `prepareChatIdentityLinkSessionConfirmation`) is rendered into a hidden form input. Anyone who loads the GET page sees the token in the page source.\n\n**Issue 3 -> The POST binds the **currently-authenticated user** to the external identity based solely on the confirmation token.**\n\n```ts\n// packages/server/src/api/controllers/ai/chatIdentityLinks.ts:124-177\nexport async function confirmChatLinkSession(ctx) {\n  ...\n  if (!ctx.isAuthenticated) { throw new HTTPError(\"Authentication is required to link chat identity\", 401) }\n  if (!session.confirmationToken || ctx.request.body?.confirmationToken !== session.confirmationToken) {\n    throw new HTTPError(\"Link confirmation is invalid or has expired\", 400)\n  }\n\n  const currentGlobalUserId = getCurrentGlobalUserId(ctx)        // <- victim's ID\n  const consumedSession = await sdk.ai.chatIdentityLinks.consumeChatIdentityLinkSession(token)\n  ...\n  await sdk.ai.chatIdentityLinks.upsertChatIdentityLink({\n    provider: consumedSession.provider,\n    externalUserId: consumedSession.externalUserId,              // <- attacker's chat identity\n    externalUserName: consumedSession.externalUserName,\n    ...\n    globalUserId: currentGlobalUserId,                            // <- bound to victim\n    linkedBy: currentGlobalUserId,\n  })\n  ...\n}\n```\n\nThere is **no binding between the session and the requester's Budibase identity at preparation time**. `prepareChatIdentityLinkSessionConfirmation` (`sdk/workspace/ai/chatIdentityLinks.ts:173-188`) stores the `confirmationToken` keyed by `token` with no user-id field. Whoever is authenticated when the POST fires (and supplies the correct `confirmationToken`) gets bound.\n\n**Issue 4 -> `assertSessionMatchesInstance` only checks workspace ID, not user.**\n\n```ts\n// packages/server/src/api/controllers/ai/chatIdentityLinks.ts:17-27\nconst assertSessionMatchesInstance = ({ workspaceId, instance }) => {\n  if (!workspaceId || workspaceId !== instance) {\n    throw new HTTPError(\"Link token is not valid for this workspace\", 400)\n  }\n}\n```\n\nThe check confirms the session belongs to the same workspace as the URL `instance` param -> useful for preventing cross-workspace confusion but useless against same-tenant identity confusion.\n\n---\n\n### Reproduction\n\n**Step-by-step attack**\n\n1. **Attacker (tenant T) provisions a chat-link session for their own external identity.** Via the agent channel provisioning flow (e.g. `POST /api/agent/:agentId/slack/provision` or via the Discord/MS Teams/Telegram provisioning endpoints), the attacker obtains a chat-link `token` bound to **their own** Slack user ID. The session is stored in Redis keyed by `token`, scoped to tenant T.\n\n2. **Attacker triggers `prepareChatIdentityLinkSessionConfirmation`.** Either by GETting the handoff page themselves (if they have a Budibase session) or via an internal API. The `confirmationToken` is generated and stored.\n\n3. **Attacker crafts a phishing/auto-submit page** that POSTs to `/api/chat-links/<instance>/<token>/handoff` with the leaked `confirmationToken` in the body. Example:\n\n   ```html\n   <form id=\"f\" method=\"post\" action=\"https://victim.budibase.app/api/chat-links/app_xxx/linktoken/handoff\">\n     <input name=\"confirmationToken\" value=\"<leaked-token>\">\n   </form>\n   <script>document.getElementById('f').submit()</script>\n   ```\n\n4. **Victim (a Budibase user in tenant T, e.g. an admin) is lured to the attacker page** while logged into Budibase. Their browser sends the POST cross-origin. If the Budibase auth cookie is sent on cross-site requests (`SameSite=Lax` by default allows top-level POST navigations, which a form-submit is), the request is authenticated as the victim.\n\n5. **The victim's `globalUserId` is now bound to the attacker's Slack identity.** When the attacker DMs the AI agent from Slack, the agent's operations run as the victim user -> with the victim's permissions on agent operations, knowledge sources, file uploads, and any downstream automation steps keyed off the linked identity.\n\n**Mitigating factors:**\n- `SameSite=Lax` cookies block cross-site POSTs from sub-resources (fetch / XHR) but **allow** top-level form-POST navigations. A phishing page that submits the form via `document.form.submit()` (a top-level navigation) succeeds. So the CSRF works with the default cookie policy.\n- The attack is **same-tenant only** (`session.tenantId !== context.getTenantId()` is checked in the sdk).\n- The attacker must induce the victim to click a link (standard phishing). No silent drive-by.\n\n---\n\n### Impact\n\n| Capability | Available |\n|---|---|\n| Bind an attacker-controlled chat identity to a victim's Budibase account | ✅ |\n| Send messages to the AI agent as the victim user (Slack/Discord/MS Teams/Telegram) | ✅ |\n| Inherit the victim's permissions on agent operations | ✅ |\n| Trigger automations / agent operations that the victim is authorised for | ✅ |\n| Read knowledge sources the victim has access to | ✅ |\n\nThe severity depends on what the agent can do as the victim. For an admin victim, this is full tenant administration via chat. For a regular user, it is impersonation within the agent subsystem. The bound identity persists until manually unlinked, so the attacker retains ongoing access.\n\nThis is a same-tenant, user-interaction-required primitive. It is below the \"unauthenticated RCE\" threshold but above \"hardening\" -> it is a real authentication-confusion vulnerability on a sensitive identity-binding operation.\n\n---\n\n### Fix\n\n**Recommended layered fixes:**\n\n1. **Bind the confirmation token to the requester's Budibase session at preparation time.** In `prepareChatIdentityLinkSessionConfirmation`, store the `globalUserId` of the requester alongside the `confirmationToken`. In `confirmChatLinkSession`, verify that `getCurrentGlobalUserId(ctx)` matches the stored requester. This breaks the CSRF / cross-user confusion.\n\n2. **Add a CSRF token to the confirmation POST.** The standard Budibase CSRF synchroniser token (`x-csrf-token` header, validated against the session's `csrfToken`) should be enforced on `POST /api/chat-links/.../handoff`. Move the route out of `publicRoutes` to an authenticated route group so CSRF applies (the GET can remain public for the redirect-to-login flow; the POST should be authenticated + CSRF-protected).\n\n3. **Add a per-session nonce that is bound to the requester's browser** (e.g. a signed cookie set on the GET, validated on the POST) to prevent cross-origin submission even when `SameSite=Lax` allows it.\n\n4. **Require user re-authentication (re-entry of password / step-up auth)** for sensitive identity-binding operations, similar to how password change requires re-auth.\n\n## Affected packages\n\n- `@budibase/server <= 3.38.1`\n\n## Remediation\n\nRefer to the advisory for the patched release.","depth":"twilight","depthScore":42,"depthScoreParts":{"impact":42.4,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}