Koa 文件上传实战:koa-body 与 koa-multer 的选择与安全防护
Koa 本身不管文件上传——它只处理 HTTP 请求流,解析 multipart 数据得靠中间件。实际项目中 koa-body 和 koa-multer 是两个最主流的选择,选错了后面改起来很痛苦。
koa-body:开箱即用,大多数场景的首选
koa-body 底层基于 formidable,既能解析普通请求体,又能处理文件上传,一个中间件搞定两件事。不需要额外装 body-parser,配置也少。
安装与基本配置:
bashnpm install koa-body
javascriptconst 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——升级时这片坑踩的人最多
单文件上传:
javascriptapp.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":
javascriptapp.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 做不到这么灵活。
安装与存储配置:
bashnpm install koa-multer
javascriptconst 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 封装的)。拿文件的位置搞混是最常见的低级错误。
单文件、多文件、混合上传:
javascript// 单文件 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 是第一道关:
javascriptapp.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 和文件扩展名,两个都对才放行:
javascriptconst 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 倍,内存占用也更低。
bashnpm install sharp
javascriptconst 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 的文件不适合一次性上传,网络波动一个中断就从头再来。分片上传把大文件切成小块逐个上传,某片失败了只重传那一片,最后服务端按顺序合并。
分片上传实现:
javascriptconst 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 一头雾水 |