{"id":"CVE-2026-35214","aliases":["GHSA-2wfh-rcwf-wh23"],"url":"https://o3.security/vulnerability/CVE-2026-35214","summary":"Budibase: Path traversal in plugin file upload enables arbitrary directory deletion and file write","details":"## Summary\n\nThe 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.\n\n## Severity\n\n- **Attack Vector:** Network — exploitable via the plugin upload HTTP API\n- **Attack Complexity:** Low — no special conditions; a single crafted multipart request suffices\n- **Privileges Required:** High — requires Global Builder role (`GLOBAL_BUILDER` permission)\n- **User Interaction:** None\n- **Scope:** Changed — the plugin upload feature is scoped to a temp directory, but the traversal escapes to the host filesystem\n- **Confidentiality Impact:** None — the vulnerability enables deletion and writing, not reading\n- **Integrity Impact:** High — attacker can delete arbitrary directories and write arbitrary files via tarball extraction\n- **Availability Impact:** High — recursive deletion of application or system directories causes denial of service\n\n### Severity Rationale\n\n 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.\n\n## Affected Component\n\n- `packages/server/src/api/controllers/plugin/file.ts` — `fileUpload()` (line 15)\n- `packages/server/src/utilities/fileSystem/filesystem.ts` — `createTempFolder()` (lines 78-91)\n\n## Description\n\n### Unsanitized filename flows into filesystem operations\n\nIn `packages/server/src/api/controllers/plugin/file.ts`, the uploaded file's name is used directly after stripping the `.tar.gz` suffix:\n\n```typescript\n// packages/server/src/api/controllers/plugin/file.ts:8-19\nexport async function fileUpload(file: KoaFile) {\n  if (!file.name || !file.path) {\n    throw new Error(\"File is not valid - cannot upload.\")\n  }\n  if (!file.name.endsWith(\".tar.gz\")) {\n    throw new Error(\"Plugin must be compressed into a gzipped tarball.\")\n  }\n  const path = createTempFolder(file.name.split(\".tar.gz\")[0])\n  await extractTarball(file.path, path)\n\n  return await getPluginMetadata(path)\n}\n```\n\nThe `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.\n\nThe `createTempFolder` function in `packages/server/src/utilities/fileSystem/filesystem.ts` uses `path.join()` which resolves `../` sequences, then performs destructive filesystem operations:\n\n```typescript\n// packages/server/src/utilities/fileSystem/filesystem.ts:78-91\nexport const createTempFolder = (item: string) => {\n  const path = join(budibaseTempDir(), item)\n  try {\n    // remove old tmp directories automatically - don't combine\n    if (fs.existsSync(path)) {\n      fs.rmSync(path, { recursive: true, force: true })\n    }\n    fs.mkdirSync(path)\n  } catch (err: any) {\n    throw new Error(`Path cannot be created: ${err.message}`)\n  }\n\n  return path\n}\n```\n\nThe `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`.\n\n### Inconsistent defenses confirm the gap\n\nThe codebase is aware of the risk in similar paths:\n\n1. **Safe path in `utils.ts`**: The `downloadUnzipTarball` function (for NPM/GitHub/URL plugin sources) generates a random name server-side:\n   ```typescript\n   // packages/server/src/api/controllers/plugin/index.ts:68\n   const name = \"PLUGIN_\" + Math.floor(100000 + Math.random() * 900000)\n   ```\n   This is safe because `name` never contains user input.\n\n2. **Safe path in `objectStore.ts`**: Other uses of `budibaseTempDir()` use UUID-generated names:\n   ```typescript\n   // packages/backend-core/src/objectStore/objectStore.ts:546\n   const outputPath = join(budibaseTempDir(), v4())\n   ```\n\n3. **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.\n\nThe file upload path is the only caller of `createTempFolder` that passes unsanitized user input.\n\n### Execution chain\n\n1. Authenticated Global Builder sends `POST /api/plugin/upload` with a multipart file whose `Content-Disposition` filename contains path traversal (e.g., `../../etc/target.tar.gz`)\n2. koa-body/formidable parses the upload, setting `file.name` to the raw filename from the header\n3. `controller.upload` → `sdk.plugins.processUploaded()` → `fileUpload(file)`\n4. `.endsWith(\".tar.gz\")` check passes (the suffix is present)\n5. `.split(\".tar.gz\")[0]` extracts `../../etc/target`\n6. `createTempFolder(\"../../etc/target\")` is called\n7. `path.join(\"/tmp/.budibase\", \"../../etc/target\")` resolves to `/etc/target`\n8. `fs.rmSync(\"/etc/target\", { recursive: true, force: true })` — **deletes the target directory recursively**\n9. `fs.mkdirSync(\"/etc/target\")` — **creates a directory at the traversed path**\n10. `extractTarball(file.path, \"/etc/target\")` — **extracts attacker-controlled tarball contents to the traversed path**\n\n## Proof of Concept\n\n```bash\n# Create a minimal tarball with a test file\nmkdir -p /tmp/plugin-poc && echo \"pwned\" > /tmp/plugin-poc/test.txt\ntar czf /tmp/poc-plugin.tar.gz -C /tmp/plugin-poc .\n\n# Upload with a traversal filename targeting /tmp/pwned (non-destructive demo)\ncurl -X POST 'http://localhost:10000/api/plugin/upload' \\\n  -H 'Cookie: <global_builder_session_cookie>' \\\n  -F \"file=@/tmp/poc-plugin.tar.gz;filename=../../tmp/pwned.tar.gz\"\n\n# Result: server executes:\n#   rm -rf /tmp/pwned        (if exists)\n#   mkdir /tmp/pwned\n#   tar xzf <upload> -C /tmp/pwned\n# Verify: ls /tmp/pwned/test.txt\n```\n\n## Impact\n\n- **Arbitrary directory deletion**: `rmSync` with `{ recursive: true, force: true }` deletes any directory the Node.js process can access, including application data directories\n- **Arbitrary file write**: Tarball extraction writes attacker-controlled files to any writable path, potentially overwriting application code, configuration, or system files\n- **Denial of service**: Deleting critical directories (e.g., the application's data directory, node_modules, or system directories) crashes the application\n- **Potential code execution**: In containerized deployments (common for Budibase) where Node.js runs as root, an attacker could overwrite startup scripts or application code to achieve remote code execution on subsequent restarts\n\n## Recommended Remediation\n\n### Option 1: Sanitize at `createTempFolder` (preferred — protects all callers)\n\n```typescript\nimport { join, resolve } from \"path\"\n\nexport const createTempFolder = (item: string) => {\n  const tempDir = budibaseTempDir()\n  const resolved = resolve(tempDir, item)\n\n  // Ensure the resolved path is within the temp directory\n  if (!resolved.startsWith(tempDir + \"/\") && resolved !== tempDir) {\n    throw new Error(\"Invalid path: directory traversal detected\")\n  }\n\n  try {\n    if (fs.existsSync(resolved)) {\n      fs.rmSync(resolved, { recursive: true, force: true })\n    }\n    fs.mkdirSync(resolved)\n  } catch (err: any) {\n    throw new Error(`Path cannot be created: ${err.message}`)\n  }\n\n  return resolved\n}\n```\n\n### Option 2: Sanitize at the upload handler (defense-in-depth)\n\nStrip path components from the filename before use:\n\n```typescript\nimport path from \"path\"\n\nexport async function fileUpload(file: KoaFile) {\n  if (!file.name || !file.path) {\n    throw new Error(\"File is not valid - cannot upload.\")\n  }\n  if (!file.name.endsWith(\".tar.gz\")) {\n    throw new Error(\"Plugin must be compressed into a gzipped tarball.\")\n  }\n  // Strip directory components from the filename\n  const safeName = path.basename(file.name).split(\".tar.gz\")[0]\n  const dir = createTempFolder(safeName)\n  await extractTarball(file.path, dir)\n\n  return await getPluginMetadata(dir)\n}\n```\n\nBoth options should ideally be applied together for defense-in-depth.\n\n## Credit\n\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).","published":"2026-04-03T15:43:12.426Z","modified":"2026-08-12T03:51:45.564002702Z","cvss":{"score":8.7,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:N/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@budibase/server","fixedVersion":"3.33.4"}],"fix":{"url":"https://github.com/Budibase/budibase/commit/6344d06d703660fd05995e61d581593c2349c879","label":"Budibase/budibase@6344d06"},"references":[{"type":"WEB","url":"https://github.com/Budibase/budibase/releases/tag/3.33.4"},{"type":"ADVISORY","url":"https://github.com/Budibase/budibase/security/advisories/GHSA-2wfh-rcwf-wh23"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/35xxx/CVE-2026-35214.json"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-35214"},{"type":"FIX","url":"https://github.com/Budibase/budibase/commit/6344d06d703660fd05995e61d581593c2349c879"},{"type":"FIX","url":"https://github.com/Budibase/budibase/pull/18240"},{"type":"PACKAGE","url":"https://github.com/Budibase/budibase"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:45.564002702Z"}}