Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 130 additions & 3 deletions packages/zip/lib/extract.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const { pipeline, Transform } = require('stream');
const errors = require('@tryghost/errors');

const pipelineAsync = promisify(pipeline);

const defaultOptions = {};

function normalizeLimit(value, fieldName) {
Expand Down Expand Up @@ -163,8 +169,6 @@ module.exports = async (zipToExtract, destination, options) => {
let totalUncompressedBytes = 0;
let entriesProcessed = 0;

const extract = require('extract-zip');

opts.dir = destination;

const originalOnEntry = opts.onEntry;
Expand All @@ -190,7 +194,130 @@ module.exports = async (zipToExtract, destination, options) => {
}
};

await extract(zipToExtract, opts);
await extractZip(zipToExtract, opts, limits);

return { path: destination };
};

// Minimal yauzl-based replacement for extract-zip. Two reasons we don't use
// extract-zip directly:
// 1. extract-zip is unmaintained (no release since 2020).
// 2. yauzl's built-in `AssertByteCountStream` is supposed to error when an
// entry's actual decompressed bytes exceed its declared `uncompressedSize`,
// but yauzl overrides that stream's `destroy` so the error never surfaces
// to `extract-zip`'s pipeline — which then hangs forever. We open yauzl
// with `validateEntrySizes: false` and run our own size guard below.
async function extractZip(zipPath, opts, limits) {
const yauzl = require('yauzl');
await fs.promises.mkdir(opts.dir, { recursive: true });
const dir = await fs.promises.realpath(opts.dir);

const zipfile = await new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true, validateEntrySizes: false }, (err, z) => {
if (err) return reject(err);
resolve(z);
});
});

try {
await new Promise((resolve, reject) => {
let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
reject(err);
};

zipfile.on('error', fail);
zipfile.on('end', () => {
if (settled) return;
settled = true;
resolve();
});
zipfile.on('entry', (entry) => {
if (settled) return;
extractEntry(entry, zipfile, opts, limits, dir)
.then(() => settled || zipfile.readEntry())
.catch(fail);
});

zipfile.readEntry();
});
} finally {
try {
zipfile.close();
} catch {
// best-effort
}
}
}

async function extractEntry(entry, zipfile, opts, limits, dir) {
// Skip macOS resource-fork entries — extract-zip did the same.
if (entry.fileName.startsWith('__MACOSX/')) return;

// Existing pre-flight checks (size, symlinks, filename length, onEntry hook).
opts.onEntry(entry, zipfile);

// Stat the entry: mode bits, directory marker, filesystem-mode resolution.
// Mirrors extract-zip's logic verbatim so behaviour is preserved.
const rawMode = (entry.externalFileAttributes >> 16) & 0xffff;
const IFMT = 61440;
const IFDIR = 16384;
const isDir =
(rawMode & IFMT) === IFDIR ||
entry.fileName.endsWith('/') ||
(entry.versionMadeBy >> 8 === 0 && entry.externalFileAttributes === 16);
const defaultMode = isDir
? (opts.defaultDirMode && parseInt(opts.defaultDirMode, 10)) || 0o755
: (opts.defaultFileMode && parseInt(opts.defaultFileMode, 10)) || 0o644;
const mode = (rawMode === 0 ? defaultMode : rawMode) & 0o777;

// Path-traversal guard — port of extract-zip's realpath/relative check.
const entryPath = path.join(dir, entry.fileName);
const containerDir = isDir ? entryPath : path.dirname(entryPath);
await fs.promises.mkdir(containerDir, { recursive: true });
const canonicalContainerDir = await fs.promises.realpath(containerDir);
if (path.relative(dir, canonicalContainerDir).split(path.sep).includes('..')) {
throw new Error(
`Out of bound path "${canonicalContainerDir}" found while processing file ${entry.fileName}`,
);
}

if (isDir) {
await fs.promises.chmod(entryPath, mode).catch(() => {});
return;
}

const readStream = await new Promise((resolve, reject) => {
zipfile.openReadStream(entry, (err, s) => (err ? reject(err) : resolve(s)));
});

// Streaming size enforcement on ACTUAL decompressed bytes. The pre-flight
// check above bounds declared metadata; this catches archives whose central
// directory lies about its uncompressed size (zip-bomb shape).
let entryActualBytes = 0;
const counter = new Transform({
transform(chunk, _encoding, cb) {
entryActualBytes += chunk.length;
if (entryActualBytes > limits.perEntryUncompressedBytes) {
return cb(
new errors.UnsupportedMediaTypeError({
message: 'Zip entry exceeds maximum uncompressed size.',
context:
'The zip contains an entry that exceeds the maximum uncompressed size.',
code: 'ENTRY_TOO_LARGE',
errorDetails: {
entryName: entry.fileName,
observedBytes: entryActualBytes,
limitBytes: limits.perEntryUncompressedBytes,
},
}),
);
}
cb(null, chunk);
},
});

await pipelineAsync(readStream, counter, fs.createWriteStream(entryPath, { mode }));
}
2 changes: 1 addition & 1 deletion packages/zip/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"dependencies": {
"@tryghost/errors": "workspace:*",
"archiver": "7.0.1",
"extract-zip": "2.0.1"
"yauzl": "3.3.0"
},
"devDependencies": {
"folder-hash": "4.1.2",
Expand Down
112 changes: 77 additions & 35 deletions packages/zip/test/zip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ function createZipWithEntries(zipPath, entries) {
});
}

