Summary
Budibase writes user-controlled content into the APPS object store using S3 keys that include a user-supplied filename component (e.g. AI knowledge file uploads, worker global config uploads). The sanitizeKey helper (packages/backend-core/src/objectStore/objectStore.ts:94-96) - backed by sanitize-s3-objectkey@0.0.1 - is intended to neutralise dangerous path sequences, but it preserves .. segments verbatim: the library only strips "unsafe" punctuation, keeping / and . (and \\ after the .replace(/\\/g, "/") in sanitizeKey). As a result, a builder can store an S3 object whose key literally contains traversal segments like app_prod_x/ai/knowledge-bases/.../files/f_x/../../../../etc/cron.d/evil.
When the same workspace is later exported via POST /api/backups/export (builder-gated), exportWorkspace calls retrieveDirectory(APPS, "${workspaceId}/") (packages/server/src/sdk/workspace/backups/exports.ts:129). retrieveDirectory lists every object under the workspace prefix - including the poisoned one - and writes each object's body to the local filesystem via join(writePath, ...possiblePath) where possiblePath = Key.split("/") (packages/backend-core/src/objectStore/objectStore.ts:593-604). Node's path.join resolves the .. segments, so the object body is written to a path that escapes writePath.
This is an authenticated-builder -> arbitrary file write on the server's filesystem during export, with attacker-controlled content. The destination is bounded by how many .. segments the key contains: enough segments reach /tmp/, /etc/cron.d/, ~/.ssh/, or anywhere else writable by the Budibase process.
Affected
| Component |
Path |
Lines |
sanitizeKey preserves .. |
packages/backend-core/src/objectStore/objectStore.ts |
94-96 (delegates to sanitize-s3-objectkey@0.0.1) |
retrieveDirectory writes without containment |
packages/backend-core/src/objectStore/objectStore.ts |
568-613 (write at 599-604) |
Export flow calls retrieveDirectory |
packages/server/src/sdk/workspace/backups/exports.ts |
129-133 |
| Export route (builder-gated) |
packages/server/src/api/routes/backup.ts |
5-9 (POST /api/backups/export) |
| AI knowledge file upload uses user filename as S3 key |
packages/server/src/sdk/workspace/ai/knowledgeBase/uploads.ts |
42-48 (key builder), 78-84 (upload) |
| AI knowledge file upload controller (builderAdmin) |
packages/server/src/api/controllers/ai/files.ts |
167-242 (uploadAgentFile); 189 (filename from multipart) |
| AI knowledge file upload route (builderOrAdmin) |
packages/server/src/api/routes/aiAgents.ts |
117 (POST /api/agent/:agentId/operations/:operationId/files) |
| Worker global config upload (admin) |
packages/worker/src/api/controllers/global/configs.ts |
678-721 (uses name URL param); 137-141 (route) |
Affected versions: master at commit 3c8d1b4023. sanitize-s3-objectkey is pinned at 0.0.1 (packages/backend-core/package.json), a 10+ year old micro-library that has never been patched to remove ... The retrieveDirectory write loop has lacked a containment check since it was introduced.
Reachable over HTTP by: any authenticated user with builderOrAdmin permission (via AI knowledge file upload at routes/aiAgents.ts:117) or admin permission (via worker global config upload). The export step that triggers the file write is builder-gated (POST /api/backups/export requires BUILDER + tenant ownership per ensureTenantAppOwnershipMiddleware).
Root cause
Issue 1 - sanitizeKey does not neutralise ...
// packages/backend-core/src/objectStore/objectStore.ts:94-96
export function sanitizeKey(input: string): string {
return sanitize(sanitizeBucket(input)).replace(/\\/g, "/")
}
sanitize is sanitize-s3-objectkey@0.0.1. From the upstream source, its SAFE_CHARACTERS = /[^0-9a-zA-Z! _\.\*'\(\)\/-]/g keeps ., /, and -. A key containing .. survives untouched.
Verified empirically with the pinned library:
IN : app_prod_x/ai/knowledge-bases/kb_x/files/f_x/../../../../etc/cron.d/evil
OUT: app_prod_x/ai/knowledge-bases/kb_x/files/f_x/../../../../etc/cron.d/evil
IN : app_x/../../../etc/passwd
OUT: app_x/../../../etc/passwd
sanitizeKey then only runs .replace(/\\/g, "/") - converting backslashes to forward slashes. It does not collapse ...
Issue 2 - retrieveDirectory writes via path.join without containment.
// packages/backend-core/src/objectStore/objectStore.ts:568-613
export async function retrieveDirectory(bucketName, path, toExclude?) {
return await tracer.trace("retrieveDirectory", async span => {
let writePath = join(budibaseTempDir(), v4())
await fsp.mkdir(writePath, { recursive: true })
...
await utils.parallelForeach(
listAllObjects(bucketName, path),
async object => {
const { Key } = object
if (!Key || toExclude?.some(x => x.test(Key))) return
...
const filename = object.Key!
const possiblePath = filename.split("/") // <- includes ".." segments
const dirs = possiblePath.slice(0, possiblePath.length - 1)
const possibleDir = join(writePath, ...dirs) // <- resolves ".."
if (possiblePath.length > 1 && !fs.existsSync(possibleDir)) {
await fsp.mkdir(possibleDir, { recursive: true })
}
await pipeline(stream, fs.createWriteStream(join(writePath, ...possiblePath), { mode: 0o644 }))
},
5
)
...
})
}
path.join(writePath, "a", "b", "..", "..", "evil") resolves to path.join(writePath, "evil") - the .. segments cancel the preceding directory components. With enough .. segments, the resolved path escapes writePath entirely. There is no assertWithinTempDir check, no path.resolve containment check, no realpath comparison. Contrast with packages/server/src/utilities/fileSystem/filesystem.ts:83-97 which DOES enforce containment for plugin tarball extraction - retrieveDirectory was simply missed.
Issue 3 - The attacker can place a poisoned key under their own workspace prefix.
The AI knowledge file upload at sdk/workspace/ai/knowledgeBase/uploads.ts:42-48 builds the key as ${workspaceId}/ai/knowledge-bases/${knowledgeBaseId}/files/${fileId}/${filename} where filename comes directly from the multipart originalFilename (controllers/ai/files.ts:189). After sanitizeKey (which preserves ..), the stored S3 key is literally: app_prod_<tenant>-<app>/ai/knowledge-bases/<kb>/files/<f>/../../../../etc/cron.d/evil.
When exportWorkspace calls retrieveDirectory(APPS, "${workspaceId}/"), the listing includes this key (it begins with the workspace prefix). The write at objectStore.ts:601 then resolves the .. segments and writes the file to the escaped location.
Reproduction
Step-by-step exploit
-
Authenticate as a builder in the target tenant.
-
Upload an AI knowledge file with a traversal-crafted filename:
POST /api/agent/<agentId>/operations/<operationId>/files
Content-Type: multipart/form-data; boundary=...
Cookie: <builder session>
--...
Content-Disposition: form-data; name="file"; filename="../../../../../../../../tmp/f10-rce-marker.txt"
Content-Type: application/pdf
<file bytes>
--...--
The controller at controllers/ai/files.ts:189 reads filename = upload.originalFilename = the traversal string. It is passed to uploadKnowledgeBaseFile -> buildKnowledgeBaseFileObjectStoreKey -> objectStore.upload({ filename: <key with ../> }) -> sanitizeKey (which preserves the ..). The object is stored.
-
Trigger an export of the same workspace:
POST /api/backups/export?appId=<workspaceId>
Cookie: <builder session>
-
exportWorkspace -> retrieveDirectory(APPS, "${workspaceId}/") lists the poisoned key. The write loop calls path.join("/tmp/.budibase/<uuid>", "app_prod_...", "ai", "knowledge-bases", ..., "..", "..", ..., "tmp", "f10-rce-marker.txt"), which resolves to /tmp/f10-rce-marker.txt (or further up with more .. segments). The object body (the attacker's uploaded file content) is written there.
-
The attacker has now written an arbitrary-content file outside the temp directory. Depending on the destination: /etc/cron.d/evil -> cron executes the file as a cron job -> RCE. ~/.ssh/authorized_keys -> SSH login as the Budibase user -> RCE. ${budibase_install_dir}/node_modules/<pkg>/index.js -> loaded by Node on next require -> RCE. Any path watched by a file-watcher / hot-reload -> RCE.
Controlled runtime PoC
f10-retrieve-directory-write.js reproduces the exact sanitizeKey + retrieveDirectory write loop against the actual pinned sanitize-s3-objectkey@0.0.1 library. Output:
=== Step 1: builder upload ===
Builder-supplied filename: ../../../../../../../../tmp/f10-rce-marker.txt
Object store key (raw): app_prod_test-tenant-app/ai/knowledge-bases/kb_test/files/f_test/../../../../../../../../tmp/f10-rce-marker.txt
After sanitizeKey: app_prod_test-tenant-app/ai/knowledge-bases/kb_test/files/f_test/../../../../../../../../tmp/f10-rce-marker.txt
sanitizeKey preserved ../? YES
=== Step 2: retrieveDirectory write (simulating export) ===
writePath: /tmp/f10-export-h5EjcO
S3 Key being written: app_prod_test-tenant-app/ai/knowledge-bases/kb_test/files/f_test/../../../../../../../../tmp/f10-rce-marker.txt
Written to: /tmp/f10-rce-marker.txt
=== Step 3: check escape ===
!!! /tmp/f10-rce-marker.txt EXISTS (size=33)
!!! Content: "ARBITRARY-CONTENT-WRITTEN-BY-F10\n"
!!! CONCLUSION: arbitrary file write outside writePath - F10 CONFIRMED
The file was written to /tmp/f10-rce-marker.txt (outside the simulated writePath = /tmp/f10-export-XXX), with attacker-controlled content.
Impact
| Capability |
Available |
| Arbitrary-content file write at any path writable by the Budibase process |
✅ |
RCE via /etc/cron.d/evil (cron executes it) - on systems where Budibase runs as root or has cron write access |
✅ |
RCE via ~/.ssh/authorized_keys - if the Budibase user's home is reachable with enough .. segments and SSH is exposed |
✅ |
RCE via writing into a Node-loaded module path (e.g. a node_modules/<pkg> file that Budibase requires) |
✅ |
| Persistence / tampering of any file the process can write |
✅ |
The attacker is an authenticated builder in the same tenant. The write is content-controlled (the attacker chose the uploaded file body) and path-controlled (the attacker chose the .. count).
The most realistic RCE vector depends on the deployment: Self-hosted Linux running as a non-root service user: target ~/.ssh/authorized_keys (if SSH exposed), or any directory in the service user's PATH that is loaded by a cron job. Container deployments: target writable paths inside the container (e.g. /etc/cron.d/, /tmp/ watched by a supervisor, or the container's Node node_modules if writable). Cloud multi-tenant: the builder is a malicious tenant; the write lands on the shared server's filesystem. Tenant isolation is broken at the filesystem level.
Fix
Recommended layered fixes:
-
Patch sanitizeKey to reject or collapse ... In packages/backend-core/src/objectStore/objectStore.ts:94-96, after the sanitize call, either reject keys containing .. segments or strip them:
export function sanitizeKey(input: string): string {
let key = sanitize(sanitizeBucket(input)).replace(/\\/g, "/")
if (/(^|\/)\.\.?(\/|$)/.test(key)) {
throw new Error(`Invalid S3 key: contains traversal segment: ${input}`)
}
return key
}
Apply this at the chokepoint so every caller benefits.
-
Add a containment assertion to retrieveDirectory. Before the createWriteStream, resolve the target path and assert it is inside writePath:
const target = path.resolve(writePath, ...possiblePath)
const resolvedWritePath = path.resolve(writePath) + path.sep
if (!target.startsWith(resolvedWritePath)) {
console.warn(`retrieveDirectory: refusing to write outside writePath: ${target}`)
return
}
This mirrors the existing assertWithinTempDir pattern in packages/server/src/utilities/fileSystem/filesystem.ts:83-97 that correctly protects plugin tarball extraction.
-
Pattern-validate the AI knowledge file filename at upload time in controllers/ai/files.ts:189 - reject any filename containing path separators or ... Similarly validate the worker global config name param in routes/global/configs.ts:137-141 (currently only Joi.string().required()).
-
Replace sanitize-s3-objectkey@0.0.1 with a maintained alternative (or vendor the function inline). The library is 10+ years old and unmaintained.
The single most defensible fix is #2 (containment in retrieveDirectory) - it eliminates the write primitive regardless of how the key was constructed. But #1 and #3 together also close the upload-time injection.
Summary
Budibase writes user-controlled content into the APPS object store using S3 keys that include a user-supplied filename component (e.g. AI knowledge file uploads, worker global config uploads). The
sanitizeKeyhelper (packages/backend-core/src/objectStore/objectStore.ts:94-96) - backed bysanitize-s3-objectkey@0.0.1- is intended to neutralise dangerous path sequences, but it preserves..segments verbatim: the library only strips "unsafe" punctuation, keeping/and.(and\\after the.replace(/\\/g, "/")insanitizeKey). As a result, a builder can store an S3 object whose key literally contains traversal segments likeapp_prod_x/ai/knowledge-bases/.../files/f_x/../../../../etc/cron.d/evil.When the same workspace is later exported via
POST /api/backups/export(builder-gated),exportWorkspacecallsretrieveDirectory(APPS, "${workspaceId}/")(packages/server/src/sdk/workspace/backups/exports.ts:129).retrieveDirectorylists every object under the workspace prefix - including the poisoned one - and writes each object's body to the local filesystem viajoin(writePath, ...possiblePath)wherepossiblePath = Key.split("/")(packages/backend-core/src/objectStore/objectStore.ts:593-604). Node'spath.joinresolves the..segments, so the object body is written to a path that escapeswritePath.This is an authenticated-builder -> arbitrary file write on the server's filesystem during export, with attacker-controlled content. The destination is bounded by how many
..segments the key contains: enough segments reach/tmp/,/etc/cron.d/,~/.ssh/, or anywhere else writable by the Budibase process.Affected
sanitizeKeypreserves..packages/backend-core/src/objectStore/objectStore.ts94-96(delegates tosanitize-s3-objectkey@0.0.1)retrieveDirectorywrites without containmentpackages/backend-core/src/objectStore/objectStore.ts568-613(write at599-604)retrieveDirectorypackages/server/src/sdk/workspace/backups/exports.ts129-133packages/server/src/api/routes/backup.ts5-9(POST /api/backups/export)packages/server/src/sdk/workspace/ai/knowledgeBase/uploads.ts42-48(key builder),78-84(upload)packages/server/src/api/controllers/ai/files.ts167-242(uploadAgentFile);189(filename from multipart)packages/server/src/api/routes/aiAgents.ts117(POST /api/agent/:agentId/operations/:operationId/files)packages/worker/src/api/controllers/global/configs.ts678-721(usesnameURL param);137-141(route)Affected versions:
masterat commit3c8d1b4023.sanitize-s3-objectkeyis pinned at0.0.1(packages/backend-core/package.json), a 10+ year old micro-library that has never been patched to remove... TheretrieveDirectorywrite loop has lacked a containment check since it was introduced.Reachable over HTTP by: any authenticated user with builderOrAdmin permission (via AI knowledge file upload at
routes/aiAgents.ts:117) or admin permission (via worker global config upload). The export step that triggers the file write is builder-gated (POST /api/backups/exportrequires BUILDER + tenant ownership perensureTenantAppOwnershipMiddleware).Root cause
Issue 1 -
sanitizeKeydoes not neutralise...sanitizeissanitize-s3-objectkey@0.0.1. From the upstream source, itsSAFE_CHARACTERS = /[^0-9a-zA-Z! _\.\*'\(\)\/-]/gkeeps.,/, and-. A key containing..survives untouched.Verified empirically with the pinned library:
sanitizeKeythen only runs.replace(/\\/g, "/")- converting backslashes to forward slashes. It does not collapse...Issue 2 -
retrieveDirectorywrites viapath.joinwithout containment.path.join(writePath, "a", "b", "..", "..", "evil")resolves topath.join(writePath, "evil")- the..segments cancel the preceding directory components. With enough..segments, the resolved path escapeswritePathentirely. There is noassertWithinTempDircheck, nopath.resolvecontainment check, norealpathcomparison. Contrast withpackages/server/src/utilities/fileSystem/filesystem.ts:83-97which DOES enforce containment for plugin tarball extraction -retrieveDirectorywas simply missed.Issue 3 - The attacker can place a poisoned key under their own workspace prefix.
The AI knowledge file upload at
sdk/workspace/ai/knowledgeBase/uploads.ts:42-48builds the key as${workspaceId}/ai/knowledge-bases/${knowledgeBaseId}/files/${fileId}/${filename}wherefilenamecomes directly from the multipartoriginalFilename(controllers/ai/files.ts:189). AftersanitizeKey(which preserves..), the stored S3 key is literally:app_prod_<tenant>-<app>/ai/knowledge-bases/<kb>/files/<f>/../../../../etc/cron.d/evil.When
exportWorkspacecallsretrieveDirectory(APPS, "${workspaceId}/"), the listing includes this key (it begins with the workspace prefix). The write atobjectStore.ts:601then resolves the..segments and writes the file to the escaped location.Reproduction
Step-by-step exploit
Authenticate as a builder in the target tenant.
Upload an AI knowledge file with a traversal-crafted filename:
The controller at
controllers/ai/files.ts:189readsfilename = upload.originalFilename= the traversal string. It is passed touploadKnowledgeBaseFile->buildKnowledgeBaseFileObjectStoreKey->objectStore.upload({ filename: <key with ../> })->sanitizeKey(which preserves the..). The object is stored.Trigger an export of the same workspace:
exportWorkspace->retrieveDirectory(APPS, "${workspaceId}/")lists the poisoned key. The write loop callspath.join("/tmp/.budibase/<uuid>", "app_prod_...", "ai", "knowledge-bases", ..., "..", "..", ..., "tmp", "f10-rce-marker.txt"), which resolves to/tmp/f10-rce-marker.txt(or further up with more..segments). The object body (the attacker's uploaded file content) is written there.The attacker has now written an arbitrary-content file outside the temp directory. Depending on the destination:
/etc/cron.d/evil-> cron executes the file as a cron job -> RCE.~/.ssh/authorized_keys-> SSH login as the Budibase user -> RCE.${budibase_install_dir}/node_modules/<pkg>/index.js-> loaded by Node on next require -> RCE. Any path watched by a file-watcher / hot-reload -> RCE.Controlled runtime PoC
f10-retrieve-directory-write.jsreproduces the exactsanitizeKey+retrieveDirectorywrite loop against the actual pinnedsanitize-s3-objectkey@0.0.1library. Output:The file was written to
/tmp/f10-rce-marker.txt(outside the simulatedwritePath = /tmp/f10-export-XXX), with attacker-controlled content.Impact
/etc/cron.d/evil(cron executes it) - on systems where Budibase runs as root or has cron write access~/.ssh/authorized_keys- if the Budibase user's home is reachable with enough..segments and SSH is exposednode_modules/<pkg>file that Budibaserequires)The attacker is an authenticated builder in the same tenant. The write is content-controlled (the attacker chose the uploaded file body) and path-controlled (the attacker chose the
..count).The most realistic RCE vector depends on the deployment: Self-hosted Linux running as a non-root service user: target
~/.ssh/authorized_keys(if SSH exposed), or any directory in the service user's PATH that is loaded by a cron job. Container deployments: target writable paths inside the container (e.g./etc/cron.d/,/tmp/watched by a supervisor, or the container's Nodenode_modulesif writable). Cloud multi-tenant: the builder is a malicious tenant; the write lands on the shared server's filesystem. Tenant isolation is broken at the filesystem level.Fix
Recommended layered fixes:
Patch
sanitizeKeyto reject or collapse... Inpackages/backend-core/src/objectStore/objectStore.ts:94-96, after thesanitizecall, either reject keys containing..segments or strip them:Apply this at the chokepoint so every caller benefits.
Add a containment assertion to
retrieveDirectory. Before thecreateWriteStream, resolve the target path and assert it is insidewritePath:This mirrors the existing
assertWithinTempDirpattern inpackages/server/src/utilities/fileSystem/filesystem.ts:83-97that correctly protects plugin tarball extraction.Pattern-validate the AI knowledge file
filenameat upload time incontrollers/ai/files.ts:189- reject any filename containing path separators or... Similarly validate the worker global confignameparam inroutes/global/configs.ts:137-141(currently onlyJoi.string().required()).Replace
sanitize-s3-objectkey@0.0.1with a maintained alternative (or vendor the function inline). The library is 10+ years old and unmaintained.The single most defensible fix is #2 (containment in
retrieveDirectory) - it eliminates the write primitive regardless of how the key was constructed. But #1 and #3 together also close the upload-time injection.