⚙ Framework

[Vue+Spring] 파일 검증 로직 구현 (+ MIME 타입, 시그니처란?)

mxnxeonx 2025. 2. 25. 17:22
728x90
728x90

파일 업로드 구현 시 보안상의 이유로 파일 검증 로직이 포함되어야 한다. 검증은 파일명, 파일 크기, 확장자, 위변조에 대해 수행되어야 하며 위변조의 경우 MIME 타입과 시그니처를 확인하면 된다.

 

파일 위변조 검증 방식

1) MIME 타입 검증

MIME 타입(Multipurpose Internet Mail Extensions)은 파일의 형식을 나타내는 표준 방식으로, Content-Type 헤더에서 확인할 수 있고 브라우저나 서버에서 파일이 어떻게 처리될지를 결정하는 데 사용된다.

  1. 파일 확장자 기반 검증 (위험)
    • example.jpg 같은 확장자를 보고 image/jpeg인지 확인하는 방식 → 쉽게 변경 가능하여 보안성이 낮음
  2. OS 또는 언어별 내장 MIME 타입 검출 기능 사용 → 신뢰할 수 있으나 MIME 타입 정보가 정확하지 않을 수 있음
    • Java: Files.probeContentType(Path path)
    • Python: mimetypes.guess_type(filename)
    • Linux: file --mime-type filename

Content-Type은 클라이언트가 조작할 수 있기 때문에 서버에서 다시 확인하는 작업이 반드시 필요하다.

 

2) 파일 시그니처 검증

파일 시그니처(File Signature) 또는 매직 바이트(Magic Bytes)는 파일의 실제 포맷을 결정하는 고유한 식별자로, 파일의 처음 몇 바이트를 검사하여 파일 유형을 판별할 수 있다.

  1. 파일 헤더(파일 앞의 몇 바이트) 읽기 확장자가 .jpg여도 실제로는 pdf일 수 있어 체크 필요
    • JPEG: FF D8 FF
    • PNG: 89 50 4E 47 0D 0A 1A 0A
    • PDF: %PDF-
    • ZIP: 50 4B 03 04
  2. 파일 내용과 확장자 비교
    • .jpg 파일을 업로드했는데 실제 시그니처가 89 50 4E 47이면 PNG 파일이므로 위조 가능성 있음으로 판별

 


 

파일 검증 구현

파일 선택 시 프론트엔드에서 1차로 검증 후 파일을 전송하고, 백엔드에서 2차로 검증 과정을 거쳐 업로드한다.

 

1) 프론트엔드 (Vue)

  1. 파일명 검증 : 파일명이 255자를 초과하는지 확인
  2. 파일 크기 검증 : 파일 크기가 지정된 최대 크기 (props.maxSize)를 초과하는지 확인
  3. 확장자 검증 : .exe, .js 등 위험한 확장자 차단
  4. 확장자 검증 : 허용된 확장자인지 확인 (props.accept 기준)
  5. 위변조 검증 : 파일 시그니처 검사를 수행 (validateFileSignature())
