Skip to content

Unauthenticated SQL Injection via Webhook to EXECUTE_QUERY Automation Chain

High
mjashanks published GHSA-x7h8-ww3q-xv7c Jul 22, 2026

Package

npm @budibase/server (npm)

Affected versions

<3.40.0

Patched versions

3.40.0

Description

Summary

Budibase automation webhooks are unauthenticated by design - anyone who knows the (workspaceId, webhookId) pair (both are random UUIDs, but they are not secrets) can POST /api/webhooks/trigger/:instance/:id and trigger the linked automation with an attacker-controlled JSON body. That body becomes the automation's trigger context.

When the triggered automation contains an EXECUTE_QUERY (or API_REQUEST) step, the step runs a builder-authored query against its datasource with the trigger context spliced into the query's parameterised bindings - but the per-user auth-config resolution is bypassed (isAutomation: true skips getAuthConfig). This yields an unauthenticated SQL injection into Snowflake with attacker-chosen parameter values and builder-supplied credentials.

When the automation also contains a COLLECT step AND the deployment is licensed for synchronous automations (Feature.SYNC_AUTOMATIONS, an enterprise Pro feature), the unauthenticated webhook caller additionally receives the EXECUTE_QUERY output back in the HTTP response, turning the primitive into a direct unauthenticated data-exfiltration channel.

Together they are Critical: unauthenticated RCE-equivalent inside Snowflake.


Affected

Component Path Lines
Unauthenticated webhook trigger route packages/server/src/api/routes/webhook.ts 11-17 (publicRoutes.post("/api/webhooks/trigger/:instance/:id", controller.trigger))
Webhook trigger -> automation invocation packages/server/src/api/controllers/webhook.ts 141-205 (trigger); the sync-COLLECT response path at 167-190
Webhook body flattened into trigger context packages/server/src/automations/triggers.ts 234-253 (isWebhookAction branch spreads params.fields into params)
Orchestrator templates step inputs against trigger ctx packages/server/src/threads/automation.ts 999-1011 (processObject(inputs, ctx) for EXECUTE_QUERY steps)
EXECUTE_QUERY step skips per-user auth packages/server/src/api/controllers/query/index.ts 429-432 (if (!opts.isAutomation) { authConfigCtx = getAuthConfig(ctx) }); 494-498 (executeV2AsAutomation sets isAutomation: true)
EXECUTE_QUERY step calls controller packages/server/src/automations/steps/executeQuery.ts 30-43 (builds ctx with body.parameters: rest, calls executeV2AsAutomation)
Sync-COLLECT response channel packages/server/src/api/controllers/webhook.ts 167-190 (isSyncAutomationsEnabled() + checkForCollectStep -> ctx.body = collectedValue?.outputs)

Affected versions: master at commit 3c8d1b4023. The webhook trigger has been public since the automation feature shipped; isAutomation: true skipping getAuthConfig is also long-standing.

Reachable over HTTP by: any unauthenticated network attacker who can discover the webhook's (instance, id) pair (both are random UUIDs and not intended to be secret - they are routinely embedded in third-party service configurations like Slack/Discord outbound webhooks, Zapier, n8n, etc., where they may leak via screenshots, exported configs, or compromised integrations).

Root cause

Issue 1 - Webhook triggers are unauthenticated.

// packages/server/src/api/routes/webhook.ts:11-17
publicRoutes.post(
  "/api/webhooks/schema/:instance/:id/:schemaToken",
  controller.buildSchemaWithToken
)

// this shouldn't have authorisation, right now its always public
publicRoutes.post("/api/webhooks/trigger/:instance/:id", controller.trigger)

The inline comment confirms intent: webhook triggers are by-design public (intended to be reachable by third-party webhook senders that cannot authenticate as Budibase users). There is no per-webhook secret, no HMAC signature, no IP allowlist. The only "secret" is the webhook's UUID, which is not cryptographically protected - it is routinely shared with external services.

Issue 2 - Webhook body is flattened into the trigger context.

