文件上传之如何彻底防止伪造文件类型

1,032 阅读5分钟

前言

昨天天群里有小伙伴提了个问题:

有用户修改了文件后缀名,从而骗过程序成功调用了头像上传接口,可能遇到了头像无法显示的问题。很明显头像上传功能应只允许上传图片类型的文件才对。

这里说个题外话,该“用户”(攻击者)的行为,往小了说,导致网站头像显示功能异常。往大了说,可能是想利用网站潜在的漏洞进行攻击,例如通过某种方法执行这个html文件,从而执行里面的js文件,这就是典型的XSS(跨站脚本攻击),对于可实施性这里暂且不谈。

我立马本地写个demo试了一下,设置accept=".png"html文件后缀改为png,直接无法选择(被禁用),当时心里想这浏览器还挺智能的,于是回复群友,前端设置accept即可限制。 到了晚上写这篇文章时再试了下,居然又可以选择了,试了chrome、edge、safari三个浏览器都可以,咱也不知道当时发生了啥。

后来查了下,设置accept,浏览器只会判断文件后缀,也就是文件的 MIME 类型(file对象的type属性),而这个值是可以伪造的。其实,前端即使不用accept也是可以判断真实类型的。

啰嗦了这么久,下面进入正题,本文就用前端和后端两种方法判断文件的真实类型。

前端方法

方法1:仅支持图片或视频

我们知道,选择文件后,拿到的file对象中有一个type字段,如type:image/png,表示文件后缀,我们暂且相信该文件就是这个类型,如果是图片,就创建一个img元素,如果是视频,就创建一个video元素,动态加载它,如果加载失败,就认为该文件不合法。

封装一个文件加载的方法:

async function isFileLoadSuccess(file, fileType) {
  return new Promise((resolve, reject) => {
    if (fileType === 'image') {
      const reader = new FileReader()
      reader.onload = (e) => {
        const src = e.target.result
        const image = new Image()
        image.src = src
        image.onload = () => {
          console.log('image onload');
          resolve(true)
        }
        image.onerror = () => reject({ message: '图片加载失败' })
      }
      reader.onerror = () => reject({ message: '图片读取失败' })
      reader.readAsDataURL(file)
    } else if (fileType === 'video') {
      const src = URL.createObjectURL(file)
      const video = document.createElement('video')
      video.onloadedmetadata = () => {
        URL.revokeObjectURL(src)
        resolve(true)
      }
      video.onerror = () => reject({ message: '视频加载失败' })
      video.src = src
      video.load()
    } else {
      resolve(null)
    }
  })
}

选择文件回调时执行

<input type="file" id="file-input" />
<script>
  const fileInput = document.getElementById('file-input')
  fileInput.addEventListener('change', async (e) => {
    const file = e.target.files[0]
    const fileType = file.type.split('/')[0]
    try {
      if (['image', 'video'].includes(fileType)) {
        const res = await isFileLoadSuccess(file, fileType)
        // 继续业务逻辑
      }
    } catch (e) {}
  })
</script>

因为我们是本地选择的文件,没理由加载失败,除非是选择了一个超大文件,大到超过了浏览器的内存限制(FileReader会消耗浏览器内存),具体可看这篇文章,但我们一般会限制文件大小,大文件上传不要使用这个方法。

方法2

其实有现成的库来判断文件的真实类型,例如file-type,支持浏览器和node环境。其原理是读取文件的内容,每种文件的前几个字节都是固定的,例如png文件的前八个字节是89 50 4E 47 0D 0A 1A 0A,jpg的前三个字节是FF D8 FF。

安装file-type

npm i file-type

浏览器环境用法:

import { fileTypeFromStream } from 'file-type';

这次我们使用element-plus的el-upload组件

<el-upload
  v-model:file-list="fileList"
  :before-upload="beforeUpload"
  :on-success="onUploadSuccess"
  :on-error="onUploadError"
>点击上传</el-upload>

在 before-upload 钩子中限制上传。

const beforeUpload = (file) => {
  const url = await readAsDataURL(file)
  const response = await fetch(url)
  const type = await fileTypeFromStream(response.body);
  if (!type) {
    // 文件类型不合法
    return false
  }
  return true
}

如果拿不到type值,我们就认为是篡改过文件后缀的。

不管哪种方法,核心就是通过读取文件内容来判断,读不到就认为是“假的”。

前端毕竟属于客户端,理论上来说客户端运行的代码是永远不能被信任的,最终还是需要服务端来实现。

后端方法

