Files
sori.studio/server/utils/zip-reader.js
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

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
}