v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.

세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 11:05:40 +09:00
parent 9bc0f0a90e
commit de767095ba
116 changed files with 8128 additions and 2933 deletions

View File

@@ -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
}