207 lines
6.5 KiB
JavaScript
207 lines
6.5 KiB
JavaScript
import { createWriteStream } from 'node:fs';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import yauzl from 'yauzl';
|
|
|
|
const MAX_ENTRIES = 50_000;
|
|
const MAX_FILE_SIZE = 256 * 1024 * 1024;
|
|
const MAX_TOTAL_SIZE = 2 * 1024 * 1024 * 1024;
|
|
const MAX_MANIFEST_SIZE = 1024 * 1024;
|
|
const MAX_COMPRESSION_RATIO = 1_000;
|
|
|
|
function openZip(file) {
|
|
return new Promise((resolve, reject) => {
|
|
yauzl.open(
|
|
file,
|
|
{
|
|
lazyEntries: true,
|
|
autoClose: true,
|
|
decodeStrings: true,
|
|
validateEntrySizes: true,
|
|
},
|
|
(error, zip) => {
|
|
if (error) reject(error);
|
|
else resolve(zip);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function readEntry(zip, entry, limit = MAX_FILE_SIZE) {
|
|
return new Promise((resolve, reject) => {
|
|
zip.openReadStream(entry, (error, stream) => {
|
|
if (error) return reject(error);
|
|
const chunks = [];
|
|
let length = 0;
|
|
stream.on('data', (chunk) => {
|
|
length += chunk.length;
|
|
if (length > limit)
|
|
stream.destroy(
|
|
new Error(`ZIP entry exceeds the ${limit}-byte read limit.`)
|
|
);
|
|
else chunks.push(chunk);
|
|
});
|
|
stream.once('error', reject);
|
|
stream.once('end', () => resolve(Buffer.concat(chunks)));
|
|
});
|
|
});
|
|
}
|
|
|
|
function unixFileType(entry) {
|
|
const madeBy = entry.versionMadeBy >>> 8;
|
|
return madeBy === 3 ? (entry.externalFileAttributes >>> 16) & 0o170000 : 0;
|
|
}
|
|
|
|
export function safeEntryPath(fileName) {
|
|
if (
|
|
typeof fileName !== 'string' ||
|
|
fileName.length === 0 ||
|
|
fileName.includes('\0')
|
|
)
|
|
throw new Error('ZIP contains an invalid empty or NUL path.');
|
|
const portable = fileName.replaceAll('\\', '/');
|
|
if (portable.startsWith('/') || /^[A-Za-z]:\//.test(portable))
|
|
throw new Error(`ZIP contains an absolute path: ${fileName}`);
|
|
const components = portable.split('/');
|
|
if (components.some((part) => part === '..'))
|
|
throw new Error(`ZIP path traversal blocked: ${fileName}`);
|
|
const normalized = path.posix.normalize(portable);
|
|
if (normalized === '..' || normalized.startsWith('../'))
|
|
throw new Error(`ZIP path traversal blocked: ${fileName}`);
|
|
if (normalized === '.' && !portable.endsWith('/'))
|
|
throw new Error(`ZIP contains an invalid path: ${fileName}`);
|
|
return normalized;
|
|
}
|
|
|
|
function validateMetadata(entry, total) {
|
|
const normalized = safeEntryPath(entry.fileName);
|
|
const directory = entry.fileName.endsWith('/');
|
|
const fileType = unixFileType(entry);
|
|
if (fileType === 0o120000)
|
|
throw new Error(`ZIP symbolic link blocked: ${entry.fileName}`);
|
|
if (fileType !== 0 && fileType !== 0o100000 && fileType !== 0o040000)
|
|
throw new Error(`ZIP special file blocked: ${entry.fileName}`);
|
|
if (entry.uncompressedSize > MAX_FILE_SIZE)
|
|
throw new Error(`ZIP entry is too large: ${entry.fileName}`);
|
|
if (
|
|
entry.compressedSize > 0 &&
|
|
entry.uncompressedSize / entry.compressedSize > MAX_COMPRESSION_RATIO
|
|
) {
|
|
throw new Error(
|
|
`ZIP entry has an unsafe compression ratio: ${entry.fileName}`
|
|
);
|
|
}
|
|
if (total + entry.uncompressedSize > MAX_TOTAL_SIZE)
|
|
throw new Error('ZIP exceeds the total uncompressed-size limit.');
|
|
return { normalized, directory };
|
|
}
|
|
|
|
export async function inspectAppArchive(file) {
|
|
const zip = await openZip(file);
|
|
const entries = [];
|
|
const paths = new Set();
|
|
let total = 0;
|
|
let manifestBuffer;
|
|
|
|
await new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
zip.close();
|
|
reject(error);
|
|
};
|
|
zip.once('error', fail);
|
|
zip.once('end', () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve();
|
|
});
|
|
zip.on('entry', (entry) => {
|
|
void (async () => {
|
|
if (entries.length >= MAX_ENTRIES)
|
|
throw new Error(`ZIP exceeds the ${MAX_ENTRIES}-entry limit.`);
|
|
const metadata = validateMetadata(entry, total);
|
|
total += entry.uncompressedSize;
|
|
if (paths.has(metadata.normalized))
|
|
throw new Error(`ZIP contains a duplicate path: ${entry.fileName}`);
|
|
paths.add(metadata.normalized);
|
|
entries.push({ fileName: entry.fileName, ...metadata });
|
|
if (!metadata.directory && metadata.normalized === 'toolbox-app.json') {
|
|
manifestBuffer = await readEntry(zip, entry, MAX_MANIFEST_SIZE);
|
|
}
|
|
zip.readEntry();
|
|
})().catch(fail);
|
|
});
|
|
zip.readEntry();
|
|
});
|
|
|
|
if (!manifestBuffer)
|
|
throw new Error('ZIP does not contain toolbox-app.json at its root.');
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(manifestBuffer.toString('utf8'));
|
|
} catch (error) {
|
|
throw new Error('ZIP toolbox-app.json is not valid JSON.', {
|
|
cause: error,
|
|
});
|
|
}
|
|
return { entries, manifest };
|
|
}
|
|
|
|
export async function extractInspectedArchive(file, destination, inspection) {
|
|
const root = path.resolve(destination);
|
|
await mkdir(root, { recursive: true });
|
|
const allowed = new Map(
|
|
inspection.entries.map((entry) => [entry.fileName, entry])
|
|
);
|
|
const zip = await openZip(file);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
zip.close();
|
|
reject(error);
|
|
};
|
|
zip.once('error', fail);
|
|
zip.once('end', () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve();
|
|
});
|
|
zip.on('entry', (entry) => {
|
|
void (async () => {
|
|
const metadata = allowed.get(entry.fileName);
|
|
if (!metadata)
|
|
throw new Error(
|
|
`ZIP changed between inspection and extraction: ${entry.fileName}`
|
|
);
|
|
const target = path.resolve(root, ...metadata.normalized.split('/'));
|
|
if (target !== root && !target.startsWith(`${root}${path.sep}`))
|
|
throw new Error(
|
|
`ZIP path escaped extraction root: ${entry.fileName}`
|
|
);
|
|
if (metadata.directory) {
|
|
await mkdir(target, { recursive: true });
|
|
} else {
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await new Promise((resolveStream, rejectStream) => {
|
|
zip.openReadStream(entry, (error, stream) => {
|
|
if (error) return rejectStream(error);
|
|
pipeline(
|
|
stream,
|
|
createWriteStream(target, { flags: 'wx', mode: 0o644 })
|
|
).then(resolveStream, rejectStream);
|
|
});
|
|
});
|
|
}
|
|
zip.readEntry();
|
|
})().catch(fail);
|
|
});
|
|
zip.readEntry();
|
|
});
|
|
}
|