前言
昨天天群里有小伙伴提了个问题:
有用户修改了文件后缀名,从而骗过程序成功调用了头像上传接口,可能遇到了头像无法显示的问题。很明显头像上传功能应只允许上传图片类型的文件才对。
这里说个题外话,该“用户”(攻击者)的行为,往小了说,导致网站头像显示功能异常。往大了说,可能是想利用网站潜在的漏洞进行攻击,例如通过某种方法执行这个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方法读取上传文件的魔数进行对比。
先用express
和multer
实现一个简单的文件上传功能:
// 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()}`);
}
}