const validateFile = async (file) => {
  // 1. 파일명 길이 검증 (255자 이하)
  if (file.name.length > 255) {
    throw new Error("파일명이 너무 깁니다. 255자 이하여야 합니다.");
  }

  // 2. 파일 크기 검증 (10MB 이하)
  if (file.size > props.maxSize * 1024 * 1024) {
    throw new Error(`파일 크기는 ${props.maxSize}MB 이하여야 합니다.`);
  }

  // 3. 파일 확장자 검증
  const fileName = file.name.toLowerCase();
  const fileExtension = "." + fileName.split(".").pop();

  // 3-1. 실행 가능한 파일 차단
  const dangerousExtensions = [
    ".php",
    ".exe",
    ".js",
    ".jsp",
    ".asp",
    ".aspx",
    ".html",
    ".htm",
    ".sh",
    ".bash",
    ".sql",
  ];
  if (dangerousExtensions.includes(fileExtension)) {
    throw new Error("실행 가능한 파일은 업로드할 수 없습니다.");
  }

  // 3-2. 허용하지 않는 파일 차단 - 레포트 상수값에서 정의
  const allowedTypes = props.accept.split(",");
  if (!allowedTypes.includes("*") && !allowedTypes.includes(fileExtension)) {
    throw new Error("지원하지 않는 파일 형식입니다.");
  }

  // 4. 파일 변조 확인 (MIME 타입, 시그니처 검증)
  try {
    const buffer = await file.slice(0, 4100).arrayBuffer();
    const header = new Uint8Array(buffer);
    const fileSignature = await validateFileSignature(
      header,
      file.type,
      fileExtension
    );

    if (!fileSignature.isValid) {
      throw new Error("파일 형식이 변조되었거나 유효하지 않습니다.");
    }
  } catch (error) {
    throw new Error("파일 형식 검증에 실패했습니다: " + error.message);
  }
};

 

MIME, 시그니처 검증 로직