// packages/server/src/automations/triggers.ts:234-253
// row actions and webhooks flatten the fields down
else if (
  sdk.automations.isRowAction(automation) ||
  sdk.automations.isWebhookAction(automation)
) {
  const {
    appId: _appId,
    timeout: _timeout,
    user: _user,
    metadata: _metadata,
    automation: _automation,
    ...fields
  } = params.fields || {}

  params = {
    ...params,
    ...fields,        // <- webhook body keys become top-level trigger params
    fields: {},
  }
}

The webhook's JSON body becomes the trigger context. A POST {"email": "x"} yields trigger.email === "x" in the automation context.

Issue 3 - EXECUTE_QUERY bypasses per-user auth-config resolution.

// packages/server/src/api/controllers/query/index.ts:429-432
let authConfigCtx = {}
if (!opts.isAutomation) {
  authConfigCtx = getAuthConfig(ctx)
}
// packages/server/src/api/controllers/query/index.ts:494-498
export async function executeV2AsAutomation(ctx) {
  return execute(ctx, { rowsOnly: false, isAutomation: true })
}

When a query executes via the automation engine, the per-user OAuth2/auth-config resolution (getAuthConfig) is skipped entirely. The query runs with the datasource's builder-configured credentials. There is no per-user ACL check on the query at this point - the only gate is "the builder wired this query into this automation".

Issue 4 - EXECUTE_QUERY inputs are templated against the trigger context.

// packages/server/src/automations/steps/executeQuery.ts:30-43
const { queryId, ...rest } = inputs.query

const ctx: any = buildCtx(appId, emitter, {
  body: {
    parameters: rest,           // <- values from inputs.query (templated against ctx)
  },
  params: {
    queryId,
  },
  user: context.user,
})

