VYPR
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.

PackageAffected versionsPatched versions
@budibase/servernpm
< 3.33.43.33.4

Affected products

1

Patches

1
f0c731b409a9

Merge pull request #18238 from Budibase/fix/bash-step

https://github.com/Budibase/budibasemelohaganMar 13, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.