// Rewrite every central-directory header's uncompressedSize field to simulate
// a zip whose declared metadata understates the real payload.
function forgeCentralUncompressedSize(zipPath, fake) {
const buf = fs.readFileSync(zipPath);
for (let i = 0; i < buf.length - 4; i++) {
if (buf.readUInt32LE(i) === 0x02014b50) {
buf.writeUInt32LE(fake, i + 24);
}
}
fs.writeFileSync(zipPath, buf);
}

describe('Compress and Extract should be opposite functions', function () {
let symlinkPath, themeFolder, zipDestination, unzipDestination;

Expand Down Expand Up @@ -227,38 +239,56 @@ describe('Extract zip', function () {
assert.equal(called, true);
});

it('preserves existing behaviour for entries without uncompressed size when no limits are configured', async function () {
function mockYauzlSingleEntry(entry) {
const originalLoad = Module._load;
Module._load = function (request, parent, isMain) {
if (request === 'extract-zip') {
return async (zipPath, opts) => {
opts.onEntry({ fileName: 'missing-size.txt', externalFileAttributes: 0 }, {});
if (request === 'yauzl') {
return {
open(_zipPath, _opts, cb) {
const zipfile = new EventEmitter();
let emitted = false;
zipfile.readEntry = () => {
setImmediate(() => {
if (emitted) return zipfile.emit('end');
emitted = true;
zipfile.emit('entry', entry);
});
};
zipfile.close = () => {};
cb(null, zipfile);
},
};
}

return originalLoad(request, parent, isMain);
};
return () => {
Module._load = originalLoad;
};
}

it('preserves existing behaviour for entries without uncompressed size when no limits are configured', async function () {
// Trailing slash → treated as directory, so we don't need a mock openReadStream.
const restore = mockYauzlSingleEntry({
fileName: 'missing-size-dir/',
externalFileAttributes: 0,
versionMadeBy: 0,
});

try {
const result = await extract(zipDestination, unzipDestination);

assert.equal(result.path, unzipDestination);
} finally {
Module._load = originalLoad;
restore();
}
});

it('throws when a limited entry has no uncompressed size', async function () {
const originalLoad = Module._load;
Module._load = function (request, parent, isMain) {
if (request === 'extract-zip') {
return async (zipPath, opts) => {
opts.onEntry({ fileName: 'missing-size.txt', externalFileAttributes: 0 }, {});
};
}

return originalLoad(request, parent, isMain);
};
const restore = mockYauzlSingleEntry({
fileName: 'missing-size.txt',
externalFileAttributes: 0,
versionMadeBy: 0,
});

try {
await assert.rejects(
Expand All @@ -284,28 +314,17 @@ describe('Extract zip', function () {
},
);
} finally {
Module._load = originalLoad;
restore();
}
});

it('throws when a limited entry has a non-numeric uncompressed size', async function () {
const originalLoad = Module._load;
Module._load = function (request, parent, isMain) {
if (request === 'extract-zip') {
return async (zipPath, opts) => {
opts.onEntry(
{
fileName: 'invalid-size.txt',
externalFileAttributes: 0,
uncompressedSize: Number.NaN,
},
{},
);
};
}

return originalLoad(request, parent, isMain);
};
const restore = mockYauzlSingleEntry({
fileName: 'invalid-size.txt',
externalFileAttributes: 0,
versionMadeBy: 0,
uncompressedSize: Number.NaN,
});

try {
await assert.rejects(
Expand All @@ -326,10 +345,33 @@ describe('Extract zip', function () {
},
);
} finally {
Module._load = originalLoad;
restore();
}
});

it('rejects a zip whose central directory understates an entry size (streaming defence)', async function () {
// The metadata-only pre-flight check sees a tiny declared size and lets it
// through; the streaming counter sees the real 1 MB payload and rejects.
await createZipWithEntries(zipDestination, [
{ name: 'lying.txt', content: Buffer.alloc(1024 * 1024, 'a') },
]);
forgeCentralUncompressedSize(zipDestination, 5);

await assert.rejects(
() =>
extract(zipDestination, unzipDestination, {
limits: { perEntryUncompressedBytes: 100 },
}),
(err) => {
assert.equal(err.code, 'ENTRY_TOO_LARGE');
assert.equal(err.errorDetails.entryName, 'lying.txt');
assert.equal(err.errorDetails.limitBytes, 100);
assert.equal(err.errorDetails.observedBytes > 100, true);
return true;
},
);
});

it('throws when an entry exceeds the per-entry uncompressed size limit', async function () {
await createZipWithEntries(zipDestination, [{ name: 'big-file.txt', content: '12345' }]);

Expand Down
Loading
Loading