feat: release toolbox portal 0.1.0
This commit is contained in:
206
scripts/zip-security.mjs
Normal file
206
scripts/zip-security.mjs
Normal file
@@ -0,0 +1,206 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user