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