try {
  await queryController.executeV2AsAutomation(ctx)
// packages/server/src/threads/automation.ts:999-1011
let inputs = cloneDeep(step.inputs)
if (
  step.stepId !== AutomationActionStepId.EXECUTE_BASH &&
  step.stepId !== AutomationActionStepId.EXECUTE_SCRIPT_V2 &&
  step.stepId !== AutomationActionStepId.EXTRACT_STATE &&
  step.stepId !== AutomationActionStepId.SERVER_LOG
) {
  inputs = await processObject(inputs, ctx)
}

The builder configures inputs.query.email = "{{ trigger.body.email }}". processObject renders that HBS binding against the trigger context, yielding the attacker-controlled email value. It lands in body.parameters.email, which the execute path forwards to the datasource integration as a query parameter. For Snowflake, the parameter is spliced raw into the SQL string.

Issue 5 - Optional response exfiltration channel via COLLECT + sync automations.

// packages/server/src/api/controllers/webhook.ts:167-190
let hasCollectStep = sdk.automations.utils.checkForCollectStep(target)

if (hasCollectStep && (await pro.features.isSyncAutomationsEnabled())) {
  const response = await triggers.externalTrigger(
    target,
    { fields: { ...ctx.request.body, body: ctx.request.body }, appId: prodAppId },
    { getResponses: true }
  )

  if (triggers.isAutomationResults(response)) {
    let collectedValue = response.steps.find(
      step => step.stepId === AutomationActionStepId.COLLECT
    )
    ctx.body = collectedValue?.outputs          // <- response body to unauthenticated caller
  } else {
    ctx.throw(400, "Automation did not have a collect block.")
  }
}

If the deployment is licensed for Feature.SYNC_AUTOMATIONS (Enterprise) and the automation has a COLLECT step, the webhook synchronously runs the automation to completion and returns the COLLECT step's outputs to the unauthenticated caller. A builder who wires COLLECT immediately after EXECUTE_QUERY makes the query's result rows directly readable by the unauthenticated webhook caller.


Reproduction

Pre-requisites:

  1. Budibase deployment with a Snowflake datasource configured by a builder.
  2. A builder who has created a Snowflake query with at least one {{ paramName }} binding (the default parameterised-query pattern).
  3. A builder who has wired that query into an automation with a WEBHOOKtrigger + EXECUTE_QUERY step. The webhook trigger is automatically public.
  4. (Optional, for response exfiltration) Enterprise license with SYNC_AUTOMATIONS enabled + a COLLECT step in the automation.

Step-by-step:

  1. Builder setup (one-time, legitimate): Builder authors a Snowflake query

    SELECT id, email, role FROM users WHERE email = '{{ email }}'

    Builder creates an automation:

    • Trigger: WEBHOOK
    • Step 1: EXECUTE_QUERY with inputs.query.queryId = <snowflake query id>,
      inputs.query.email = "{{ trigger.body.email }}"
    • Step 2 (optional, for exfil): COLLECT collecting step 1's output
  2. Attacker (unauthenticated) discovers the webhook URL. Webhook URLs are of the form POST /api/webhooks/trigger/<workspaceId>/<webhookId>. Both IDs are random UUIDs but are routinely embedded in third-party webhook configs (Slack, Discord, Zapier, n8n) and may leak via screenshots, exported configs, or compromised integrations.

  3. Attacker sends a SQLi payload:

    POST /api/webhooks/trigger/<workspaceId>/<webhookId>
    Content-Type: application/json
    
    { "email": "' UNION SELECT api_key, owner, 'admin' FROM system.auth WHERE '1'='1" }
  4. Server-side trace:

    • webhook.ts:170 calls triggers.externalTrigger(target, { fields: { email: "...", body: {...} } })
    • triggers.ts:237-253 flattens: params.email = "' UNION SELECT ..."
    • externalTrigger (with getResponses: true if sync+COLLECT) -> executeInThread -> Orchestrator.execute
    • automation.ts:1011 processObject({ query: { queryId, email: "{{ trigger.body.email }}" } }, ctx) renders email = "' UNION SELECT ..."
    • executeQuery.ts:43 executeV2AsAutomation -> query/index.ts:415 execute with isAutomation: true -> getAuthConfig skipped -> Runner.run thread
    • threads/query.ts:193 enrichContext (non-SQL branch for Snowflake) -> renders SQL with noEscaping: true
    • snowflake.ts:160 this.client.execute({ sqlText: <injected SQL> })
  5. Result:

    • Without sync/COLLECT: the SQL executes; the attacker cannot read the response but the SQL is committed - INSERT/UPDATE/DELETE/CREATE TASK side effects succeed. Persistence, data tampering, and Snowflake-side privilege escalation (CREATE FUNCTION/CREATE TASK) work.
    • With sync/COLLECT: the response body containing the query rows is returned in the HTTP response. Direct unauthenticated data exfiltration.

Evidence

The webhook-unauthenticated half is confirmed by direct code reading of routes/webhook.ts:11-17 (public route), controllers/webhook.ts:141-205 (trigger function), triggers.ts:234-253 (body-to-context flattening), automation.ts:999-1011 (processObject templates inputs), executeQuery.ts:30-43 (step builds ctx and calls executeV2AsAutomation), and query/index.ts:429-432 (isAutomation: true skips getAuthConfig).


Impact

Capability Without sync/COLLECT With sync/COLLECT
Snowflake SQL injection (any payload)
Data tampering (INSERT/UPDATE/DELETE)
Persistence in Snowflake (CREATE TASK)
Snowflake-side code execution (CREATE FUNCTION with JS/Python UDF) ✅ (if role permits)
Reading query response rows ❌ (no response channel)
Full data exfiltration of Snowflake tables via side-channel ✅ direct

Even without sync/COLLECT, the attacker can:

  • Exfiltrate via a second automation step: OUTGOING_WEBHOOK to an attacker-controlled host (its URL is also builder-configurable to read the EXECUTE_QUERY output via {{ steps.<exec-query-step>.response }} bindings).
  • Cause destructive writes via DELETE/DROP if the Snowflake role permits.

The attacker is completely unauthenticated. The only information needed
is the webhook (instance, id) pair, which is not secret by design.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

The product constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command when it is sent to a downstream component. Without sufficient removal or quoting of SQL syntax in user-controllable inputs, the generated SQL query can cause those inputs to be interpreted as SQL instead of ordinary user data. Learn more on MITRE.

Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information. Learn more on MITRE.

Missing Authentication for Critical Function

The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources. Learn more on MITRE.

Missing Authorization

The product does not perform an authorization check when an actor attempts to access a resource or perform an action. Learn more on MITRE.

Credits