文件格式校验方案

一、背景

异常现象

很长一段时间以来,前后端都是根据扩展名判断文件类型,但近期发现用户上传的.jpg格式图片存在解析异常的问题。拿到原图后测试发现:

  • Windows 10 原生图片查看器提示文件损坏
  • 主流浏览器(Chrome/Firefox)可正常渲染
  • Windows 11 原生查看器正常显示

原因排查

这不禁让笔者感到好奇,于是打开二进制格式检查了下文件头,发现这些文件的 Magic Number 对应的并不是 JPEG 格式,而是 AVIF (文件头:6674797061766966),一种较新的图片格式。

用户的无心之过

从用户视角来看,用户上传.avif图片时发现系统不支持上传,于是手动修改图片后缀为.jpg(用户以为改了扩展名就相当于改了文件格式),绕过了前端校验,而且由于浏览器强大的兼容能力,用户上传后发现在浏览器上能正常预览图片,便认为自己的操作是合理的。而后,后端解码失败。这些用户并非恶意攻击者,而是因系统未兼容新型图片格式采取的无奈之举。

二、解决方案

除了判断文件扩展名之外,还可以进行文件头校验和内容特征解析

Magic Number判断

魔数指的是文件开头的一串特定的字节序列,相较于文件扩展名,魔数更能有效识别文件类型。魔数没有固定长度,大部分文件类型的魔数不同,但也有少量文件类型有相同魔数

文件类型 文件头 文件尾
jpeg(jpg) FF D8 FF D9
png 89 50 4E 47 0D 0A 1A 0A
bmp 42 4d
gif 47 49 46 38 39 61
tiff 4d 4d 或 49 49
zip/xlsx/pptx/docx 50 4B 03 04

少量文件类型的判断,可以直接校验文件头。比如若只允许用户上传jpg/png格式的图片,实现如下:

java 复制代码
@Getter
public enum MimeTypeEnum {

    IMAGE_JPEG("image/jpeg", "FFD8", "FFD9"),
    IMAGE_PNG("image/png", "89504E470D0A1A0A", null),
    IMAGE_BMP("image/bmp", "424D", null),
    ;

    private final String mimeType;
    private final byte[] header; // 文件头
    private final byte[] footer; // 文件尾

    MimeTypeEnum(String mimeType, String header, String footer) {
        this.mimeType = mimeType;
        this.header = header == null ? null : DatatypeConverter.parseHexBinary(header);
        this.footer = footer == null ? null : DatatypeConverter.parseHexBinary(footer);
    }

    public static final Set<MimeTypeEnum> whiteList = Sets.newHashSet(IMAGE_JPEG, IMAGE_PNG);
}
java 复制代码
public static void test(MultipartFile mFile) throws Exception {
    MimeTypeEnum mimeType = detectMimeType(mFile);

    Assert.isTrue(MimeTypeEnum.whiteList.contains(mimeType), "不支持文件类型:" + mimeType);
}

public static MimeTypeEnum detectMimeType(MultipartFile multipartFile) throws IOException {
    try (InputStream inputStream = multipartFile.getInputStream()) {
        byte[] header = new byte[8]; // 读取前 8 个字节
        byte[] footer = new byte[2];// 读取后 2 个字节
        inputStream.read(header);
        inputStream.skip(multipartFile.getSize() - 2 - 8);
        inputStream.read(footer);

        for (MimeTypeEnum mimeTypeEnum : MimeTypeEnum.values()) {
            if (matchMagicNumber(header, footer, mimeTypeEnum)) {
                return mimeTypeEnum;
            }
        }
    }
    return null;
}


private static boolean matchMagicNumber(byte[] header, byte[] footer, MimeTypeEnum mimeType) {
    // 检查文件头
    if (!Arrays.equals(mimeType.getHeader(), Arrays.copyOf(header, mimeType.getHeader().length))) {
        return false;
    }

    // 检查文件尾
    if (mimeType.getFooter() != null) {
        return Arrays.equals(mimeType.getFooter(), footer);
    }
    return true;
}

注意,zip/xlsx/pptx/docx的魔数都是相同的,无法用魔数精确分辨。具体方法后面说

主流检测库对比

常见的文件类型极多,手动维护魔数判断繁琐,目前已有许多文件类型校验库,没必要重复造轮子了

库名称 格式覆盖 文件类型明细
Tika >1k org/apache/tika/mime/tika-mimetypes.xml
JMimeMagic >100 src/main/resources/magic.xml

Tika的使用

Tika支持的文件类型最多,由Apache维护并跟进最新文件格式。在 tika-mimetypes.xml 中有笔者需要的.avif格式

java 复制代码
<mime-type type="image/avif">
    <!-- According to https://github.com/libvips/libvips/pull/1657
      older avif used to use the the heif 'ftypmif1' as well -->
    <_comment>AV1 Image File</_comment>
    <acronym>AVIF</acronym>
    <tika:link>https://en.wikipedia.org/wiki/AV1#AV1_Image_File_Format_(AVIF)</tika:link>
    <magic priority="60">
      <match value="ftypavif" type="string" offset="4"/>
    </magic>
    <glob pattern="*.avif"/>
