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:
- Budibase deployment with a Snowflake datasource configured by a builder.
- A builder who has created a Snowflake query with at least one
{{ paramName }} binding (the default parameterised-query pattern).
- A builder who has wired that query into an automation with a
WEBHOOKtrigger + EXECUTE_QUERY step. The webhook trigger is automatically public.
- (Optional, for response exfiltration) Enterprise license with SYNC_AUTOMATIONS enabled + a
COLLECT step in the automation.
Step-by-step:
-
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
-
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.
-
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" }
-
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> })
-
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.
Summary
Budibase automation webhooks are unauthenticated by design - anyone who knows the
(workspaceId, webhookId)pair (both are random UUIDs, but they are not secrets) canPOST /api/webhooks/trigger/:instance/:idand 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(orAPI_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: trueskipsgetAuthConfig). This yields an unauthenticated SQL injection into Snowflake with attacker-chosen parameter values and builder-supplied credentials.When the automation also contains a
COLLECTstep 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
packages/server/src/api/routes/webhook.ts11-17(publicRoutes.post("/api/webhooks/trigger/:instance/:id", controller.trigger))packages/server/src/api/controllers/webhook.ts141-205(trigger); the sync-COLLECT response path at167-190packages/server/src/automations/triggers.ts234-253(isWebhookActionbranch spreadsparams.fieldsintoparams)packages/server/src/threads/automation.ts999-1011(processObject(inputs, ctx)forEXECUTE_QUERYsteps)EXECUTE_QUERYstep skips per-user authpackages/server/src/api/controllers/query/index.ts429-432(if (!opts.isAutomation) { authConfigCtx = getAuthConfig(ctx) });494-498(executeV2AsAutomationsetsisAutomation: true)EXECUTE_QUERYstep calls controllerpackages/server/src/automations/steps/executeQuery.ts30-43(builds ctx withbody.parameters: rest, callsexecuteV2AsAutomation)packages/server/src/api/controllers/webhook.ts167-190(isSyncAutomationsEnabled()+checkForCollectStep->ctx.body = collectedValue?.outputs)Affected versions:
masterat commit3c8d1b4023. The webhook trigger has been public since the automation feature shipped;isAutomation: trueskippinggetAuthConfigis 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.
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.
The webhook's JSON body becomes the trigger context. A POST
{"email": "x"}yieldstrigger.email === "x"in the automation context.Issue 3 - EXECUTE_QUERY bypasses per-user auth-config resolution.
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.
The builder configures
inputs.query.email = "{{ trigger.body.email }}".processObjectrenders that HBS binding against the trigger context, yielding the attacker-controlled email value. It lands inbody.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.
If the deployment is licensed for
Feature.SYNC_AUTOMATIONS(Enterprise) and the automation has aCOLLECTstep, the webhook synchronously runs the automation to completion and returns the COLLECT step's outputs to the unauthenticated caller. A builder who wiresCOLLECTimmediately afterEXECUTE_QUERYmakes the query's result rows directly readable by the unauthenticated webhook caller.Reproduction
Pre-requisites:
{{ paramName }}binding (the default parameterised-query pattern).WEBHOOKtrigger +EXECUTE_QUERYstep. The webhook trigger is automatically public.COLLECTstep in the automation.Step-by-step:
Builder setup (one-time, legitimate): Builder authors a Snowflake query
Builder creates an automation:
WEBHOOKEXECUTE_QUERYwithinputs.query.queryId = <snowflake query id>,inputs.query.email = "{{ trigger.body.email }}"COLLECTcollecting step 1's outputAttacker (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.Attacker sends a SQLi payload:
Server-side trace:
webhook.ts:170callstriggers.externalTrigger(target, { fields: { email: "...", body: {...} } })triggers.ts:237-253flattens:params.email = "' UNION SELECT ..."externalTrigger(withgetResponses: trueif sync+COLLECT) ->executeInThread->Orchestrator.executeautomation.ts:1011processObject({ query: { queryId, email: "{{ trigger.body.email }}" } }, ctx)rendersemail = "' UNION SELECT ..."executeQuery.ts:43executeV2AsAutomation->query/index.ts:415 executewithisAutomation: true->getAuthConfigskipped -> Runner.run threadthreads/query.ts:193enrichContext(non-SQL branch for Snowflake) -> renders SQL withnoEscaping: truesnowflake.ts:160this.client.execute({ sqlText: <injected SQL> })Result:
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(processObjecttemplates inputs),executeQuery.ts:30-43(step builds ctx and callsexecuteV2AsAutomation), andquery/index.ts:429-432(isAutomation: trueskipsgetAuthConfig).Impact
Even without sync/COLLECT, the attacker can:
OUTGOING_WEBHOOKto an attacker-controlled host (its URL is also builder-configurable to read the EXECUTE_QUERY output via{{ steps.<exec-query-step>.response }}bindings).The attacker is completely unauthenticated. The only information needed
is the webhook
(instance, id)pair, which is not secret by design.