v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import { inflateRawSync } from 'node:zlib'
|
||||
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 엔트리 경로를 안전하게 정리한다.
|
||||
@@ -13,111 +18,116 @@ const normalizeZipEntryPath = (value) => String(value || '')
|
||||
.join('/')
|
||||
|
||||
/**
|
||||
* ZIP 종료 레코드 위치를 찾는다.
|
||||
* @param {Buffer} buffer - ZIP 파일 버퍼
|
||||
* @returns {number} 종료 레코드 위치
|
||||
* ZIP 내부 경로가 상위 폴더나 절대 경로를 가리키는지 확인한다.
|
||||
* @param {string} value - 원본 ZIP 경로
|
||||
* @returns {boolean} 안전하지 않은 경로 여부
|
||||
*/
|
||||
const findEndOfCentralDirectoryOffset = (buffer) => {
|
||||
const signature = 0x06054b50
|
||||
const minOffset = Math.max(0, buffer.length - 65557)
|
||||
const isUnsafeZipEntryPath = (value) => {
|
||||
const normalized = String(value || '').replace(/\\/g, '/')
|
||||
return normalized.startsWith('/')
|
||||
|| normalized.includes('\0')
|
||||
|| normalized.split('/').some((part) => part === '..')
|
||||
}
|
||||
|
||||
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
|
||||
if (buffer.readUInt32LE(offset) === signature) {
|
||||
return offset
|
||||
/**
|
||||
* 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 -1
|
||||
return Buffer.concat(chunks, totalBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 엔트리 데이터를 압축 해제한다.
|
||||
* @param {Buffer} buffer - 전체 ZIP 버퍼
|
||||
* @param {Object} entry - 중앙 디렉터리 엔트리
|
||||
* @returns {Buffer} 압축 해제된 데이터
|
||||
* 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 엔트리 목록
|
||||
*/
|
||||
const readZipEntryData = (buffer, entry) => {
|
||||
const localHeaderOffset = entry.localHeaderOffset
|
||||
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
|
||||
|
||||
if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) {
|
||||
throw new Error('INVALID_ZIP_LOCAL_HEADER')
|
||||
}
|
||||
|
||||
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26)
|
||||
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28)
|
||||
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength
|
||||
const compressedData = buffer.subarray(dataOffset, dataOffset + entry.compressedSize)
|
||||
|
||||
if (entry.compressionMethod === 0) {
|
||||
return Buffer.from(compressedData)
|
||||
}
|
||||
|
||||
if (entry.compressionMethod === 8) {
|
||||
return inflateRawSync(compressedData)
|
||||
}
|
||||
|
||||
throw new Error('UNSUPPORTED_ZIP_COMPRESSION')
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 파일 버퍼를 파일 엔트리 목록으로 읽는다.
|
||||
* @param {Buffer} buffer - ZIP 파일 버퍼
|
||||
* @returns {Array<{ path: string, data: Buffer }>} 파일 엔트리 목록
|
||||
*/
|
||||
export const readZipBufferEntries = (buffer) => {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 22) {
|
||||
try {
|
||||
directory = await unzipper.Open.file(zipFilePath)
|
||||
} catch {
|
||||
throw new Error('INVALID_ZIP_FILE')
|
||||
}
|
||||
|
||||
const endOffset = findEndOfCentralDirectoryOffset(buffer)
|
||||
const sourceEntries = directory.files.filter((entry) => entry.type !== 'Directory')
|
||||
|
||||
if (endOffset < 0) {
|
||||
throw new Error('INVALID_ZIP_FILE')
|
||||
}
|
||||
|
||||
const entryCount = buffer.readUInt16LE(endOffset + 10)
|
||||
const centralDirectorySize = buffer.readUInt32LE(endOffset + 12)
|
||||
const centralDirectoryOffset = buffer.readUInt32LE(endOffset + 16)
|
||||
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize
|
||||
|
||||
if (centralDirectoryOffset < 0 || centralDirectoryEnd > buffer.length) {
|
||||
throw new Error('INVALID_ZIP_FILE')
|
||||
if (sourceEntries.length > maxEntryCount) {
|
||||
throw new Error('ZIP_ENTRY_COUNT_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
const entries = []
|
||||
let offset = centralDirectoryOffset
|
||||
const usedPaths = new Set()
|
||||
let totalUncompressedBytes = 0
|
||||
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (buffer.readUInt32LE(offset) !== 0x02014b50) {
|
||||
throw new Error('INVALID_ZIP_CENTRAL_DIRECTORY')
|
||||
for (const sourceEntry of sourceEntries) {
|
||||
const rawPath = String(sourceEntry.path || '')
|
||||
if (isUnsafeZipEntryPath(rawPath)) {
|
||||
throw new Error('UNSAFE_ZIP_ENTRY_PATH')
|
||||
}
|
||||
|
||||
const compressionMethod = buffer.readUInt16LE(offset + 10)
|
||||
const compressedSize = buffer.readUInt32LE(offset + 20)
|
||||
const uncompressedSize = buffer.readUInt32LE(offset + 24)
|
||||
const nameLength = buffer.readUInt16LE(offset + 28)
|
||||
const extraLength = buffer.readUInt16LE(offset + 30)
|
||||
const commentLength = buffer.readUInt16LE(offset + 32)
|
||||
const localHeaderOffset = buffer.readUInt32LE(offset + 42)
|
||||
const rawPath = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
||||
const normalizedPath = normalizeZipEntryPath(rawPath)
|
||||
const isDirectory = rawPath.replace(/\\/g, '/').endsWith('/')
|
||||
|
||||
if (normalizedPath && !isDirectory && !normalizedPath.startsWith('__MACOSX/')) {
|
||||
entries.push({
|
||||
path: normalizedPath,
|
||||
compressionMethod,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset
|
||||
})
|
||||
if (!normalizedPath || normalizedPath.startsWith('__MACOSX/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
offset += 46 + nameLength + extraLength + commentLength
|
||||
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.map((entry) => ({
|
||||
path: entry.path,
|
||||
data: readZipEntryData(buffer, entry)
|
||||
}))
|
||||
return entries
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user