服务端阅读 05月28日 04:27
Koa 文件上传实战:koa-body 与 koa-multer 的选择与安全防护
Koa 本身不管文件上传——它只处理 HTTP 请求流,解析 multipart 数据得靠中间件。实际项目中 koa-body 和 koa-multer 是两个最主流的选择,选错了后面改起来很痛苦。koa-body:开箱即用,大多数场景的首选koa-body 底层基于 formidable,既能解析普通请求体,又能处理文件上传,一个中间件搞定两件事。不需要额外装 body-parser,配置也少。安装与基本配置:npm install koa-bodyconst koaBody = require('koa-body');app.use(koaBody({ multipart: true, formidable: { maxFileSize: 100 * 1024 * 1024, // 100MB keepExtensions: true, uploadDir: './uploads', multiples: true }}));三个容易踩坑的配置项:multipart: true 必须显式开启,默认是 false,文件上传不生效时先检查这个maxFileSize 默认只有 2MB,上传个高清头像都不够,实际项目基本都要调大新版 koa-body 通过 ctx.request.files 获取文件,旧版用 ctx.request.body.files——升级时这片坑踩的人最多单文件上传:app.use(async (ctx) => { const file = ctx.request.files.file; if (!file) ctx.throw(400, 'No file uploaded'); ctx.body = { message: 'File uploaded successfully', file: { name: file.name, size: file.size, path: file.path, type: file.type } };});多文件上传——注意单文件和多文件的返回格式不一致:只上传一个文件时 ctx.request.files.files 返回的是对象,多个文件时返回数组。不统一处理的话,Array.map 在单文件场景会报 "file.map is not a function":app.use(async (ctx) => { const files = ctx.request.files.files; if (!files) ctx.throw(400, 'No files uploaded'); // 统一转数组,这是 formidable 的坑 const fileList = Array.isArray(files) ? files : [files]; const uploadedFiles = fileList.map(file => ({ name: file.name, size: file.size, path: file.path, type: file.type })); ctx.body = { message: `${uploadedFiles.length} files uploaded`, files: uploadedFiles };});koa-multer:精细控制文件名和存储路径koa-multer 基于 Express 生态的 multer 改造,核心优势是 diskStorage 可以精确控制文件命名和目录结构——比如按日期分目录、按用户 ID 分目录,koa-body 做不到这么灵活。安装与存储配置:npm install koa-multerconst multer = require('koa-multer');const path = require('path');const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, './uploads/'); }, filename: function (req, file, cb) { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); }});const upload = multer({ storage: storage, limits: { fileSize: 100 * 1024 * 1024 }, fileFilter: function (req, file, cb) { const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Invalid file type'), false); } }});一个关键区别:koa-multer 的文件挂在 ctx.req.file / ctx.req.files 上(走的是 Node 原生 http.IncomingMessage),不是 ctx.request(Koa 封装的)。拿文件的位置搞混是最常见的低级错误。单文件、多文件、混合上传:// 单文件app.use(upload.single('file'));app.use(async (ctx) => { const file = ctx.req.file; ctx.body = { message: 'File uploaded', file };});// 多文件(最多 10 个)app.use(upload.array('files', 10));app.use(async (ctx) => { const files = ctx.req.files; ctx.body = { message: `${files.length} files uploaded`, files };});// 混合上传:同一请求中不同字段接收不同数量的文件app.use(upload.fields([ { name: 'avatar', maxCount: 1 }, { name: 'documents', maxCount: 5 }]));app.use(async (ctx) => { const files = ctx.req.files; const body = ctx.req.body; ctx.body = { avatar: files.avatar[0], documents: files.documents, data: body };});koa-body 还是 koa-multer?简单场景用 koa-body——一个中间件同时处理请求体解析和文件上传,少装一个包。需要按日期/用户分目录、自定义文件命名规则、按字段分组上传时用 koa-multer。两者也能配合使用,koa-body 处理普通请求体,koa-multer 专门处理上传路由,但要注意中间件加载顺序。文件上传安全防线:三层校验缺一不可文件上传是 Web 应用最常见的攻击入口。2024 年的数据显示,约 40% 的 Web 应用安全漏洞与文件上传校验不足有关。前端的 accept 属性和 JS 校验形同虚设,攻击者用 curl 或 Postman 直接绕过。第一层:中间件级过滤koa-body 的 formidable.filter 和 koa-multer 的 fileFilter 是第一道关:app.use(koaBody({ multipart: true, formidable: { maxFileSize: 10 * 1024 * 1024, // 10MB keepExtensions: true, uploadDir: './uploads', filter: function ({ name, originalFilename, mimetype }) { const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']; return allowedTypes.includes(mimetype); } }}));但这一层只检查 MIME type——MIME 是客户端声明的,可以伪造。攻击者把 PHP webshell 的 MIME 声明成 image/jpeg 就能过这一关。第二层:业务逻辑校验在业务层同时校验 MIME type 和文件扩展名,两个都对才放行:const path = require('path');const fs = require('fs');async function validateFile(ctx, next) { const file = ctx.request.files?.file; if (!file) ctx.throw(400, 'No file uploaded'); // 校验大小 const maxSize = 10 * 1024 * 1024; if (file.size > maxSize) { fs.unlinkSync(file.path); ctx.throw(400, 'File size exceeds limit'); } // 校验 MIME type const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; if (!allowedTypes.includes(file.type)) { fs.unlinkSync(file.path); ctx.throw(400, 'Invalid file type'); } // 校验扩展名(双重验证) const ext = path.extname(file.name).toLowerCase(); const allowedExts = ['.jpg', '.jpeg', '.png', '.gif']; if (!allowedExts.includes(ext)) { fs.unlinkSync(file.path); ctx.throw(400, 'Invalid file extension'); } await next();}app.use(validateFile);第三层:运维层防护代码层校验之外,还有几件事必须在运维层面做:随机文件名:不要用用户上传的原始文件名,防止路径遍历攻击(攻击者构造 ../../../etc/passwd 这样的文件名)上传目录隔离:不要把上传目录放在 Web 静态资源目录下,否则上传的 .html 文件可能被执行 XSS失败时清理临时文件:formidable 会先把文件写入 uploadDir,校验失败后不删就留在磁盘上了,日积月累会撑满磁盘速率限制:用 koa-ratelimit 限制单 IP 上传频率,防止恶意大文件轰炸图片处理:上传后的二次加工用户上传的图片通常需要压缩和生成缩略图。sharp 是 Node.js 生态里性能最好的图片处理库,基于 libvips,比 GraphicsMagick 快 4-5 倍,内存占用也更低。npm install sharpconst sharp = require('sharp');app.use(async (ctx) => { const file = ctx.request.files.file; if (!file) ctx.throw(400, 'No file uploaded'); // 生成缩略图 const thumbnailPath = file.path.replace(/(\.[\w\d]+)$/, '_thumb$1'); await sharp(file.path) .resize(200, 200, { fit: 'cover', position: 'center' }) .toFile(thumbnailPath); // 压缩原图 const compressedPath = file.path.replace(/(\.[\w\d]+)$/, '_compressed$1'); await sharp(file.path) .jpeg({ quality: 80 }) .toFile(compressedPath); ctx.body = { message: 'Image processed successfully', original: file.path, thumbnail: thumbnailPath, compressed: compressedPath };});一个实战经验:压缩质量 80 是性价比最高的档位——肉眼几乎看不出和原图的差别,但文件体积能缩小 60-70%。大文件分片上传超过 100MB 的文件不适合一次性上传,网络波动一个中断就从头再来。分片上传把大文件切成小块逐个上传,某片失败了只重传那一片,最后服务端按顺序合并。分片上传实现:const fs = require('fs');const path = require('path');app.use(async (ctx) => { const { chunkIndex, totalChunks, fileId } = ctx.request.body; const file = ctx.request.files.chunk; // 按 fileId 创建临时分片目录 const chunkDir = path.join('./uploads/chunks', fileId); if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir, { recursive: true }); } // 保存当前分片 const currentChunkPath = path.join(chunkDir, `chunk_${chunkIndex}`); const reader = fs.createReadStream(file.path); const writer = fs.createWriteStream(currentChunkPath); await new Promise((resolve, reject) => { reader.pipe(writer); writer.on('finish', resolve); writer.on('error', reject); }); // 检查是否所有分片都已到达 const uploadedChunks = fs.readdirSync(chunkDir).length; if (uploadedChunks === parseInt(totalChunks)) { // 合并所有分片 const finalPath = path.join('./uploads', `${fileId}${path.extname(file.name)}`); const writeStream = fs.createWriteStream(finalPath); for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(chunkDir, `chunk_${i}`); const chunkData = fs.readFileSync(chunkPath); writeStream.write(chunkData); fs.unlinkSync(chunkPath); } writeStream.end(); fs.rmdirSync(chunkDir); ctx.body = { message: 'File upload completed', path: finalPath }; } else { ctx.body = { message: `Chunk ${chunkIndex} uploaded`, progress: `${uploadedChunks}/${totalChunks}` }; }});分片上传上生产之前,这几件事必须处理:断点续传:客户端上传前先请求服务端查已有分片列表,跳过已上传的分片,而不是从头开始分片过期清理:用户上传了 3 片然后关闭页面,分片永远留在磁盘上。设置定时任务(cron job),清理超过 24 小时未完成的分片目录并发写入:客户端用 Promise.all 同时传多个分片时,fs.readdirSync 读到的数量可能不准确,需要用文件锁或 Redis 计数器保证一致性完整性校验:合并完成后用 MD5 或 SHA256 校验文件哈希,和客户端传来的原始哈希对比,确保传输没有丢数据生产环境清单| 分类 | 要点 | 不做的后果 ||------|------|-----------|| 文件大小限制 | maxFileSize 或 limits.fileSize | 大文件撑爆内存或磁盘 || MIME + 扩展名双重校验 | 两个都检查,不能只靠一个 | 伪造 MIME 上传恶意文件 || 随机文件名 | UUID 或时间戳+随机数 | 路径遍历攻击、文件名冲突 || 上传目录隔离 | 不放在 static 目录下 | 上传的 HTML/JS 被直接执行 || 校验失败清临时文件 | fs.unlinkSync(file.path) | 磁盘被垃圾文件撑满 || 速率限制 | koa-ratelimit 限制单 IP | 恶意大文件轰炸 || 流式处理大文件 | createReadStream + pipe | 大文件一次性读进内存 OOM || 图片压缩 | sharp 质量设 80 | 原图直接存浪费存储和带宽 || 分片过期清理 | 定时任务清理 24h 未完成分片 | 孤立分片占满磁盘 || 友好错误提示 | "文件太大,最大 10MB" | 用户看到 400 Bad Request 一头雾水 |