虽然后现成的file-type库,但这次我们使用node自带的方法读取文件字节数。

思路:每种文件的前几个字节(我们成为魔数)都是固定的,通过fs方法读取上传文件的魔数进行对比。

先用expressmulter实现一个简单的文件上传功能:

// index.js
const express = require('express');
const multer = require('multer');
const app = express();
const port = 1000;

const storage = multer.diskStorage({
  // 指定存储目录
  destination: function (req, file, cb) {
    try {
      fs.mkdirSync('uploads');
    } catch (e) {}
    cb(null, 'uploads');
  },
  // 指定文件名
  filename: function (req, file, cb) {
    // 对文件名进行编码,避免中文乱码
    const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
    const uniqueSuffix = Date.now() + '-' + originalName;
    cb(null, uniqueSuffix);
  },
});
const upload = multer({
  storage,
  // 限制文件大小为 5MB
  limits: {
    fileSize: 5 * 1024 * 1024
  },
});

app.post('/upload', upload.single('file'), async (req, res) => {
  try {
    res.send('文件上传成功!');
  } catch (error) {
    res.status(400).send(error.message);
  }
});

app.listen(port, () => {
  console.log(`服务运行在 http://localhost:${port}`);
});

运行一下

postman调用

上传成功

下面读取文件的魔数,封装一个用于校验文件类型合法性的函数:


async function validateFileType(filePath, expectedType) {
  // 定义对象,通过file.mimetype映射两种不同文件的魔数
  const MAGIC_NUMBERS = {
    'image/png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), // PNG
    'image/jpeg': Buffer.from([0xFF, 0xD8, 0xFF]), // JPEG
  };
  // 根据上传的文件mimetype获取魔数
  const magicNumber = MAGIC_NUMBERS[expectedType];
  // 这里只定义了png和jpeg两种,不符合的就不允许上传
  if (!magicNumber) throw new Error('不支持该文件类型');
  // 打开文件,获取文件描述符,r: 只读
  const fd = await fs.promises.open(filePath, 'r');
  try {
    // 以上传文件的魔数长度创建缓冲区
    const buffer = Buffer.alloc(magicNumber.length);
    // 读取该文件从0到魔数长度的字节
    const { bytesRead } = await fd.read(buffer, 0, magicNumber.length, 0);
    // 如果和预期字节数不相等,或buffer内容不同,则不允许上传
    if (bytesRead < magicNumber.length || !buffer.equals(magicNumber)) {
      throw new Error(`文件类型不合法,预期为 ${expectedType.toUpperCase()}`);
    }
  } finally {
    // 结束后关闭文件描述符。
    await fd.close();
  }
}

注意:

  • bytesRead < magicNumber.length:如果读取的字节数小于魔数的长度,说明读取失败了,或者文件损坏
  • !buffer.equals(magicNumber):如果读取的字节内容与预期的魔数不匹配,说明文件格式不对,即篡改过文件后缀

我们改一下代码,文件上传后调用一下这个校验方法

app.post('/upload', upload.single('file'), async (req, res) => {
  try {
    console.log('[ req ] >', req)
    if (!req.file) {
      throw new Error('未上传文件');
    }
    const { path, mimetype } = req.file;
    await validateFileType(path, mimetype);
    // 后续验证逻辑
    res.send('文件上传成功!');
  } catch (error) {
    // 校验失败时删除无效文件
    if (req.file) {
      fs.unlinkSync(req.file.path);
    }
    res.status(400).send(error.message);
  }
});

如果校验通过,告诉前端上传成功

因为multer的diskStorage模式,会自动上传文件到指定的文件夹,即硬盘,如果检验不通过,就删除文件,避免占用过多空间。

multer还有个memoryStorage模式,不会自动上传文件到硬盘,而是在内存中,即buffer,这种方式上传文件后,可以直接通过file.buffer拿到文件的buffer内容,然后跟文件的固定魔数对比即可,比diskStorage简单很多,但不适合大文件上传,可能会导致内存溢出,并且还需要手动写入文件夹。

使用memoryStorage时,校验文件可以如下:

function validateFileType(file, expectedType) {
  const magicNumber = MAGIC_NUMBERS[expectedType];
  const fileBuffer = file.buffer;
  // 获取文件头部字节,长度等于固定魔数的长度
  const header = fileBuffer.subarray(0, magicNumber.length);
  if (!header.equals(magicNumber)) {
    throw new Error(`文件类型不合法,预期为 ${expectedType.toUpperCase()}`);
  }
}