Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시 - 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시 - 외부링/필러/내부링/추가구성 모든 정보 포함 - 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
170 lines
6.0 KiB
Python
170 lines
6.0 KiB
Python
"""
|
|
파일 업로드 검증 유틸리티
|
|
보안 강화를 위한 파일 검증 로직
|
|
"""
|
|
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)
|