javaspring-bootvalidationfile-upload

Spoof-proof file type validation in a Spring Boot app


I've created a custom validator for use in a Spring Boot app that checks the type of an uploaded file. The @ValidFileType annotation is defined as

@Documented
@Constraint(validatedBy = MultiPartFileValidator.class)
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidFileType {

    String[] allowed() default {
        "image/webp",
        "image/svg+xml",
        "application/zip",
    };

    String message() default "{com.example.invalidFileType}";

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

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

The validator itself is

class MultiPartFileValidator implements ConstraintValidator<ValidFileType, MultipartFile> {

    private Set<String> allowed;

    @Override
    public void initialize(ValidFileType constraintAnnotation) {
        allowed = Set.of(constraintAnnotation.allowed());
    }

    @Override
    public boolean isValid(MultipartFile file, ConstraintValidatorContext context) {
        return file == null || allowed.contains(file.getContentType());
    }
}

This is typically used in a controller method like so:

@PostMapping
public void uploadFile(@Valid @ValidFileType @RequestPart MultipartFile uploadedFile) {
    // if we get this far, the file that has passed validation
}

However, someone has pointed out that this validator relies solely on the content-type header which can be spoofed. If I can't rely on this header (or the extension in the file name), is there anything else I can use to verify the file type that is more resilient?


Solution

  • You can test out Tika for this. Requires this dependency: org.apache.tika:tika-core:3.0.0

    Example usage in a similar validator

    import jakarta.validation.ConstraintValidator;
    import jakarta.validation.ConstraintValidatorContext;
    import org.apache.tika.Tika;
    import org.springframework.http.MediaType;
    import org.springframework.web.multipart.MultipartFile;
    
    import java.io.IOException;
    import java.util.Set;
    
    public class MultiPartFileValidator implements ConstraintValidator<ValidFileType, MultipartFile> {
    
        private static final Set<String> ALLOWED_FILETYPES =
                Set.of(MediaType.IMAGE_PNG_VALUE, MediaType.IMAGE_JPEG_VALUE);
    
        private static final Tika TIKA = new Tika();
    
        private long maxFileSize;
        private String invalidFileTypeMessage;
        private String invalidSizeMessage;
    
        @Override
        public void initialize(ValidFileType constraintAnnotation) {
            this.maxFileSize = constraintAnnotation.maxFileSize();
            this.invalidFileTypeMessage = constraintAnnotation.invalidFileTypeMessage();
            this.invalidSizeMessage = constraintAnnotation.invalidSizeMessage();
        }
    
        @Override
        public boolean isValid(MultipartFile file, ConstraintValidatorContext context) {
            // Null or empty check upfront
            if (file == null) {
                return true;
            }
    
            context.disableDefaultConstraintViolation();
    
            // Size check: Ensure the file is within the allowed size limit
            if (file.getSize() > maxFileSize) {
                return setValidationError(invalidSizeMessage, context);
            }
    
            // Validate file type
            try {
                // other overloads of detect exist
                String mimeType = TIKA.detect(file.getBytes());
                if (!ALLOWED_FILETYPES.contains(mimeType)) {
                    return setValidationError(invalidFileTypeMessage, context);
                }
            } catch (IOException e) {
                // Handle error if file type detection fails
                return setValidationError("Unable to detect file type", context);
            }
    
            // If both size and type are valid
            return true;
        }
    
        private static boolean setValidationError(String message, ConstraintValidatorContext context) {
            context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
            return false;
        }
    }