# Budibase: Chat-Link Handoff Identity Confusion (Same-Tenant Account-Link CSRF)

Budibase chat-link handoff POST lacks CSRF and session binding, letting an attacker bind their Slack identity to a victim's account and act as them via the AI agent.

- Payout: Research
- Severity: high
- Program: Budibase
- Found by: hypnguyen1209
- Disclosed: 2026-07-24
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-pvcr-8mvp-w8qr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-pvcr-8mvp-w8qr
- Tags: area:web domain:web impact:ato impact:privesc kind:research platform:ghsa sev:high tech:nodejs technique:source-review vuln:access-control vuln:auth-bypass vuln:csrf

**Target:** AI chat-link handoff endpoints (/api/chat-links/:instance/:token/handoff)
**Root cause:** The handoff POST is on publicRoutes with no CSRF middleware, and the confirmation token is rendered in plaintext into the GET page with no binding to the requester's Budibase session at preparation time. The POST binds whatever user is authenticated to the session's external chat identity.
**Steps:**
1. Attacker provisions a chat-link session for their own external chat identity via agent channel provisioning (e.g. `POST /api/agent/:agentId/slack/provision`), obtaining a `token` scoped to tenant T.
2. Attacker triggers `prepareChatIdentityLinkSessionConfirmation` so a `confirmationToken` is generated and stored keyed by `token`.
3. Attacker builds an auto-submit page: `<form id="f" method="post" action="https://victim.budibase.app/api/chat-links/app_xxx/linktoken/handoff"><input name="confirmationToken" value="<leaked-token>"></form><script>document.getElementById('f').submit()</script>`
4. Victim logged into Budibase is lured to the page; top-level form POST navigates cross-site and SameSite=Lax cookies are sent, authenticating as the victim.
5. `confirmChatLinkSession` checks only `ctx.isAuthenticated` and the `confirmationToken`, then calls `upsertChatIdentityLink` with `globalUserId: currentGlobalUserId` (victim) and `externalUserId` (attacker).
6. Attacker DMs the AI agent from their chat platform and operations run as the victim.
**How found:** Code review of the chat-link handoff flow: noticed the GET/POST are on publicRoutes with no auth-group middleware or CSRF, the confirmationToken is rendered into the HTML page source, and the POST binds the currently-authenticated user with no preparation-time session binding; `assertSessionMatchesInstance` only checks workspace ID, not user.
**Exploitation:** Same-tenant phishing: attacker mints a chat-link session for their own Slack/Discord/Teams/Telegram identity, leaks the confirmationToken from the GET page, and lures a logged-in victim to an auto-submitting form POST. The victim's globalUserId is bound to the attacker's external identity, giving persistent impersonation of the victim inside agent operations, knowledge sources, and downstream automations until manually unlinked.
**Escalation:** Impact scales with victim privileges: an admin victim yields full tenant administration via chat; a regular user yields impersonation within the agent subsystem.
**Insight:** Identity-linking/confirmation flows that render a token into a page and bind 'whoever is authenticated at POST time' are CSRF-to-account-link bugs; check whether the confirmation token is bound to the requester's session at preparation, not just validated at submission.

## Proof of concept (copied exactly from the original report)

**Auto-submitting CSRF form to handoff endpoint**

```
<form id="f" method="post" action="https://victim.budibase.app/api/chat-links/app_xxx/linktoken/handoff">
     <input name="confirmationToken" value="<leaked-token>">
   </form>
   <script>document.getElementById('f').submit()</script>
```

**Confirmation token rendered in hidden form input**

```
<form method="post" action="${helpers.escapeHtml(action)}">
      <input type="hidden" name="confirmationToken" value="${helpers.escapeHtml(session.confirmationToken)}">
      <button type="submit">Confirm</button>
```

**POST binds current user to external identity**

```
const currentGlobalUserId = getCurrentGlobalUserId(ctx)        // <- victim's ID
  const consumedSession = await sdk.ai.chatIdentityLinks.consumeChatIdentityLinkSession(token)
  ...
  await sdk.ai.chatIdentityLinks.upsertChatIdentityLink({
    provider: consumedSession.provider,
    externalUserId: consumedSession.externalUserId,              // <- attacker's chat identity
    externalUserName: consumedSession.externalUserName,
    ...
    globalUserId: currentGlobalUserId,                            // <- bound to victim
    linkedBy: currentGlobalUserId,
```

