""" 파일 업로드 검증 유틸리티 보안 강화를 위한 파일 검증 로직 """ import os import magic from pathlib import Path from typing import List, Optional, Tuple from fastapi import UploadFile, HTTPException from ..config import get_settings from .logger import get_logger settings = get_settings() logger = get_logger(__name__) class FileValidator: """파일 업로드 검증 클래스""" def __init__(self): self.max_file_size = settings.security.max_file_size self.allowed_extensions = settings.security.allowed_file_extensions # MIME 타입 매핑 self.mime_type_mapping = { '.xlsx': [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/octet-stream' # 일부 브라우저에서 xlsx를 이렇게 인식 ], '.xls': [ 'application/vnd.ms-excel', 'application/octet-stream' ], '.csv': [ 'text/csv', 'text/plain', 'application/csv' ] } def validate_file_extension(self, filename: str) -> bool: """파일 확장자 검증""" file_ext = Path(filename).suffix.lower() is_valid = file_ext in self.allowed_extensions if not is_valid: logger.warning(f"허용되지 않은 파일 확장자: {file_ext}, 파일: {filename}") return is_valid def validate_file_size(self, file_size: int) -> bool: """파일 크기 검증""" is_valid = file_size <= self.max_file_size if not is_valid: logger.warning(f"파일 크기 초과: {file_size} bytes (최대: {self.max_file_size} bytes)") return is_valid def validate_filename(self, filename: str) -> bool: """파일명 검증 (보안 위험 문자 체크)""" # 위험한 문자들 dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'] for char in dangerous_chars: if char in filename: logger.warning(f"위험한 문자 포함된 파일명: {filename}") return False # 파일명 길이 체크 (255자 제한) if len(filename) > 255: logger.warning(f"파일명이 너무 긺: {len(filename)} 문자") return False return True def validate_mime_type(self, file_content: bytes, filename: str) -> bool: """MIME 타입 검증 (파일 내용 기반)""" try: # python-magic을 사용한 MIME 타입 검증 detected_mime = magic.from_buffer(file_content, mime=True) file_ext = Path(filename).suffix.lower() expected_mimes = self.mime_type_mapping.get(file_ext, []) if detected_mime in expected_mimes: return True logger.warning(f"MIME 타입 불일치 - 파일: {filename}, 감지된 타입: {detected_mime}, 예상 타입: {expected_mimes}") return False except Exception as e: logger.error(f"MIME 타입 검증 실패: {e}") # magic 라이브러리 오류 시 확장자 검증으로 대체 return self.validate_file_extension(filename) def sanitize_filename(self, filename: str) -> str: """파일명 정화 (안전한 파일명으로 변환)""" # 위험한 문자들을 언더스코어로 대체 dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'] sanitized = filename for char in dangerous_chars: sanitized = sanitized.replace(char, '_') # 연속된 언더스코어 제거 while '__' in sanitized: sanitized = sanitized.replace('__', '_') # 앞뒤 공백 및 점 제거 sanitized = sanitized.strip(' .') return sanitized async def validate_upload_file(self, file: UploadFile) -> Tuple[bool, Optional[str]]: """ 업로드 파일 종합 검증 Returns: Tuple[bool, Optional[str]]: (검증 성공 여부, 에러 메시지) """ try: # 1. 파일명 검증 if not self.validate_filename(file.filename): return False, f"유효하지 않은 파일명: {file.filename}" # 2. 확장자 검증 if not self.validate_file_extension(file.filename): return False, f"허용되지 않은 파일 형식입니다. 허용 형식: {', '.join(self.allowed_extensions)}" # 3. 파일 내용 읽기 file_content = await file.read() await file.seek(0) # 파일 포인터 리셋 # 4. 파일 크기 검증 if not self.validate_file_size(len(file_content)): return False, f"파일 크기가 너무 큽니다. 최대 크기: {self.max_file_size // (1024*1024)}MB" # 5. MIME 타입 검증 if not self.validate_mime_type(file_content, file.filename): return False, "파일 형식이 올바르지 않습니다." logger.info(f"파일 검증 성공: {file.filename} ({len(file_content)} bytes)") return True, None except Exception as e: logger.error(f"파일 검증 중 오류 발생: {e}", exc_info=True) return False, f"파일 검증 중 오류가 발생했습니다: {str(e)}" # 전역 파일 검증기 인스턴스 file_validator = FileValidator() async def validate_uploaded_file(file: UploadFile) -> None: """ 파일 검증 헬퍼 함수 (HTTPException 발생) Args: file: 업로드된 파일 Raises: HTTPException: 검증 실패 시 """ is_valid, error_message = await file_validator.validate_upload_file(file) if not is_valid: raise HTTPException(status_code=400, detail=error_message)