Critical severity9.0NVD Advisory· Published Apr 3, 2026· Updated Apr 8, 2026
CVE-2026-35216
CVE-2026-35216
Description
Budibase is an open-source low-code platform. Prior to version 3.33.4, an unauthenticated attacker can achieve Remote Code Execution (RCE) on the Budibase server by triggering an automation that contains a Bash step via the public webhook endpoint. No authentication is required to trigger the exploit. The process executes as root inside the container. This issue has been patched in version 3.33.4.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@budibase/servernpm | < 3.33.4 | 3.33.4 |
Affected products
1Patches
1f0c731b409a9Merge pull request #18238 from Budibase/fix/bash-step
6 files changed · +237 −26
packages/server/package.json+1 −0 modified@@ -91,6 +91,7 @@ "dd-trace": "5.63.0", "dotenv": "8.2.0", "extract-zip": "^2.0.1", + "execa": "^5.1.1", "form-data": "4.0.4", "global-agent": "3.0.0", "google-auth-library": "^10.5.0",
packages/server/src/automations/steps/bash.ts+80 −9 modified@@ -1,33 +1,103 @@ -import { execSync } from "child_process" -import { processStringSync } from "@budibase/string-templates" +import execa from "execa" +import { findHBSBlocks, processStringSync } from "@budibase/string-templates" import * as automationUtils from "../automationUtils" import environment from "../../environment" import { BashStepInputs, BashStepOutputs } from "@budibase/types" +const INVALID_INPUTS = "Budibase bash automation failed: Invalid inputs" +const COMMAND_BINDINGS_ERROR = + "Budibase bash automation failed: Command bindings are not supported. Use the args field for dynamic values." +const ARGS_VALIDATION_ERROR = + "Budibase bash automation failed: Args must be a JSON array of strings." + +interface JsonEditorInput { + value?: unknown +} + +const validateArgs = (args: unknown): string[] => { + if (!Array.isArray(args) || args.some(arg => typeof arg !== "string")) { + throw new Error(ARGS_VALIDATION_ERROR) + } + + return args +} + +const parseArgs = (args: unknown) => { + if (args == null) { + return [] + } + + if (Array.isArray(args)) { + return validateArgs(args) + } + + if (typeof args === "object" && "value" in (args as JsonEditorInput)) { + const value = (args as JsonEditorInput).value + + if (Array.isArray(value)) { + return validateArgs(value) + } + + if (typeof value === "string") { + try { + return validateArgs(JSON.parse(value)) + } catch { + throw new Error(ARGS_VALIDATION_ERROR) + } + } + } + + throw new Error(ARGS_VALIDATION_ERROR) +} + +const processArgs = (args: unknown, context: object) => { + return parseArgs(args).map(arg => processStringSync(arg, context)) +} + export async function run({ inputs, context, }: { inputs: BashStepInputs context: object }): Promise<BashStepOutputs> { - if (inputs.code == null) { + if (inputs.command == null) { return { - stdout: "Budibase bash automation failed: Invalid inputs", + success: false, + stdout: INVALID_INPUTS, } } try { - const command = processStringSync(inputs.code, context) + if (findHBSBlocks(inputs.command).length > 0) { + return { + success: false, + stdout: COMMAND_BINDINGS_ERROR, + response: { + message: COMMAND_BINDINGS_ERROR, + }, + } + } + + const command = inputs.command.trim() + if (!command) { + return { + success: false, + stdout: INVALID_INPUTS, + } + } + + const args = processArgs(inputs.args, context) let stdout, success = true try { - stdout = execSync(command, { + stdout = execa.sync(command, args, { timeout: environment.QUERY_THREAD_TIMEOUT, - }).toString() + stripFinalNewline: false, + }).stdout } catch (err: any) { - stdout = err.message + stdout = err.stderr || err.stdout || err.message success = false } @@ -38,7 +108,8 @@ export async function run({ } catch (err) { return { success: false, - response: automationUtils.getError(err), + response: + err instanceof Error ? err.message : automationUtils.getError(err), } } }
packages/server/src/automations/tests/steps/bash.spec.ts+132 −7 modified@@ -1,3 +1,6 @@ +import fs from "fs" +import os from "os" +import path from "path" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import * as automation from "../../index" import { Table } from "@budibase/types" @@ -7,6 +10,13 @@ import { basicTable } from "../../../tests/utilities/structures" describe("Execute Bash Automations", () => { const config = new TestConfiguration() let table: Table + const writeFileScript = + "require('fs').writeFileSync(process.argv[1], 'initial content')" + const uppercaseFileScript = + "const fs = require('fs'); process.stdout.write(fs.readFileSync(process.argv[1], 'utf8').toUpperCase() + '\\n')" + const deleteFileScript = "require('fs').unlinkSync(process.argv[1])" + const addFiveScript = + "process.stdout.write(String(Number(process.argv[1]) + 5) + '\\n')" beforeAll(async () => { await automation.init() @@ -24,11 +34,21 @@ describe("Execute Bash Automations", () => { config.end() }) + afterEach(() => { + const injectedPath = path.join(os.tmpdir(), "budibase-bash-step-injection") + if (fs.existsSync(injectedPath)) { + fs.unlinkSync(injectedPath) + } + }) + it("should use trigger data in bash command and pass output to subsequent steps", async () => { const result = await createAutomationBuilder(config) .onAppAction() .bash( - { code: "echo '{{ trigger.fields.command }}'" }, + { + command: "echo", + args: ["{{ trigger.fields.command }}"], + }, { stepName: "Echo Command" } ) .serverLog( @@ -47,15 +67,24 @@ describe("Execute Bash Automations", () => { const result = await createAutomationBuilder(config) .onAppAction() .bash( - { code: "echo 'initial content' > {{ trigger.fields.filename }}" }, + { + command: "node", + args: ["-e", writeFileScript, "{{ trigger.fields.filename }}"], + }, { stepName: "Create File" } ) .bash( - { code: "cat {{ trigger.fields.filename }} | tr '[a-z]' '[A-Z]'" }, + { + command: "node", + args: ["-e", uppercaseFileScript, "{{ trigger.fields.filename }}"], + }, { stepName: "Transform Content" } ) .bash( - { code: "rm {{ trigger.fields.filename }}" }, + { + command: "node", + args: ["-e", deleteFileScript, "{{ trigger.fields.filename }}"], + }, { stepName: "Cleanup" } ) .test({ fields: { filename: "testfile.txt" } }) @@ -76,7 +105,10 @@ describe("Execute Bash Automations", () => { ) .bash( { - code: "echo Row data: {{ steps.[Get Row].rows.[0].name }} - {{ steps.[Get Row].rows.[0].description }}", + command: "echo", + args: [ + "Row data: {{ steps.[Get Row].rows.[0].name }} - {{ steps.[Get Row].rows.[0].description }}", + ], }, { stepName: "Process Row Data" } ) @@ -98,7 +130,10 @@ describe("Execute Bash Automations", () => { const result = await createAutomationBuilder(config) .onAppAction() .bash( - { code: "echo $(( {{ trigger.fields.threshold }} + 5 ))" }, + { + command: "node", + args: ["-e", addFiveScript, "{{ trigger.fields.threshold }}"], + }, { stepName: "Calculate Value" } ) .executeScript( @@ -121,16 +156,106 @@ describe("Execute Bash Automations", () => { expect(result.steps[2].outputs.message).toContain("Value was high") }) + it("should escape unquoted bindings before executing the command", async () => { + const injectedPath = path.join(os.tmpdir(), "budibase-bash-step-injection") + const payload = `hello; touch ${injectedPath}` + + const result = await createAutomationBuilder(config) + .onAppAction() + .bash( + { + command: "echo", + args: ["{{ trigger.fields.command }}"], + }, + { stepName: "Echo Command" } + ) + .test({ fields: { command: payload } }) + + expect(result.steps[0].outputs.stdout).toEqual(`${payload}\n`) + expect(fs.existsSync(injectedPath)).toEqual(false) + }) + + it("should reject command bindings", async () => { + const payload = "echo" + + const result = await createAutomationBuilder(config) + .onAppAction() + .bash( + { + command: "{{ trigger.fields.command }}", + args: ["hello world"], + }, + { stepName: "Dynamic Command" } + ) + .test({ fields: { command: payload } }) + + expect(result.steps[0].outputs.success).toEqual(false) + expect(result.steps[0].outputs.stdout).toEqual( + "Budibase bash automation failed: Command bindings are not supported. Use the args field for dynamic values." + ) + }) + + it("should accept args provided in the builder JSON editor shape", async () => { + const result = await createAutomationBuilder(config) + .onAppAction() + .bash( + { + command: "echo", + args: { + value: JSON.stringify(["{{ trigger.fields.command }}"]), + }, + }, + { stepName: "JSON Editor Args" } + ) + .test({ fields: { command: "hello world" } }) + + expect(result.steps[0].outputs.success).toEqual(true) + expect(result.steps[0].outputs.stdout).toEqual("hello world\n") + }) + + it("should reject invalid JSON editor args", async () => { + const result = await createAutomationBuilder(config) + .onAppAction() + .bash( + { + command: "echo", + args: { + value: "{ invalid json }", + }, + }, + { stepName: "Invalid JSON Editor Args" } + ) + .test({ fields: {} }) + + expect(result.steps[0].outputs.success).toEqual(false) + expect(result.steps[0].outputs.response).toEqual( + "Budibase bash automation failed: Args must be a JSON array of strings." + ) + }) + it("should handle null values gracefully", async () => { const result = await createAutomationBuilder(config) .onAppAction() .bash( // @ts-expect-error - testing null input - { code: null }, + { command: null }, { stepName: "Null Command" } ) .test({ fields: {} }) + expect(result.steps[0].outputs.success).toBe(false) + expect(result.steps[0].outputs.stdout).toBe( + "Budibase bash automation failed: Invalid inputs" + ) + }) + + it("should reject empty commands as failed invalid inputs", async () => { + const result = await createAutomationBuilder(config) + .onAppAction() + .bash({ command: " " }, { stepName: "Empty Command" }) + .test({ fields: {} }) + + expect(result.steps[0].outputs.success).toBe(false) expect(result.steps[0].outputs.stdout).toBe( "Budibase bash automation failed: Invalid inputs" )
packages/server/src/threads/automation.ts+4 −1 modified@@ -854,12 +854,15 @@ class Orchestrator { let inputs = cloneDeep(step.inputs) if ( + step.stepId !== AutomationActionStepId.EXECUTE_BASH && step.stepId !== AutomationActionStepId.EXECUTE_SCRIPT_V2 && step.stepId !== AutomationActionStepId.EXTRACT_STATE ) { // The EXECUTE_SCRIPT_V2 step saves its input.code value as a `{{ js // "..." }}` template, and expects to receive it that way in the - // function that runs it. So we skip this next bit for that step. + // function that runs it. EXECUTE_BASH also handles its own templating + // so it can reject bindings in the command name while still allowing + // templated args. So we skip this next bit for those steps. inputs = await processObject(inputs, ctx) }
packages/shared-core/src/automations/steps/bash.ts+14 −8 modified@@ -1,6 +1,5 @@ import { AutomationActionStepId, - AutomationCustomIOType, AutomationFeature, AutomationIOType, AutomationStepDefinition, @@ -9,9 +8,9 @@ import { export const definition: AutomationStepDefinition = { name: "Bash Scripting", - tagline: "Execute a bash command", + tagline: "Execute a system command", icon: "git-branch", - description: "Run a bash script", + description: "Run a command with explicit arguments", type: AutomationStepType.ACTION, internal: true, features: { @@ -22,19 +21,26 @@ export const definition: AutomationStepDefinition = { schema: { inputs: { properties: { - code: { + command: { type: AutomationIOType.STRING, - customType: AutomationCustomIOType.CODE, - title: "Code", + title: "Command", + description: + "The executable to run. Bindings are not supported in this field.", + }, + args: { + type: AutomationIOType.JSON, + title: "Arguments", + description: + "A JSON array of string arguments. Bindings are supported in each string item.", }, }, - required: ["code"], + required: ["command"], }, outputs: { properties: { stdout: { type: AutomationIOType.STRING, - description: "Standard output of your bash command or script", + description: "Standard output of the executed command", }, success: { type: AutomationIOType.BOOLEAN,
packages/types/src/documents/workspace/automation/StepInputsOutputs.ts+6 −1 modified@@ -38,8 +38,13 @@ export type ExternalAppStepOutputs = { success: boolean } +export type JSONEditorInput<T> = { + value?: T | string +} + export type BashStepInputs = { - code: string + command: string + args?: string[] | JSONEditorInput<string[]> } export type BashStepOutputs = BaseAutomationOutputs & {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/Budibase/budibase/commit/f0c731b409a96e401445a6a6030d2994ff4ac256nvdPatchWEB
- github.com/Budibase/budibase/pull/18238nvdIssue TrackingPatchWEB
- github.com/Budibase/budibase/security/advisories/GHSA-fcm4-4pj2-m5hfnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-fcm4-4pj2-m5hfghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-35216ghsaADVISORY
- github.com/Budibase/budibase/releases/tag/3.33.4nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.