파일 업로드 기반의 기능을 종종 개발해야할 일이 있습니다.
이때 업로드 파일의 유효성(특히, 보안문제로)을 잘 진행해줘야합니다.
 
간단하게 범용적으로 사용할만한 커스텀 어노테이션을 만들어서 메모 목적으로 글을 작성해둡니다.
 
 
* 요약: 파일명의 확장자 검사뿐만 아니라, 확장자가 수정된 파일인지도 잘 검사해줘야함
 
1. apache tika 라이브러리를 추가해서 mime타입 검사에 사용

<!-- 업로드 파일의 확장자 검사 등의 목적 -->
<dependency>
    <groupId>org.apache.tika</groupId>
    <artifactId>tika-core</artifactId>
    <version>2.8.0</version>
</dependency>

 
2. 업로드 허용 파일들에 대해서 정의 enum 작성
 - 코드 관리를 조금 더 타이트하게 하려고 정의했는데 안쓰고 구현해도 무방

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 업로드 허용 파일에 대한 정의 Enum
 *  - 실제 사용되는 일부 파일들에 대한 정의만 추가되어 있으니, 신규 정의가 필요하면 내용 작성해서 사용
 *
 * @author 
 */
@Getter
@AllArgsConstructor
public enum UploadAllowFileDefine {

	// @formatter:off
	CSV("csv", new String[]{"text/csv", "text/plain"}), //텍스트 에디터에서 수정되는 text/plain 도 허용함
	;
	
	// @formatter:on

	private String fileExtensionLowerCase; //파일 확장자(소문자)
	private String[] allowMimeTypes; //허용하는 mime type array(파일 내용 변조 후 확장자 변경하는 공격을 막기 위해서 사용. 2023-07-31 기준 apache TIKA로 detect 중)

}

 
3. 커스텀 valid 인터페이스 작성

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 업로드 파일의 유효성 검사 체크용
 *
 * @author 
 */
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = FileUploadValidator.class)
public @interface FileUploadValid {

	String message();

	Class<?>[] groups() default {};

	Class<? extends Payload>[] payload() default {};

	/** 업로드 허용 파일들의 정의 array(여러 종류의 파일 타입을 허용할 수도 있기에 array) */
	UploadAllowFileDefine[] allowFileDefines();

}

 
4. 커스텀 validator 구현체 작성

....

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.tika.Tika;
import org.springframework.web.multipart.MultipartFile;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.io.IOException;

/**
 * 업로드 파일의 유효성 검사 체크용
 *  - 사용 방법 예) @FileUploadValid(allowFileDefines = {UploadAllowFileDefine.CSV}, message = "유효한 CSV파일만 업로드 가능합니다.")
 *
 * @author 
 */
@Slf4j
public class FileUploadValidator implements ConstraintValidator<FileUploadValid, MultipartFile> {
	private FileUploadValid annotation;

	@Override
	public void initialize(FileUploadValid constraintAnnotation) {
		this.annotation = constraintAnnotation;
	}

	@Override
	public boolean isValid(MultipartFile multipartFile, ConstraintValidatorContext context) {

		if (multipartFile.isEmpty()) {
			context.buildConstraintViolationWithTemplate("업로드 대상 파일이 없습니다. 정확히 선택 업로드해주세요.(There is no file to upload. Please upload correctly)").addConstraintViolation();
			return false;
		}

		final String fileName = multipartFile.getOriginalFilename();
		if (StringUtils.isBlank(fileName)) {
			context.buildConstraintViolationWithTemplate("업로드 요청한 파일명이 존재하지 않습니다.(not exist file name)").addConstraintViolation();
			return false;
		}

		try {
			int targetByte = multipartFile.getBytes().length;
			if (targetByte == 0) {
				context.buildConstraintViolationWithTemplate("파일의 용량이 0 byte입니다.(The size of the file is 0 bytes.)").addConstraintViolation();
				return false;
			}
		} catch (IOException e) {
			log.error(e.getMessage(), e);
			context.buildConstraintViolationWithTemplate("파일의 용량 확인 중 에러가 발생했습니다.(An error occurred while checking the file size.)").addConstraintViolation();
			return false;
		}

		//허용된 파일 확장자 검사
		final String detectedMediaType = this.getMimeTypeByTika(multipartFile); //확장자 변조한 파일인지 확인을 위한 mime type 얻기

		final UploadAllowFileDefine[] allowExtArray = annotation.allowFileDefines();
		final String fileExt = FilenameUtils.getExtension(fileName);
		for (UploadAllowFileDefine allowDefine : allowExtArray) {

			//파일명의 허용 확장자 검사
			if (StringUtils.equals(allowDefine.getFileExtensionLowerCase(), fileExt.toLowerCase()) == false) {
				StringBuilder sb = new StringBuilder();
				sb.append("허용되지 않는 확장자의 파일이며 다음 확장자들만 허용됩니다. This is a file with a disallowed extension, and only the following extensions are allowed.");
				sb.append(": ");
				sb.append(ArrayUtils.toString(allowExtArray));
				context.buildConstraintViolationWithTemplate(sb.toString()).addConstraintViolation();

				return false;
			}

			//파일 변조 업로드를 막기위한 mime타입 검사(예. exe파일을 csv로 확장자 변경하는 업로드를 막음)
			if (ArrayUtils.contains(allowDefine.getAllowMimeTypes(), detectedMediaType) == false) {
				StringBuilder sb = new StringBuilder();
				sb.append("확장자 변조 파일은 허용되지 않습니다.(Modified files with extensions are not allowed.)");
				context.buildConstraintViolationWithTemplate(sb.toString()).addConstraintViolation();

				return false;
			}
		}

		return true;
	}

	/**
	 * apache Tika라이브러리를 이용해서 파일의 mimeType을 가져옴
	 *
	 * @param multipartFile
	 * @return
	 */
	private String getMimeTypeByTika(MultipartFile multipartFile) {

		try {

			Tika tika = new Tika();
			String mimeType = tika.detect(multipartFile.getInputStream());
			log.debug("업로드 요청된 파일 {}의 mimeType:{}", multipartFile.getOriginalFilename(), mimeType);

			return mimeType;

		} catch (IOException e) {
			log.error(e.getMessage(), e);
			return null;
		}
	}

}

 
사용 예

@FileUploadValid(allowFileDefines = {UploadAllowFileDefine.CSV}, message = "유효한 CSV파일만 업로드 가능합니다.")
private MultipartFile targetCsvFile;

+ Recent posts