Files
toolbox-portal/scripts/zip-security.mjs

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();
});
}