summaryrefslogtreecommitdiff
path: root/lib/file-stroage.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/file-stroage.ts')
-rw-r--r--lib/file-stroage.ts588
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, '&amp;') // & → &amp;
+ .replace(/</g, '&lt;') // < → &lt;
+ .replace(/>/g, '&gt;') // > → &gt;
+ .replace(/"/g, '&quot;') // " → &quot;
+ .replace(/'/g, '&#39;') // ' → &#39;
+ .replace(/\//g, '&#47;') // / → &#47;
+ .replace(/\\/g, '&#92;'); // \ → &#92;
+}
+
+// 파일명에서 위험한 문자 제거 (저장용)
+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