Root cause
Authentication & access control introduced
The AI-assisted change added access and refresh tokens to the user object passed through automation execution.
How AI contributed: Direct introductionPublished Jul 24, 2026 in
budibase/budibase
Summary When an SSO-authenticated user tests an automation in the Budibase builder, their OAuth2 access token and refresh token are included in the automation test results. These results are broadcast via WebSocket to all builders connected to the same dev app and stored in an in-memory cache accessible to any builder who polls the test status endpoint. This allows any co-builder of the same app to steal the testing user's OAuth2 tokens. Details The vulnerability exists because getUserContextBindings() intentionally includes OAuth2 tokens in user context bindings (so automations can call external APIs), but the automation test pipeline exposes the full result — including these tokens — to all builders of the same app without sanitization. Step 1: Tokens included in user bindings In packages/server/src/sdk/users/utils.ts:134-161: Step 2: Bindings passed to automation execution In packages/server/src/api/controllers/automation.ts:311-312: Step 3: Tokens placed in trigger outputs In packages/server/src/threads/automation.ts:409-413: Step 4: Result broadcast without sanitization The full result (including trigger.outputs.user.oauth2) is exposed via two vectors: 1. WebSocket broadcast — builderSocket.emitToRoom() calls this.io.in(room).emit() (packages/server/src/websockets/websocket.ts:291) which sends to ALL sockets in the app's room, not just the originator. 2. Test status endpoint — recordTestProgress() stores the result in a Map keyed by ${appId}:${automationId} with no user isolation (packages/server/src/automations/testProgress.ts:41-74). Any builder can call GET /api/automations/:id/test/status to retrieve another user's test results. PoC Requires two builder-level users on the same Budibase app, where User A authenticates via SSO/OIDC (Google, Azure AD, etc.) which provides OAuth2 tokens. Additionally, User B can passively receive the tokens by simply having the Budibase builder open (connected via WebSocket), as the BuilderSocketEvent.AutomationTestProgress event with status: "complete" includes the full result payload. Impact · OAuth2 access tokens for external services (Google Workspace, Azure AD, GitHub, etc.) are exposed to co-builders of the same app. These tokens can be used to access external APIs as the victim user. · OAuth2 refresh tokens provide persistent access — an attacker can generate new access tokens even after the original expires, maintaining long-term access to the victim's external service accounts. · The attack is passive via WebSocket — an attacker only needs to have the builder UI open to receive tokens when any co-builder tests an automation. · Test results persist in memory for 5 minutes (TTL in testProgress.ts:15), providing a window for polling-based attacks. Recommended Fix Strip OAuth2 tokens from automation test results before storing/broadcasting them. The tokens are needed during automation execution but should not be included in the result sent to clients. In packages/server/src/api/controllers/automation.ts, sanitize the result before passing to emitProgress: Apply sanitization in the emitProgress callback at line 290 and before returning ctx.body at line 342: Additionally, the testProgress store should be scoped per-user (keyed by ${appId}:${automationId}:${userId}) so that one builder's test results are not accessible to another builder via the testStatus endpoint.
An automation test copied an SSO user's access and refresh tokens into result output that was broadcast and cached for other builders in the same app.
Root cause
The AI-assisted change added access and refresh tokens to the user object passed through automation execution.
Fix
The fix strips OAuth2 data before results leave the server and isolates cached progress by builder.
Case facts
@@ -130,27 +131,25 @@ export function getUserContextBindings(user: ContextUser): UserBindings { if (!user) { return {} }- // Current user context for bindable search- const {- _id,- _rev,- firstName,- lastName,- email,- status,- roleId,- globalId,- userId,- } = user- return {- _id,- _rev,- firstName,- lastName,- email,- status,- roleId,- globalId,- userId,++ const bindings: UserBindings = {+ _id: user._id,+ _rev: user._rev,+ firstName: user.firstName,+ lastName: user.lastName,+ email: user.email,+ status: user.status,+ roleId: user.roleId,+ globalId: user.globalId,+ userId: user.userId, }++ if (isSSOUser(user) && user.oauth2) {+ bindings.oauth2 = {+ accessToken: user.oauth2.accessToken,+ refreshToken: user.oauth2.refreshToken,+ }+ }++ return bindings }@@ -40,6 +40,7 @@ import { } from "@budibase/types" import { testConnection } from "../../automations/email" import { getActionDefinitions as actionDefs } from "../../automations/actions"+import { sanitizeAutomationTestResult } from "../../automations/sanitizeTestResult" import * as triggers from "../../automations/triggers" import { AutomationTestProgressEvent,@@ -395,10 +396,11 @@ export async function test( const emitProgress = (event: ProgressEventInput) => { const payload: AutomationTestProgressEvent = { ...event,+ result: sanitizeAutomationTestResult(event.result), automationId: automation._id!, appId, }- recordTestProgress(appId, automation._id!, payload)+ recordTestProgress(appId, automation._id!, payload, ctx.user._id) builderSocket?.emitToRoom( ctx, ctx.appId,@@ -421,15 +423,16 @@ export async function test( ) }) await events.automation.tested(automation)+ const sanitizedResult = sanitizeAutomationTestResult(result) emitProgress({ status: "complete", occurredAt: Date.now(),- result,+ result: sanitizedResult, })- return result+ return sanitizedResult } - clearTestProgress(appId, automation._id!)+ clearTestProgress(appId, automation._id!, ctx.user._id) if (asyncFlag) { ctx.status = 202@@ -449,6 +452,6 @@ export async function test( export async function testStatus(ctx: UserCtx<void, unknown>) { const automationId = ctx.params.id- const status = getTestProgress(ctx.appId, automationId)+ const status = getTestProgress(ctx.appId, automationId, ctx.user._id) ctx.body = status || {} }@@ -0,0 +1,31 @@+import { AutomationTestProgressEvent } from "@budibase/types"+import cloneDeep from "lodash/cloneDeep"++function stripOAuth2FromOutputs(+ outputs?: Record<string, any> | null+): Record<string, any> | undefined | null {+ if (outputs?.user?.oauth2) {+ delete outputs.user.oauth2+ }+ return outputs+}++export function sanitizeAutomationTestResult(+ result: AutomationTestProgressEvent["result"]+): AutomationTestProgressEvent["result"] {+ if (!result) {+ return result+ }++ const sanitized: NonNullable<AutomationTestProgressEvent["result"]> =+ cloneDeep(result)+ if ("trigger" in sanitized) {+ stripOAuth2FromOutputs(sanitized.trigger.outputs)+ for (const step of sanitized.steps || []) {+ stripOAuth2FromOutputs(step.outputs)+ }+ } else {+ stripOAuth2FromOutputs(sanitized.outputs)+ }+ return sanitized+}@@ -35,15 +35,19 @@ export function stopCleanup() { clearInterval(cleanupInterval) } -const getKey = (appId: string | undefined, automationId: string) =>- `${appId || "unknown"}:${automationId}`+const getKey = (+ appId: string | undefined,+ automationId: string,+ userId?: string+) => `${appId || "unknown"}:${automationId}:${userId || "unknown"}` export function recordTestProgress( appId: string | undefined, automationId: string,- event: AutomationTestProgressEvent+ event: AutomationTestProgressEvent,+ userId?: string ) {- const key = getKey(appId, automationId)+ const key = getKey(appId, automationId, userId) const state = progressState.get(key) || ({@@ -75,16 +79,18 @@ export function recordTestProgress( export function getTestProgress( appId: string | undefined,- automationId: string+ automationId: string,+ userId?: string ) {- const key = getKey(appId, automationId)+ const key = getKey(appId, automationId, userId) return progressState.get(key) } export function clearTestProgress( appId: string | undefined,- automationId: string+ automationId: string,+ userId?: string ) {- const key = getKey(appId, automationId)+ const key = getKey(appId, automationId, userId) progressState.delete(key) }AI-assisted change d356dfc30def0160d47001ab7711a283b60a4ee99443241710ba8dfc7bfc3618 · Fix 2940b1be23c0826e29d7d6948539766ee6d52f8522502ec10ab36d51c32671da