GHSA-pvcr-8mvp-w8qr Budibase: Chat-Link Handoff Identity Confusion (Same-Tenant Account-Link CSRF)
SummaryThe 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.Consequently, 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.Affected| Component | Path | Lines |
|---|---|---|
| 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`) |
| Confirmation controller | `packages/server/src/api/controllers/ai/chatIdentityLinks.ts` | `124-177` (`confirmChatLinkSession`); the binding at `153-173` |
| Confirmation token rendered into HTML | `packages/server/src/api/controllers/ai/chatIdentityLinks.ts` | `40-60` (`renderLinkConfirmationPage`); the hidden input at `55` |
| Session creation (attacker side) | `packages/server/src/sdk/workspace/ai/chatIdentityLinks.ts` | `138-171` (`createChatIdentityLinkSession`), `173-188` (`prepareChatIdentityLinkSessionConfirmation`) |
| Upsert of identity link | `packages/server/src/sdk/workspace/ai/chatIdentityLinks.ts` | `201-250` (`upsertChatIdentityLink`) |**Affected versions:** `master` at commit `3c8d1b4023`.**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).Root cause**Issue 1 -> The handoff routes are public and unauthenticated at the middleware layer.**`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.**Issue 2 -> The confirmation token is exposed to anyone who views the GET page.**The `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.**Issue 3 -> The POST binds the **currently-authenticated user** to the external identity based solely on the confirmation token.**There 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.**Issue 4 -> `assertSessionMatchesInstance` only checks workspace ID, not user.**The 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.Reproduction**Step-by-step attack****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.**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.**Attacker crafts a phishing/auto-submit page** that POSTs to `/api/chat-links/<instance>/<token>/handoff` with the leaked `confirmationToken` in the body. Example:**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.**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.**Mitigating factors:**`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.The attack is **same-tenant only** (`session.tenantId !== context.getTenantId()` is checked in the sdk).The attacker must induce the victim to click a link (standard phishing). No silent drive-by.Impact| Capability | Available |
|---|---|
| Bind an attacker-controlled chat identity to a victim's Budibase account | ✅ |
| Send messages to the AI agent as the victim user (Slack/Discord/MS Teams/Telegram) | ✅ |
| Inherit the victim's permissions on agent operations | ✅ |
| Trigger automations / agent operations that the victim is authorised for | ✅ |
| Read knowledge sources the victim has access to | ✅ |The 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.This 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.Fix**Recommended layered fixes:****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.**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).**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.**Require user re-authentication (re-entry of password / step-up auth)** for sensitive identity-binding operations, similar to how password change requires re-auth.
CVSS v3.1 score: 7.7