Hall of Fame image from https://openclipart.org/detail/120343/trophy
Back to Hall of Fame Contents Back to Wekan Website

Contents / ZipBleed

CVE Vulnerability name Date Responsible Security Disclosure by Vulnerabilities
-

ZipBleed

2026-07-25 xet7 (found and fix)

Found while reviewing the open dependency pull requests.
  • 1. ZipBleed — arbitrary file write when restoring a backup archive (CWE-22 Improper Limitation of a Pathname to a Restricted Directory, "zip-slip")
  • Affected the WeKan backup restore (server/methods/backup.js)
  • Fixed at upcoming WeKan release


Details

1. ZipBleed — a restored backup archive could write outside the files directory (CWE-22)

Admin Panel → Attachments → Backup can restore a WeKan backup: a zip archive holding <stamp>/attachments/…, <stamp>/avatars/… and <stamp>/data/<collection>.ndjson. The restore streamed each entry to disk under the matching directory:

// vulnerable — the entry chooses its own destination
for (const entry of directory.files) {
  if (entry.type !== 'File') continue;
  const rel = entry.path.split('/').slice(1);   // drop the <stamp> top folder
  const kind = rel[0];
  if (kind !== 'attachments' && kind !== 'avatars') continue;
  const destPath = path.join(
    kind === 'attachments' ? attachmentsDir() : avatarsDir(), ...rel.slice(1));
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
  entry.stream().pipe(fs.createWriteStream(destPath));
}
    

A zip entry carries its own path, chosen by whoever built the archive, and path.join() resolves .. segments rather than rejecting them. The only check here is on the FIRST segment, so an entry named

2026-07-25_12-00-00/attachments/../../../../etc/cron.d/wekan
    

satisfies it — its first segment really is attachments — and then joins its way clean out of the files directory. With attachmentsDir() = /data/files/attachments, that entry resolves to /etc/cron.d/wekan, and the entry's contents are streamed there. A crafted backup.zip could therefore drop a new file or overwrite an existing one anywhere the WeKan process could write. This is the long-known "zip-slip" pattern, the same class as the unzipper advisory GHSA-884w-698f-927f — but in WeKan's own code, not in the library: WeKan runs unzipper 0.12.x, which is not affected by that advisory.

restoreBackup is behind requireAdmin(), so an admin has to run the restore. That is not much of a barrier here, because it is also exactly how a hostile archive arrives: it is offered as a backup to restore, at the one moment nobody inspects the file they were handed. Nothing in the archive is visible before it is unpacked.

Fix: resolve each entry to an absolute path and require it to sit under the directory it belongs in. Comparing against the base plus a separator matters — a bare startsWith(base) would also accept a sibling directory whose name merely begins with the same letters, such as /data/files/attachments-evil.

// fixed — models/lib/backupPaths.js
function safeEntryPath(baseDir, segments) {
  if (!baseDir) return null;
  const parts = (Array.isArray(segments) ? segments : []).filter(
    s => typeof s === 'string' && s !== '');
  if (!parts.length) return null;
  const base = path.resolve(baseDir);
  const dest = path.resolve(base, ...parts);
  if (dest === base) return null;
  return dest.startsWith(base + path.sep) ? dest : null;
}
    

The data half of an archive names the Mongo collection to restore into, and that name came from the entry too, so it is now constrained to a plain [A-Za-z0-9_-]+ name. That also keeps a restore out of the database's internal system.* collections, which are excluded from backups in the first place.

A refused entry is skipped and reported to the server log rather than thrown on, so one hostile entry in an otherwise good archive cannot abort a genuine restore.

tests/zipbleed.test.cjs pins this: the exact traversal above is refused, a plain entry still restores where it belongs, absolute segments and prefix-matching siblings are refused, and the restore really calls both guards. One of its assertions reproduces what the old code did with the attack path — path.join returning /etc/cron.d/wekan — so the test states the danger rather than only the remedy.