</mime-type>

引入pom依赖后,通过detect方法判断出mimeType,示例代码如下:

java 复制代码
public void test(MultipartFile file) {                                                                                                           
    String mimeType = new Tika().detect(file.getInputStream());

    log.info(mimeType) // image/avif
}

tika返回的mimeType(Multipurpose Internet Mail Extensions),用于标识互联网上传输的文件类型和格式,常见的mimeType如下:

扩展名 MIME 类型
.jpeg, .jpg image/jpeg
.png image/png
.avif image/avif
.gif image/gif
.mp4 video/mp4
.pdf application/pdf
.ppt application/vnd.ms-powerpoint
.pptx application/vnd.openxmlformats-officedocument.presentationml.presentation
.doc application/msword
.docx application/vnd.openxmlformats-officedocument.wordprocessingml.document
.xls application/vnd.ms-excel
.xlsx application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

区分zip/xlsx/pptx/docx

由于xlsx/pptx/docx魔数相同,都是ooxml(Office Open XML File Formats),Tika只能识别为application/x-tika-ooxml,因此需要额外读取实际内容判断其类型。如果将文件修改扩展名为zip,就可以发现Excel的实际文件目录如下,我们可以通过workbook.xml识别其为excel。其他格式同理。

java 复制代码
│   [Content_Types].xml
│
│───_rels
│      .rels
│
├───docProps
│       app.xml
│       core.xml
│
└───xl
   │   sharedStrings.xml
   │   styles.xml
   │   workbook.xml
   │
   ├───_rels
   │      workbook.xml.rels
   │
   └───worksheets
          sheet1.xml

检测代码如下:

java 复制代码
/* 文件类型白名单 */
public static List<String> mimeTypeWhiteList = Arrays.asList(
    "image/jpeg", 
    "image/png");

public  void test(MultipartFile multipartFile) throws Exception {
    String mimeType = new Tika().detect(file.getInputStream());
    
    if ("application/x-tika-ooxml".equals(mimeType)) {
        mimeType = detectOOXML(file);
    }
    log.info(mimeType);

    Assert.isTrue(mimeTypeWhiteList.contains(mimeType), "不支持文件类型:" + mimeType);
}

/**
 * 解析ooxml(Office Open XML File Formats)
 */
private String detectOOXML(File file) throws IOException {
    try (ZipFile zipFile = new ZipFile(file)) {
        if (zipFile.getEntry("word/document.xml") != null) {
            return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        }
        if (zipFile.getEntry("xl/workbook.xml") != null) {
            return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
        }
        if (zipFile.getEntry("ppt/presentation.xml") != null) {
            return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
        }
    }
    return "application/zip";
}

区分xls/ppt/doc

xls/ppt/doc是Microsoft Office的早期版本,使用二进制文件格式,读取文件内容可以进行大致识别。

java 复制代码
private static String detectMsOffice(InputStream inputStream) throws Exception {
    byte[] buffer = new byte[1024 * 10];

    while (inputStream.read(buffer) != -1) { // todo 滑动窗口优化
        if (containsSubArray(buffer, "Excel".getBytes())) {
            return "application/vnd.ms-excel";
        }
        if (containsSubArray(buffer, "PowerPoint".getBytes())) {
            return "application/vnd.ms-powerpoint";
        }
        if (containsSubArray(buffer, "Office Word".getBytes())) {
            return "application/msword";
        }
    }
    return "unknown";
}

然而读取文件内容进行识别并不一定准确,如下图,假如在excel中输入"PowerPoint"就可能被识别为ppt。所以目前三者之间并没有精确识别的办法。

三、总结

文件扩展名校验虽然不够准确,但实现起来简单,能满足大部分情况(毕竟修改扩展名的用户只是极少数),适合作为短期方案。但长期来看还是推荐组合校验(扩展名+魔数+内容),能更精确识别文件类型。

相关推荐
.又是新的一天.4 分钟前
使用IDEA创建Maven版本的web项目以及lombok的使用
java·maven·intellij-idea
zimoyin6 分钟前
Java 快速转 C# 教程
java·开发语言·c#
Haooog10 分钟前
Java 面向对象详解和JVM底层内存分析
java·jvm
CopyLower21 分钟前
Java在微服务架构中的最佳实践:从设计到部署
java·微服务·架构
oneDay++23 分钟前
# IntelliJ IDEA企业版安装与配置全指南:避坑详解
java·开发语言·经验分享·学习·学习方法
supingemail1 小时前
面试之 Java 新特性 一览表
java·面试·职场和发展
星星点点洲1 小时前
【Java】应对高并发的思路
java
LDM>W<1 小时前
黑马点评-用户登录
java·redis
保利九里1 小时前
数据类型转换
java·开发语言
Uranus^1 小时前
使用Spring Boot与Spring Security构建安全的RESTful API
java·spring boot·spring security·jwt·restful api