/** * File Upload Security - 파일 업로드 보안 유틸리티 * * - Magic number (파일 시그니처) 검증 * - 파일명 sanitize * - 확장자 화이트리스트 검증 * - 파일 크기 제한 * * @author TK-FB-Project * @since 2026-02-04 */ const path = require('path'); const crypto = require('crypto'); const fs = require('fs').promises; /** * 파일 시그니처 (Magic Numbers) * 파일의 실제 타입을 확인하기 위한 바이너리 시그니처 */ const FILE_SIGNATURES = { // 이미지 'ffd8ff': { mime: 'image/jpeg', ext: ['.jpg', '.jpeg'] }, '89504e47': { mime: 'image/png', ext: ['.png'] }, '47494638': { mime: 'image/gif', ext: ['.gif'] }, '52494646': { mime: 'image/webp', ext: ['.webp'] }, // RIFF (WebP 시작) // 문서 '25504446': { mime: 'application/pdf', ext: ['.pdf'] }, '504b0304': { mime: 'application/zip', ext: ['.zip', '.xlsx', '.docx', '.pptx'] }, // 주의: BMP, TIFF 등 추가 가능 }; /** * 허용된 이미지 확장자 */ const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; /** * 허용된 문서 확장자 */ const ALLOWED_DOCUMENT_EXTENSIONS = ['.pdf', '.xlsx', '.docx', '.pptx', '.zip']; /** * 위험한 확장자 (절대 허용 안 함) */ const DANGEROUS_EXTENSIONS = [ '.exe', '.bat', '.cmd', '.sh', '.ps1', '.vbs', '.js', '.jar', '.php', '.asp', '.aspx', '.jsp', '.cgi', '.pl', '.py', '.rb', '.htaccess', '.htpasswd', '.ini', '.config', '.env' ]; /** * 파일 시그니처(Magic Number) 검증 * * @param {Buffer} buffer - 파일 버퍼 (최소 8바이트) * @returns {Object|null} 매칭된 파일 정보 또는 null */ const checkMagicNumber = (buffer) => { if (!buffer || buffer.length < 4) { return null; } // 처음 8바이트를 hex로 변환 const hex = buffer.slice(0, 8).toString('hex').toLowerCase(); // 시그니처 매칭 for (const [signature, info] of Object.entries(FILE_SIGNATURES)) { if (hex.startsWith(signature)) { return info; } } return null; }; /** * 파일 버퍼에서 실제 MIME 타입 검증 * * @param {Buffer} buffer - 파일 버퍼 * @param {string} declaredMime - 선언된 MIME 타입 * @returns {Object} { valid: boolean, actualType: string|null, message: string } */ const validateFileType = (buffer, declaredMime) => { const detected = checkMagicNumber(buffer); if (!detected) { return { valid: false, actualType: null, message: '알 수 없는 파일 형식입니다.' }; } // MIME 타입이 일치하는지 확인 if (detected.mime !== declaredMime) { return { valid: false, actualType: detected.mime, message: `파일 형식이 일치하지 않습니다. (선언: ${declaredMime}, 실제: ${detected.mime})` }; } return { valid: true, actualType: detected.mime, message: 'OK' }; }; /** * 파일명 sanitize * 경로 조작 및 특수문자 제거 * * @param {string} filename - 원본 파일명 * @returns {string} 안전한 파일명 */ const sanitizeFilename = (filename) => { if (!filename || typeof filename !== 'string') { return 'unnamed'; } // 경로 구분자 제거 (path traversal 방지) let safe = path.basename(filename); // 특수문자 제거 (영문, 숫자, -, _, . 만 허용) safe = safe.replace(/[^a-zA-Z0-9가-힣._-]/g, '_'); // 연속된 점 제거 (이중 확장자 방지) safe = safe.replace(/\.{2,}/g, '.'); // 앞뒤 점/공백 제거 safe = safe.replace(/^[\s.]+|[\s.]+$/g, ''); // 빈 파일명 처리 if (!safe || safe === '') { safe = 'unnamed'; } // 최대 길이 제한 (255자) if (safe.length > 255) { const ext = path.extname(safe); const name = path.basename(safe, ext); safe = name.slice(0, 255 - ext.length) + ext; } return safe; }; /** * 확장자 검증 * * @param {string} filename - 파일명 * @param {string[]} allowedExtensions - 허용된 확장자 배열 * @returns {Object} { valid: boolean, extension: string, message: string } */ const validateExtension = (filename, allowedExtensions = ALLOWED_IMAGE_EXTENSIONS) => { const ext = path.extname(filename).toLowerCase(); // 위험한 확장자 체크 if (DANGEROUS_EXTENSIONS.includes(ext)) { return { valid: false, extension: ext, message: `보안상 허용되지 않는 파일 형식입니다: ${ext}` }; } // 허용된 확장자 체크 if (!allowedExtensions.includes(ext)) { return { valid: false, extension: ext, message: `허용된 파일 형식: ${allowedExtensions.join(', ')}` }; } return { valid: true, extension: ext, message: 'OK' }; }; /** * 안전한 랜덤 파일명 생성 * * @param {string} originalFilename - 원본 파일명 (확장자 추출용) * @returns {string} 랜덤 파일명 */ const generateSafeFilename = (originalFilename) => { const ext = path.extname(originalFilename).toLowerCase(); const randomName = crypto.randomBytes(16).toString('hex'); const timestamp = Date.now(); return `${timestamp}_${randomName}${ext}`; }; /** * 안전한 업로드 경로 생성 * 경로 조작(path traversal) 방지 * * @param {string} baseDir - 기본 업로드 디렉토리 * @param {string} filename - 파일명 * @returns {string} 안전한 전체 경로 */ const getSafeUploadPath = (baseDir, filename) => { const safeName = sanitizeFilename(filename); const fullPath = path.join(baseDir, safeName); // 결과 경로가 baseDir 안에 있는지 확인 const resolvedBase = path.resolve(baseDir); const resolvedFull = path.resolve(fullPath); if (!resolvedFull.startsWith(resolvedBase)) { throw new Error('경로 조작이 감지되었습니다.'); } return resolvedFull; }; /** * Multer 파일 필터 생성 * * @param {Object} options - 옵션 * @param {string[]} options.allowedExtensions - 허용된 확장자 * @param {string[]} options.allowedMimes - 허용된 MIME 타입 * @param {boolean} options.checkMagicNumber - Magic number 검증 여부 * @returns {Function} Multer fileFilter 함수 */ const createFileFilter = (options = {}) => { const { allowedExtensions = ALLOWED_IMAGE_EXTENSIONS, allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], checkMagicNumber = false // Multer에서는 버퍼 접근이 제한적이므로 기본 false } = options; return (req, file, cb) => { // 확장자 검증 const extResult = validateExtension(file.originalname, allowedExtensions); if (!extResult.valid) { return cb(new Error(extResult.message), false); } // MIME 타입 검증 if (!allowedMimes.includes(file.mimetype)) { return cb(new Error(`허용된 MIME 타입: ${allowedMimes.join(', ')}`), false); } cb(null, true); }; }; /** * 업로드된 파일 검증 (후처리용) * Multer 업로드 후 파일 내용을 검증 * * @param {string} filePath - 업로드된 파일 경로 * @param {string} declaredMime - 선언된 MIME 타입 * @returns {Promise} 검증 결과 */ const validateUploadedFile = async (filePath, declaredMime) => { try { // 파일 시작 부분 읽기 const fd = await fs.open(filePath, 'r'); const buffer = Buffer.alloc(8); await fd.read(buffer, 0, 8, 0); await fd.close(); // Magic number 검증 const typeResult = validateFileType(buffer, declaredMime); if (!typeResult.valid) { // 위험한 파일이면 삭제 await fs.unlink(filePath); return { valid: false, deleted: true, message: typeResult.message }; } return { valid: true, deleted: false, message: 'OK', actualType: typeResult.actualType }; } catch (error) { return { valid: false, deleted: false, message: `파일 검증 중 오류: ${error.message}` }; } }; module.exports = { // 상수 ALLOWED_IMAGE_EXTENSIONS, ALLOWED_DOCUMENT_EXTENSIONS, DANGEROUS_EXTENSIONS, FILE_SIGNATURES, // 함수 checkMagicNumber, validateFileType, sanitizeFilename, validateExtension, generateSafeFilename, getSafeUploadPath, createFileFilter, validateUploadedFile };