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?
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;
}
}