세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
134 lines
4.5 KiB
JavaScript
134 lines
4.5 KiB
JavaScript
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<Buffer>} 엔트리 데이터
|
|
*/
|
|
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<Array<{ path: string, compressedSize: number, uncompressedSize: number, readBuffer: (maxBytes?: number) => Promise<Buffer>, 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
|
|
}
|