diff options
Diffstat (limited to 'lib/file-stroage.ts')
| -rw-r--r-- | lib/file-stroage.ts | 588 |
1 files changed, 532 insertions, 56 deletions
diff --git a/lib/file-stroage.ts b/lib/file-stroage.ts index ae84f506..beca05ee 100644 --- a/lib/file-stroage.ts +++ b/lib/file-stroage.ts @@ -1,8 +1,9 @@ -// lib/file-storage.ts - File과 ArrayBuffer를 위한 분리된 함수들 +// lib/file-storage.ts - 보안이 강화된 파일 저장 유틸리티 import { promises as fs } from "fs"; import path from "path"; import crypto from "crypto"; +import { createHash } from "crypto"; interface FileStorageConfig { baseDir: string; @@ -10,6 +11,309 @@ interface FileStorageConfig { isProduction: boolean; } +// 보안 설정 +const SECURITY_CONFIG = { + // 허용된 파일 확장자 + ALLOWED_EXTENSIONS: new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', + // SVG 제거 - XSS 위험으로 인해 + // 'svg', + 'dwg', 'dxf', 'zip', 'rar', '7z' + ]), + + // 금지된 파일 확장자 (실행 파일 등) + FORBIDDEN_EXTENSIONS: new Set([ + 'exe', 'bat', 'cmd', 'scr', 'vbs', 'js', 'jar', 'com', 'pif', + 'msi', 'reg', 'ps1', 'sh', 'php', 'asp', 'jsp', 'py', 'pl', + // XSS 방지를 위한 추가 확장자 + 'html', 'htm', 'xhtml', 'xml', 'xsl', 'xslt' + ]), + + // 허용된 MIME 타입 + ALLOWED_MIME_TYPES: new Set([ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp', + // SVG 제거 - XSS 위험으로 인해 + // 'image/svg+xml', + 'text/plain', 'text/csv', + 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed' + ]), + + // 최대 파일 크기 (100MB) + MAX_FILE_SIZE: 100 * 1024 * 1024, + + // 파일명 최대 길이 + MAX_FILENAME_LENGTH: 255, +}; + +// 보안 검증 클래스 +class FileSecurityValidator { + // 파일 확장자 검증 + static validateExtension(fileName: string): { valid: boolean; error?: string } { + const extension = path.extname(fileName).toLowerCase().substring(1); + + if (!extension) { + return { valid: false, error: "파일 확장자가 없습니다" }; + } + + if (SECURITY_CONFIG.FORBIDDEN_EXTENSIONS.has(extension)) { + return { valid: false, error: `금지된 파일 형식입니다: .${extension}` }; + } + + if (!SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension)) { + return { valid: false, error: `허용되지 않은 파일 형식입니다: .${extension}` }; + } + + return { valid: true }; + } + + // 파일명 안전성 검증 + static validateFileName(fileName: string): { valid: boolean; error?: string } { + // 길이 체크 + if (fileName.length > SECURITY_CONFIG.MAX_FILENAME_LENGTH) { + return { valid: false, error: "파일명이 너무 깁니다" }; + } + + // 위험한 문자 체크 (XSS 방지 강화) + const dangerousPatterns = [ + /[<>:"'|?*]/, // HTML 태그 및 특수문자 + /[\x00-\x1f]/, // 제어문자 + /^\./, // 숨김 파일 + /\.\./, // 상위 디렉토리 접근 + /\/|\\$/, // 경로 구분자 + /javascript:/i, // JavaScript 프로토콜 + /data:/i, // Data URI + /vbscript:/i, // VBScript 프로토콜 + /on\w+=/i, // 이벤트 핸들러 (onclick=, onload= 등) + /<script/i, // Script 태그 + /<iframe/i, // Iframe 태그 + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(fileName)) { + return { valid: false, error: "안전하지 않은 파일명입니다" }; + } + } + + // 예약된 Windows 파일명 체크 + const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']; + const nameWithoutExt = path.basename(fileName, path.extname(fileName)).toUpperCase(); + + if (reservedNames.includes(nameWithoutExt)) { + return { valid: false, error: "예약된 파일명입니다" }; + } + + return { valid: true }; + } + + // 파일 크기 검증 + static validateFileSize(size: number): { valid: boolean; error?: string } { + if (size <= 0) { + return { valid: false, error: "파일이 비어있습니다" }; + } + + if (size > SECURITY_CONFIG.MAX_FILE_SIZE) { + const maxSizeMB = Math.round(SECURITY_CONFIG.MAX_FILE_SIZE / (1024 * 1024)); + return { valid: false, error: `파일 크기가 너무 큽니다 (최대 ${maxSizeMB}MB)` }; + } + + return { valid: true }; + } + + // MIME 타입 검증 + static validateMimeType(mimeType: string, fileName: string): { valid: boolean; error?: string } { + if (!mimeType) { + return { valid: false, error: "MIME 타입을 확인할 수 없습니다" }; + } + + // 기본 MIME 타입 체크 + const baseMimeType = mimeType.split(';')[0].toLowerCase(); + + if (!SECURITY_CONFIG.ALLOWED_MIME_TYPES.has(baseMimeType)) { + return { valid: false, error: `허용되지 않은 파일 형식입니다: ${baseMimeType}` }; + } + + // 확장자와 MIME 타입 일치성 체크 + const extension = path.extname(fileName).toLowerCase().substring(1); + const expectedMimeTypes = this.getExpectedMimeTypes(extension); + + if (expectedMimeTypes.length > 0 && !expectedMimeTypes.includes(baseMimeType)) { + console.warn(`⚠️ MIME 타입 불일치: ${fileName} (확장자: ${extension}, MIME: ${baseMimeType})`); + // 경고만 하고 허용 (일부 브라우저에서 MIME 타입이 다를 수 있음) + } + + return { valid: true }; + } + + // 확장자별 예상되는 MIME 타입들 + private static getExpectedMimeTypes(extension: string): string[] { + const mimeMap: Record<string, string[]> = { + 'pdf': ['application/pdf'], + 'doc': ['application/msword'], + 'docx': ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'xls': ['application/vnd.ms-excel'], + 'xlsx': ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + 'jpg': ['image/jpeg'], + 'jpeg': ['image/jpeg'], + 'png': ['image/png'], + 'gif': ['image/gif'], + 'bmp': ['image/bmp'], + 'svg': ['image/svg+xml'], + 'webp': ['image/webp'], + 'txt': ['text/plain'], + 'csv': ['text/csv', 'application/csv'], + 'zip': ['application/zip'], + 'rar': ['application/x-rar-compressed'], + '7z': ['application/x-7z-compressed'], + }; + + return mimeMap[extension] || []; + } + + // 디렉터리 기본 안전성 검증 (경로 탐색 공격 방지) + static validateDirectory(directory: string): { valid: boolean; error?: string } { + // 경로 정규화 + const normalizedDir = path.normalize(directory).replace(/^\/+/, ''); + + // 경로 탐색 공격 방지 + if (normalizedDir.includes('..') || normalizedDir.includes('//')) { + return { valid: false, error: "안전하지 않은 디렉터리 경로입니다" }; + } + + // 절대 경로 방지 + if (path.isAbsolute(directory)) { + return { valid: false, error: "절대 경로는 사용할 수 없습니다" }; + } + + return { valid: true }; + } + + // 파일 내용 기본 검증 (매직 넘버 체크 + XSS 패턴 검사) + static async validateFileContent(buffer: Buffer, fileName: string): Promise<{ valid: boolean; error?: string }> { + try { + const extension = path.extname(fileName).toLowerCase().substring(1); + + // 파일 시그니처 (매직 넘버) 검증 + const fileSignatures: Record<string, Buffer[]> = { + 'pdf': [Buffer.from([0x25, 0x50, 0x44, 0x46])], // %PDF + 'jpg': [Buffer.from([0xFF, 0xD8, 0xFF])], + 'jpeg': [Buffer.from([0xFF, 0xD8, 0xFF])], + 'png': [Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])], + 'gif': [Buffer.from([0x47, 0x49, 0x46, 0x38])], // GIF8 + 'zip': [Buffer.from([0x50, 0x4B, 0x03, 0x04]), Buffer.from([0x50, 0x4B, 0x05, 0x06])], + }; + + const expectedSignatures = fileSignatures[extension]; + if (expectedSignatures) { + const hasValidSignature = expectedSignatures.some(signature => + buffer.subarray(0, signature.length).equals(signature) + ); + + if (!hasValidSignature) { + return { valid: false, error: `파일 내용이 확장자와 일치하지 않습니다: ${extension}` }; + } + } + + // 실행 파일 패턴 검색 + const executablePatterns = [ + Buffer.from([0x4D, 0x5A]), // MZ (Windows executable) + Buffer.from([0x7F, 0x45, 0x4C, 0x46]), // ELF (Linux executable) + ]; + + for (const pattern of executablePatterns) { + if (buffer.subarray(0, pattern.length).equals(pattern)) { + return { valid: false, error: "실행 파일은 업로드할 수 없습니다" }; + } + } + + // XSS 패턴 검사 (텍스트 기반 파일용) + const textBasedExtensions = ['txt', 'csv', 'xml', 'svg', 'html', 'htm']; + if (textBasedExtensions.includes(extension)) { + const content = buffer.toString('utf8', 0, Math.min(buffer.length, 8192)); // 첫 8KB만 검사 + + const xssPatterns = [ + /<script[\s\S]*?>/i, // <script> 태그 + /<iframe[\s\S]*?>/i, // <iframe> 태그 + /on\w+\s*=\s*["'][^"']*["']/i, // 이벤트 핸들러 (onclick="...") + /javascript\s*:/i, // javascript: 프로토콜 + /vbscript\s*:/i, // vbscript: 프로토콜 + /data\s*:\s*text\/html/i, // data:text/html + /<meta[\s\S]*?http-equiv[\s\S]*?>/i, // meta refresh + /<object[\s\S]*?>/i, // object 태그 + /<embed[\s\S]*?>/i, // embed 태그 + /<form[\s\S]*?action[\s\S]*?>/i, // form 태그 + ]; + + for (const pattern of xssPatterns) { + if (pattern.test(content)) { + return { valid: false, error: "파일에 잠재적으로 위험한 스크립트가 포함되어 있습니다" }; + } + } + } + + return { valid: true }; + } catch (error) { + console.error("파일 내용 검증 오류:", error); + return { valid: false, error: "파일 내용을 검증할 수 없습니다" }; + } + } +} + +// 파일 업로드 로깅 클래스 +class FileUploadLogger { + static logUploadAttempt(fileName: string, size: number, directory: string, userId?: string) { + console.log(`📤 파일 업로드 시도:`, { + fileName, + size: this.formatFileSize(size), + directory, + userId, + timestamp: new Date().toISOString(), + }); + } + + static logUploadSuccess(fileName: string, hashedFileName: string, size: number, directory: string, userId?: string) { + console.log(`✅ 파일 업로드 성공:`, { + originalName: fileName, + savedName: hashedFileName, + size: this.formatFileSize(size), + directory, + userId, + timestamp: new Date().toISOString(), + }); + } + + static logUploadError(fileName: string, error: string, userId?: string) { + console.error(`❌ 파일 업로드 실패:`, { + fileName, + error, + userId, + timestamp: new Date().toISOString(), + }); + } + + static logSecurityViolation(fileName: string, violation: string, userId?: string) { + console.warn(`🚨 파일 업로드 보안 위반:`, { + fileName, + violation, + userId, + timestamp: new Date().toISOString(), + }); + } + + private static formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + // 파일명 해시 생성 유틸리티 export function generateHashedFileName(originalName: string): string { const fileExtension = path.extname(originalName); @@ -24,19 +328,43 @@ export function generateHashedFileName(originalName: string): string { return `${timestamp}-${randomHash}${fileExtension}`; } -// ✅ File 저장용 인터페이스 +// HTML safe한 파일명 생성 (XSS 방지) +export function sanitizeFileNameForDisplay(fileName: string): string { + return fileName + .replace(/&/g, '&') // & → & + .replace(/</g, '<') // < → < + .replace(/>/g, '>') // > → > + .replace(/"/g, '"') // " → " + .replace(/'/g, ''') // ' → ' + .replace(/\//g, '/') // / → / + .replace(/\\/g, '\'); // \ → \ +} + +// 파일명에서 위험한 문자 제거 (저장용) +export function sanitizeFileNameForStorage(fileName: string): string { + return fileName + .replace(/[<>:"'|?*\\\/]/g, '_') // 위험한 문자를 언더스코어로 + .replace(/[\x00-\x1f]/g, '') // 제어문자 제거 + .replace(/\s+/g, '_') // 공백을 언더스코어로 + .replace(/_{2,}/g, '_') // 연속된 언더스코어를 하나로 + .replace(/^_+|_+$/g, '') // 앞뒤 언더스코어 제거 + .substring(0, 200); // 길이 제한 +} + +// 보안 강화된 파일 저장 옵션들 interface SaveFileOptions { file: File; directory: string; originalName?: string; + userId?: string; } -// ✅ Buffer/ArrayBuffer 저장용 인터페이스 interface SaveBufferOptions { buffer: Buffer | ArrayBuffer; fileName: string; directory: string; originalName?: string; + userId?: string; } interface SaveFileResult { @@ -44,10 +372,19 @@ interface SaveFileResult { filePath?: string; publicPath?: string; fileName?: string; + originalName?: string; + fileSize?: number; error?: string; + securityChecks?: { + extensionCheck: boolean; + fileNameCheck: boolean; + sizeCheck: boolean; + mimeTypeCheck: boolean; + contentCheck: boolean; + }; } -const nasPath = process.env.NAS_PATH || "/evcp_nas" +const nasPath = process.env.NAS_PATH || "/evcp_nas"; // 환경별 설정 function getStorageConfig(): FileStorageConfig { @@ -68,22 +405,87 @@ function getStorageConfig(): FileStorageConfig { } } -// ✅ 1. File 객체 저장 함수 (기존 방식) +// 보안이 강화된 File 객체 저장 함수 export async function saveFile({ file, directory, - originalName + originalName, + userId, }: SaveFileOptions): Promise<SaveFileResult> { + const finalFileName = originalName || file.name; + + // 초기 로깅 + FileUploadLogger.logUploadAttempt(finalFileName, file.size, directory, userId); + try { const config = getStorageConfig(); - const finalFileName = originalName || file.name; - const hashedFileName = generateHashedFileName(finalFileName); + const securityChecks = { + extensionCheck: false, + fileNameCheck: false, + sizeCheck: false, + mimeTypeCheck: false, + contentCheck: false, + }; + + // 1. 디렉터리 기본 안전성 검증 + const dirValidation = FileSecurityValidator.validateDirectory(directory); + if (!dirValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `Directory: ${dirValidation.error}`, userId); + return { success: false, error: dirValidation.error, securityChecks }; + } + + // 2. 파일 확장자 검증 + const extValidation = FileSecurityValidator.validateExtension(finalFileName); + if (!extValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `Extension: ${extValidation.error}`, userId); + return { success: false, error: extValidation.error, securityChecks }; + } + securityChecks.extensionCheck = true; + + // 3. 파일명 안전성 검증 + const nameValidation = FileSecurityValidator.validateFileName(finalFileName); + if (!nameValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `FileName: ${nameValidation.error}`, userId); + return { success: false, error: nameValidation.error, securityChecks }; + } + securityChecks.fileNameCheck = true; + + // 4. 파일 크기 검증 + const sizeValidation = FileSecurityValidator.validateFileSize(file.size); + if (!sizeValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `Size: ${sizeValidation.error}`, userId); + return { success: false, error: sizeValidation.error, securityChecks }; + } + securityChecks.sizeCheck = true; + + // 5. MIME 타입 검증 + const mimeValidation = FileSecurityValidator.validateMimeType(file.type, finalFileName); + if (!mimeValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `MIME: ${mimeValidation.error}`, userId); + return { success: false, error: mimeValidation.error, securityChecks }; + } + securityChecks.mimeTypeCheck = true; + + // 6. 파일 내용 추출 및 검증 + const arrayBuffer = await file.arrayBuffer(); + const dataBuffer = Buffer.from(arrayBuffer); + + const contentValidation = await FileSecurityValidator.validateFileContent(dataBuffer, finalFileName); + if (!contentValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `Content: ${contentValidation.error}`, userId); + return { success: false, error: contentValidation.error, securityChecks }; + } + securityChecks.contentCheck = true; + + // 7. 안전한 파일명 전처리 + const safeOriginalName = sanitizeFileNameForStorage(finalFileName); + const hashedFileName = generateHashedFileName(safeOriginalName); - // 저장 경로 설정 + // 8. 저장 경로 설정 const saveDir = path.join(config.baseDir, directory); const filePath = path.join(saveDir, hashedFileName); - // 웹 접근 경로 + // 9. 웹 접근 경로 let publicPath: string; if (config.isProduction) { publicPath = `${config.publicUrl}/${directory}/${hashedFileName}`; @@ -91,54 +493,110 @@ export async function saveFile({ publicPath = `/${directory}/${hashedFileName}`; } - console.log(`📄 File 객체 저장: ${finalFileName}`); + console.log(`📄 보안 검증 완료 - File 객체 저장: ${finalFileName}`); console.log(`📁 저장 위치: ${filePath}`); console.log(`🌐 웹 접근 경로: ${publicPath}`); - // 디렉토리 생성 + // 10. 디렉토리 생성 await fs.mkdir(saveDir, { recursive: true }); - // File 객체에서 데이터 추출 - const arrayBuffer = await file.arrayBuffer(); - const dataBuffer = Buffer.from(arrayBuffer); - - // 파일 저장 + // 11. 파일 저장 await fs.writeFile(filePath, dataBuffer); - console.log(`✅ File 저장 완료: ${hashedFileName} (${dataBuffer.length} bytes)`); + // 12. 성공 로깅 + FileUploadLogger.logUploadSuccess(finalFileName, hashedFileName, file.size, directory, userId); return { success: true, filePath, publicPath, fileName: hashedFileName, + originalName: finalFileName, + fileSize: file.size, + securityChecks, }; } catch (error) { - console.error("File 저장 실패:", error); + const errorMessage = error instanceof Error ? error.message : "File 저장 중 오류가 발생했습니다."; + FileUploadLogger.logUploadError(finalFileName, errorMessage, userId); return { success: false, - error: error instanceof Error ? error.message : "File 저장 중 오류가 발생했습니다.", + error: errorMessage, }; } } -// ✅ 2. Buffer/ArrayBuffer 저장 함수 (DRM 복호화용) +// 보안이 강화된 Buffer 저장 함수 export async function saveBuffer({ buffer, fileName, directory, - originalName + originalName, + userId, }: SaveBufferOptions): Promise<SaveFileResult> { + const finalFileName = originalName || fileName; + const dataBuffer = buffer instanceof ArrayBuffer ? Buffer.from(buffer) : buffer; + + // 초기 로깅 + FileUploadLogger.logUploadAttempt(finalFileName, dataBuffer.length, directory, userId); + try { const config = getStorageConfig(); - const finalFileName = originalName || fileName; - const hashedFileName = generateHashedFileName(finalFileName); + const securityChecks = { + extensionCheck: false, + fileNameCheck: false, + sizeCheck: false, + mimeTypeCheck: true, // Buffer는 MIME 타입 검증 스킵 + contentCheck: false, + }; + + // 1. 디렉터리 기본 안전성 검증 + const dirValidation = FileSecurityValidator.validateDirectory(directory); + if (!dirValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `Directory: ${dirValidation.error}`, userId); + return { success: false, error: dirValidation.error, securityChecks }; + } + + // 2. 파일 확장자 검증 + const extValidation = FileSecurityValidator.validateExtension(finalFileName); + if (!extValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `Extension: ${extValidation.error}`, userId); + return { success: false, error: extValidation.error, securityChecks }; + } + securityChecks.extensionCheck = true; + + // 3. 파일명 안전성 검증 + const nameValidation = FileSecurityValidator.validateFileName(finalFileName); + if (!nameValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `FileName: ${nameValidation.error}`, userId); + return { success: false, error: nameValidation.error, securityChecks }; + } + securityChecks.fileNameCheck = true; + + // 4. 파일 크기 검증 + const sizeValidation = FileSecurityValidator.validateFileSize(dataBuffer.length); + if (!sizeValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `Size: ${sizeValidation.error}`, userId); + return { success: false, error: sizeValidation.error, securityChecks }; + } + securityChecks.sizeCheck = true; + + // 5. 파일 내용 검증 + const contentValidation = await FileSecurityValidator.validateFileContent(dataBuffer, finalFileName); + if (!contentValidation.valid) { + FileUploadLogger.logSecurityViolation(finalFileName, `Content: ${contentValidation.error}`, userId); + return { success: false, error: contentValidation.error, securityChecks }; + } + securityChecks.contentCheck = true; - // 저장 경로 설정 + // 6. 안전한 파일명 전처리 + const safeOriginalName = sanitizeFileNameForStorage(finalFileName); + const hashedFileName = generateHashedFileName(safeOriginalName); + + // 7. 저장 경로 설정 const saveDir = path.join(config.baseDir, directory); const filePath = path.join(saveDir, hashedFileName); - // 웹 접근 경로 + // 8. 웹 접근 경로 let publicPath: string; if (config.isProduction) { publicPath = `${config.publicUrl}/${directory}/${hashedFileName}`; @@ -146,37 +604,39 @@ export async function saveBuffer({ publicPath = `/${directory}/${hashedFileName}`; } - console.log(`🔓 Buffer/ArrayBuffer 저장: ${finalFileName}`); + console.log(`🔓 보안 검증 완료 - Buffer 저장: ${finalFileName}`); console.log(`📁 저장 위치: ${filePath}`); console.log(`🌐 웹 접근 경로: ${publicPath}`); - // 디렉토리 생성 + // 9. 디렉토리 생성 await fs.mkdir(saveDir, { recursive: true }); - // Buffer 준비 - const dataBuffer = buffer instanceof ArrayBuffer ? Buffer.from(buffer) : buffer; - - // 파일 저장 + // 10. 파일 저장 await fs.writeFile(filePath, dataBuffer); - console.log(`✅ Buffer 저장 완료: ${hashedFileName} (${dataBuffer.length} bytes)`); + // 11. 성공 로깅 + FileUploadLogger.logUploadSuccess(finalFileName, hashedFileName, dataBuffer.length, directory, userId); return { success: true, filePath, publicPath, fileName: hashedFileName, + originalName: finalFileName, + fileSize: dataBuffer.length, + securityChecks, }; } catch (error) { - console.error("Buffer 저장 실패:", error); + const errorMessage = error instanceof Error ? error.message : "Buffer 저장 중 오류가 발생했습니다."; + FileUploadLogger.logUploadError(finalFileName, errorMessage, userId); return { success: false, - error: error instanceof Error ? error.message : "Buffer 저장 중 오류가 발생했습니다.", + error: errorMessage, }; } } -// ✅ 업데이트 함수들 +// 업데이트 함수들 (보안 검증 포함) export async function updateFile( options: SaveFileOptions, oldFilePath?: string @@ -190,11 +650,9 @@ export async function updateFile( return result; } catch (error) { - console.error("File 업데이트 실패:", error); - return { - success: false, - error: error instanceof Error ? error.message : "File 업데이트 중 오류가 발생했습니다.", - }; + const errorMessage = error instanceof Error ? error.message : "File 업데이트 중 오류가 발생했습니다."; + FileUploadLogger.logUploadError(options.originalName || options.file.name, errorMessage, options.userId); + return { success: false, error: errorMessage }; } } @@ -211,15 +669,13 @@ export async function updateBuffer( return result; } catch (error) { - console.error("Buffer 업데이트 실패:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Buffer 업데이트 중 오류가 발생했습니다.", - }; + const errorMessage = error instanceof Error ? error.message : "Buffer 업데이트 중 오류가 발생했습니다."; + FileUploadLogger.logUploadError(options.originalName || options.fileName, errorMessage, options.userId); + return { success: false, error: errorMessage }; } } -// 파일 삭제 함수 +// 안전한 파일 삭제 함수 export async function deleteFile(publicPath: string): Promise<boolean> { try { const config = getStorageConfig(); @@ -232,6 +688,13 @@ export async function deleteFile(publicPath: string): Promise<boolean> { absolutePath = path.join(process.cwd(), 'public', publicPath); } + // 경로 안전성 검증 + const normalizedPath = path.normalize(absolutePath); + if (normalizedPath.includes('..')) { + console.error("🚨 위험한 파일 삭제 시도:", absolutePath); + return false; + } + console.log(`🗑️ 파일 삭제: ${absolutePath}`); await fs.access(absolutePath); @@ -243,17 +706,18 @@ export async function deleteFile(publicPath: string): Promise<boolean> { } } -// ✅ 편의 함수들 (하위 호환성) +// 편의 함수들 (하위 호환성) export const save = { file: saveFile, buffer: saveBuffer, }; -// ✅ DRM 워크플로우 통합 함수 +// DRM 워크플로우 통합 함수 (보안 강화) export async function saveDRMFile( originalFile: File, decryptFunction: (file: File) => Promise<ArrayBuffer>, - directory: string + directory: string, + userId?: string ): Promise<SaveFileResult> { try { console.log(`🔐 DRM 파일 처리 시작: ${originalFile.name}`); @@ -261,11 +725,12 @@ export async function saveDRMFile( // 1. DRM 복호화 const decryptedData = await decryptFunction(originalFile); - // 2. 복호화된 데이터 저장 + // 2. 보안 검증과 함께 복호화된 데이터 저장 const result = await saveBuffer({ buffer: decryptedData, fileName: originalFile.name, - directory + directory, + userId, }); if (result.success) { @@ -274,10 +739,21 @@ export async function saveDRMFile( return result; } catch (error) { + const errorMessage = error instanceof Error ? error.message : "DRM 파일 처리 중 오류가 발생했습니다."; console.error(`❌ DRM 파일 처리 실패: ${originalFile.name}`, error); - return { - success: false, - error: error instanceof Error ? error.message : "DRM 파일 처리 중 오류가 발생했습니다.", - }; + FileUploadLogger.logUploadError(originalFile.name, errorMessage, userId); + return { success: false, error: errorMessage }; } +} + +// 보안 설정 조회 함수 +export function getSecurityConfig() { + return { + allowedExtensions: Array.from(SECURITY_CONFIG.ALLOWED_EXTENSIONS), + forbiddenExtensions: Array.from(SECURITY_CONFIG.FORBIDDEN_EXTENSIONS), + allowedMimeTypes: Array.from(SECURITY_CONFIG.ALLOWED_MIME_TYPES), + maxFileSize: SECURITY_CONFIG.MAX_FILE_SIZE, + maxFileSizeFormatted: FileUploadLogger['formatFileSize'](SECURITY_CONFIG.MAX_FILE_SIZE), + maxFilenameLength: SECURITY_CONFIG.MAX_FILENAME_LENGTH, + }; }
\ No newline at end of file |
