import unzipper from 'unzipper' const DEFAULT_MAX_ENTRY_COUNT = 5000 const DEFAULT_MAX_ENTRY_BYTES = 300 * 1024 * 1024 const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024 const DEFAULT_MAX_COMPRESSION_RATIO = 250 /** * ZIP 엔트리 경로를 안전하게 정리한다. * @param {string} value - ZIP 내부 경로 * @returns {string} 정리된 경로 */ const normalizeZipEntryPath = (value) => String(value || '') .replace(/\\/g, '/') .replace(/^\/+/g, '') .split('/') .filter((part) => part && part !== '.' && part !== '..') .join('/') /** * ZIP 내부 경로가 상위 폴더나 절대 경로를 가리키는지 확인한다. * @param {string} value - 원본 ZIP 경로 * @returns {boolean} 안전하지 않은 경로 여부 */ const isUnsafeZipEntryPath = (value) => { const normalized = String(value || '').replace(/\\/g, '/') return normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').some((part) => part === '..') } /** * ZIP 엔트리 스트림을 제한된 크기의 Buffer로 읽는다. * @param {NodeJS.ReadableStream} stream - 엔트리 스트림 * @param {number} maxBytes - 최대 허용 바이트 * @returns {Promise} 엔트리 데이터 */ const readStreamBuffer = async (stream, maxBytes) => { const chunks = [] let totalBytes = 0 for await (const chunk of stream) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) totalBytes += buffer.length if (totalBytes > maxBytes) { stream.destroy() throw new Error('ZIP_ENTRY_SIZE_LIMIT_EXCEEDED') } chunks.push(buffer) } return Buffer.concat(chunks, totalBytes) } /** * ZIP 중앙 디렉터리를 읽고 엔트리 수·크기·압축률을 검증한다. * 실제 파일 데이터는 필요할 때 각 엔트리 스트림으로 읽는다. * @param {string} zipFilePath - ZIP 파일 절대 경로 * @param {{ maxEntryCount?: number, maxEntryBytes?: number, maxTotalBytes?: number, maxCompressionRatio?: number }} [options] - 제한 옵션 * @returns {Promise Promise, createReadStream: () => NodeJS.ReadableStream }>>} ZIP 엔트리 목록 */ export const openZipFileEntries = async (zipFilePath, options = {}) => { const maxEntryCount = Number(options.maxEntryCount) || DEFAULT_MAX_ENTRY_COUNT const maxEntryBytes = Number(options.maxEntryBytes) || DEFAULT_MAX_ENTRY_BYTES const maxTotalBytes = Number(options.maxTotalBytes) || DEFAULT_MAX_TOTAL_BYTES const maxCompressionRatio = Number(options.maxCompressionRatio) || DEFAULT_MAX_COMPRESSION_RATIO let directory = null try { directory = await unzipper.Open.file(zipFilePath) } catch { throw new Error('INVALID_ZIP_FILE') } const sourceEntries = directory.files.filter((entry) => entry.type !== 'Directory') if (sourceEntries.length > maxEntryCount) { throw new Error('ZIP_ENTRY_COUNT_LIMIT_EXCEEDED') } const entries = [] const usedPaths = new Set() let totalUncompressedBytes = 0 for (const sourceEntry of sourceEntries) { const rawPath = String(sourceEntry.path || '') if (isUnsafeZipEntryPath(rawPath)) { throw new Error('UNSAFE_ZIP_ENTRY_PATH') } const normalizedPath = normalizeZipEntryPath(rawPath) if (!normalizedPath || normalizedPath.startsWith('__MACOSX/')) { continue } if (usedPaths.has(normalizedPath)) { throw new Error('DUPLICATE_ZIP_ENTRY_PATH') } usedPaths.add(normalizedPath) const compressedSize = Number(sourceEntry.compressedSize || 0) const uncompressedSize = Number(sourceEntry.uncompressedSize || 0) const compressionRatio = uncompressedSize / Math.max(compressedSize, 1) if (uncompressedSize < 0 || uncompressedSize > maxEntryBytes) { throw new Error('ZIP_ENTRY_SIZE_LIMIT_EXCEEDED') } if (uncompressedSize > 1024 * 1024 && compressionRatio > maxCompressionRatio) { throw new Error('ZIP_COMPRESSION_RATIO_LIMIT_EXCEEDED') } totalUncompressedBytes += uncompressedSize if (totalUncompressedBytes > maxTotalBytes) { throw new Error('ZIP_TOTAL_SIZE_LIMIT_EXCEEDED') } entries.push({ path: normalizedPath, compressedSize, uncompressedSize, readBuffer: (readMaxBytes = maxEntryBytes) => readStreamBuffer( sourceEntry.stream(), Math.min(readMaxBytes, maxEntryBytes) ), createReadStream: () => sourceEntry.stream() }) } return entries }