const validateFileSignature = async (header, mimeType, extension) => {
  // 4-1. 파일 시그니처 검증
  const signatures = {
    ".jpg": [[0xff, 0xd8, 0xff]],
    ".jpeg": [[0xff, 0xd8, 0xff]],
    ".png": [[0x89, 0x50, 0x4e, 0x47]],
    ".pdf": [[0x25, 0x50, 0x44, 0x46]],
    ".docx": [[0x50, 0x4b]],
    ".xlsx": [[0x50, 0x4b]],
    ".doc": [[0xd0, 0xcf, 0x11, 0xe0]],
    ".xls": [[0xd0, 0xcf, 0x11, 0xe0]],
  };

  const expectedSignatures = signatures[extension];
  if (!expectedSignatures) {
    return { isValid: false, message: "지원하지 않는 파일 형식입니다." };
  }

  const isValidSignature = expectedSignatures.some((signature) => {
    return signature.every((byte, index) => header[index] === byte);
  });

  if (!isValidSignature) {
    return { isValid: false, message: "파일 시그니처가 일치하지 않습니다." };
  }

  // 4-2. MIME 타입 검증
  const expectedMimeTypes = {
    ".jpg": ["image/jpeg"],
    ".jpeg": ["image/jpeg"],
    ".png": ["image/png"],
    ".pdf": ["application/pdf"],
    ".docx": [
      "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    ],
    ".xlsx": [
      "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    ],
    ".doc": ["application/msword"],
    ".xls": ["application/vnd.ms-excel"],
  };

  const validMimeTypes = expectedMimeTypes[extension];
  if (!validMimeTypes?.includes(mimeType)) {
    return { isValid: false, message: "MIME 타입이 일치하지 않습니다." };
  }

  return { isValid: true };
};

 

2) 백엔드 (Spring)

  1. 파일 1차 검증 : 파일이 비어 있는지 확인 (file.isEmpty())
  2. 파일명 검증 : 파일명이 길거나 (255자 초과) 특수문자가 포함되어 있으면 예외 발생
  3. 확장자 검증 : 허용된 확장자인지 확인 (properties.getAllowedExtensions())
  4. 위변조 검증 (MIME) : MIME 타입 검증 (isValidMimeType() 호출)
  5. 위변조 검증 (시그니처) : 파일 시그니처 검증 (validateFileSignature() 호출)
  6. 파일 크기 검증 : 파일 크기 제한 (properties.getMaxFileSize() 초과 여부 확인)
/**
 * 파일 유효성 검사
 *
 * @param file 업로드된 파일 객체
 */
public void validateFile(MultipartFile file) throws IOException {
    if (file.isEmpty()) {
        throw new FileException(FileErrorCode.FILE_EMPTY);
    }

    String originalFilename = file.getOriginalFilename();

    // 파일명 길이 및 특수문자 검사
    if (originalFilename == null || originalFilename.length() > 255) {
        throw new FileException(FileErrorCode.INVALID_FILENAME_LENGTH);
    }
    if (originalFilename.contains("..") || originalFilename.contains("/") || originalFilename.contains("\\") || 
        originalFilename.contains(":") || originalFilename.contains("*") || originalFilename.contains("?") || 
        originalFilename.contains("\"") || originalFilename.contains("<") || originalFilename.contains(">") || 
        originalFilename.contains("|")) {
        throw new FileException(FileErrorCode.INVALID_FILENAME);
    }

    // 확장자 검사
    String extension = StringUtils.getFilenameExtension(originalFilename);
    if (extension == null || !properties.getAllowedExtensions().contains(extension.toLowerCase())) {
        throw new FileException(FileErrorCode.INVALID_FILE_EXTENSION);
    }

    // MIME 타입 검사
    String contentType = file.getContentType();
    if (contentType == null || !isValidMimeType(contentType, extension)) {
        throw new FileException(FileErrorCode.INVALID_MIME_TYPE);
    }

    // 파일 시그니처 검사
    if (!validateFileSignature(file, extension)) {
        throw new FileException(FileErrorCode.INVALID_FILE_SIGNATURE);
    }

    // 파일 크기 제한 검사
    if (file.getSize() > properties.getMaxFileSize()) {
        throw new FileException(FileErrorCode.FILE_SIZE_EXCEEDED);
    }
}

 

파일 시그니처 검증 로직

  • 파일의 처음 8바이트를 읽어 파일 확장자별로 시그니처(매직 바이트)를 확인
  • JPEG 파일은 FF D8 FF, PNG는 89 50 4E 47 0D 0A 1A 0A 등으로 검증
/**
 * 파일 시그니처 검증
 *
 * @param file      업로드된 파일 객체
 * @param extension 파일 확장자
 * @return 유효한 파일 시그니처 여부
 */
private boolean validateFileSignature(MultipartFile file, String extension) throws IOException {
    byte[] fileBytes = new byte[8];  // 대부분의 파일 시그니처는 처음 8바이트 이내
    try (InputStream is = file.getInputStream()) {
        int bytesRead = is.read(fileBytes);
        if (bytesRead < fileBytes.length) {
            fileBytes = Arrays.copyOf(fileBytes, bytesRead);
        }
    }

    if ("jpg".equals(extension) || "jpeg".equals(extension)) {
        return validateJpegSignature(fileBytes);
    } else if ("png".equals(extension)) {
        return validatePngSignature(fileBytes);
    } else if ("pdf".equals(extension)) {
        return validatePdfSignature(fileBytes);
    } else if ("doc".equals(extension)) {
        return validateDocSignature(fileBytes);
    } else if ("docx".equals(extension) || "xlsx".equals(extension)) {
        return validateOfficeOpenXmlSignature(fileBytes);
    } else if ("xls".equals(extension)) {
        return validateXlsSignature(fileBytes);
    } else {
        return false;
    }
}

 

파일 MIME 타입 검증 로직

  • 파일별로 지정된 MIME 타입과 일치하는지 확인
/**
 * MIME 타입 검증
 *
 * @param contentType 파일의 MIME 타입
 * @param extension   파일 확장자
 * @return 유효한 MIME 타입 여부
 */
private boolean isValidMimeType(String contentType, String extension) {
    Map<String, List<String>> validMimeTypes = Map.of(
            "jpg", List.of("image/jpeg"),
            "jpeg", List.of("image/jpeg"),
            "png", List.of("image/png"),
            "pdf", List.of("application/pdf"),
            "doc", List.of("application/msword"),
            "docx", List.of("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
            "xls", List.of("application/vnd.ms-excel"),
            "xlsx", List.of("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    );

    List<String> validTypes = validMimeTypes.get(extension.toLowerCase());
    return validTypes != null && validTypes.contains(contentType);
}

 

 

728x90
320x100