安全的文件上传需要多层防护,不能只依赖单一检查
| 检查项 | 方法 | 目的 |
|---|---|---|
| 扩展名 | 白名单验证 | 只允许安全的扩展名 |
| 双扩展名 | 检查所有 . 分隔部分 | 防止 shell.php.jpg |
| MIME 类型 | 白名单 + 魔数验证 | 防止 MIME 伪造 |
| 文件大小 | 限制最大尺寸 | 防止 DoS |
| 文件重命名 | 随机生成文件名 | 防止路径遍历 |
| 存储位置 | 非 Web 可访问目录 | 防止直接执行 |
const multer = require('multer')
const fileType = require('file-type')
const path = require('path')
const crypto = require('crypto')
const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
const storage = multer.diskStorage({
destination: './uploads/', // 非 public 目录
filename: (req, file, cb) => {
// 随机文件名
const ext = path.extname(file.originalname).toLowerCase()
const name = crypto.randomBytes(16).toString('hex')
cb(null, name + ext)
}
})
const upload = multer({
storage,
limits: { fileSize: MAX_SIZE },
fileFilter: async (req, file, cb) => {
// 1. 检查扩展名
const ext = path.extname(file.originalname).toLowerCase()
if (!['.jpg', '.jpeg', '.png', '.gif'].includes(ext)) {
return cb(new Error('不允许的扩展名'))
}
// 2. 检查双扩展名
if (file.originalname.split('.').length > 2) {
return cb(new Error('检测到双扩展名'))
}
// 3. 检查 MIME
if (!ALLOWED_MIMES.includes(file.mimetype)) {
return cb(new Error('不允许的 MIME 类型'))
}
cb(null, true)
}
})
// 上传后验证文件内容
app.post('/upload', upload.single('file'), async (req, res) => {
const buffer = fs.readFileSync(req.file.path)
const type = await fileType.fromBuffer(buffer)
if (!type || !ALLOWED_MIMES.includes(type.mime)) {
fs.unlinkSync(req.file.path) // 删除文件
return res.status(400).json({ error: '文件内容与类型不符' })
}
res.json({ success: true, filename: req.file.filename })
})