文件格式校验方案

一、背景

异常现象

很长一段时间以来,前后端都是根据扩展名判断文件类型,但近期发现用户上传的.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。所以目前三者之间并没有精确识别的办法。

三、总结

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

相关推荐
明天不下雨(牛客同名)2 小时前
为什么 ThreadLocalMap 的 key 是弱引用 value是强引用
java·jvm·算法
多多*3 小时前
Java设计模式 简单工厂模式 工厂方法模式 抽象工厂模式 模版工厂模式 模式对比
java·linux·运维·服务器·stm32·单片机·嵌入式硬件
草捏子4 小时前
从CPU原理看:为什么你的代码会让CPU"原地爆炸"?
后端·cpu
嘟嘟MD4 小时前
程序员副业 | 2025年3月复盘
后端·创业
胡图蛋.5 小时前
Spring Boot 支持哪些日志框架?推荐和默认的日志框架是哪个?
java·spring boot·后端
无责任此方_修行中5 小时前
关于 Node.js 原生支持 TypeScript 的总结
后端·typescript·node.js
牛马baby5 小时前
Java高频面试之并发编程-01
java·开发语言·面试
小小大侠客5 小时前
将eclipse中的web项目导入idea
java·eclipse·intellij-idea
不再幻想,脚踏实地5 小时前
MySQL(一)
java·数据库·mysql
吃海鲜的骆驼5 小时前
SpringBoot详细教程(持续更新中...)
java·spring boot·后端