Node.js入门次阅读

Node.js 文件上传:从能用到能用在生产

一个能跑的最小上传接口,和能上生产的上传接口,中间隔着类型校验、大小限制、重命名、目录穿越防护。这份 demo 用 Express + multer 把这几层一次补齐,含前端进度条,复制即可跑。

文件上传Expressmulter安全实战Demo

项目里"加个上传功能"听起来是半小时的事,但真做成能用的,坑基本都在细节里:文件重名覆盖、上传目录被塞满、用户传个 .exe 进来、文件名带 ../ 直接写到你不想写的地方。

下面从最小版本开始,一层层加。

环境

npm init -y
npm i express multer

最小可用版

// server.js
const express = require('express');
const multer  = require('multer');
const path    = require('path');

const app = express();
const upload = multer({ dest: 'uploads/' });   // 文件落在 ./uploads

app.post('/upload', upload.single('file'), (req, res) => {
  // req.file 是上传的文件信息,req.body 是其他文本字段
  if (!req.file) return res.status(400).json({ error: '没有收到文件' });
  res.json({ filename: req.file.filename, size: req.file.size });
});

app.listen(3000, () => console.log('http://localhost:3000'));

测一下:

curl -F "file=@./test.png" http://localhost:3000/upload

这版能用,但落地的文件名是随机哈希、没有扩展名,而且谁都能传任何东西。接着改。

生产版:限制 + 重命名 + 类型白名单

const express = require('express');
const multer  = require('multer');
const path    = require('path');
const fs      = require('fs');
const crypto  = require('crypto');

const app = express();
const UPLOAD_DIR = path.join(__dirname, 'uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });

// 只允许这几类
const ALLOWED = {
  'image/jpeg': '.jpg',
  'image/png' : '.png',
  'image/gif' : '.gif',
  'application/pdf': '.pdf',
};

const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, UPLOAD_DIR),
  filename: (req, file, cb) => {
    // 不用用户给的原名:防重名、防中文乱码、防目录穿越
    const ext = ALLOWED[file.mimetype] || path.extname(file.originalname).toLowerCase();
    const name = crypto.randomBytes(16).toString('hex') + ext;
    cb(null, name);
  },
});

const upload = multer({
  storage,
  limits: {
    fileSize: 10 * 1024 * 1024,   // 单个文件 10MB
    files: 5,                     // 一次最多 5 个
  },
  fileFilter: (req, file, cb) => {
    if (ALLOWED[file.mimetype]) return cb(null, true);
    cb(new Error(`不支持的文件类型: ${file.mimetype}`));
  },
});

app.post('/upload', upload.single('file'), (req, res) => {
  if (!req.file) return res.status(400).json({ error: '没有收到文件' });
  res.json({
    filename: req.file.filename,
    original: req.file.originalname,
    size: req.file.size,
  });
});

// 统一错误处理(multer 的限制错误会走到这)
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    const msg = {
      LIMIT_FILE_SIZE: '文件超过 10MB',
      LIMIT_FILE_COUNT: '文件数量超限',
      LIMIT_UNEXPECTED_FILE: '字段名不匹配',
    }[err.code] || err.message;
    return res.status(400).json({ error: msg });
  }
  res.status(400).json({ error: err.message });
});

app.listen(3000, () => console.log('http://localhost:3000'));

几个关键点

为什么不用 file.originalname 直接存盘

三个原因:重名会互相覆盖;中文名在某些系统上乱码;更麻烦的是 originalname 完全由客户端控制,可能是 ../../etc/passwd 这种。multer 的 diskStorage 里我们只用 path.extname() 取扩展名、主体用随机哈希,目录穿越自然就没了。

mimetype 也不可全信

它是客户端给的 Content-Type,可以伪造。真要严格,得在写盘后读文件头魔数判断真实类型:

// 粗略判断:读前几个字节
function sniffExt(buf) {
  if (buf[0] === 0xff && buf[1] === 0xd8) return '.jpg';
  if (buf[0] === 0x89 && buf[1] === 0x50) return '.png';
  if (buf[0] === 0x25 && buf[1] === 0x50) return '.pdf';
  return null;
}

limits 是必要的

不限大小,一个 2GB 的文件就能把磁盘写满。除 fileSize 外,files 限制数量,fields 限制非文件字段数。

多文件与多字段

// 同一字段多个文件
app.post('/multi', upload.array('photos', 5), (req, res) => {
  res.json(req.files.map(f => f.filename));
});

// 不同字段不同文件(比如头像 + 身份证)
const cpUpload = upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'idcard', maxCount: 1 },
]);
app.post('/profile', cpUpload, (req, res) => {
  res.json({ avatar: req.files.avatar?.[0]?.filename, idcard: req.files.idcard?.[0]?.filename });
});

前端:带进度条

multer 时代前端一般用 XMLHttpRequestfetch 拿不到上传进度):

<input type="file" id="f" />
<button onclick="upload()">上传</button>
<progress id="p" value="0" max="100"></progress>
<pre id="out"></pre>

<script>
async function upload() {
  const file = document.getElementById('f').files[0];
  if (!file) return alert('先选文件');

  const form = new FormData();
  form.append('file', file);

  const xhr = new XMLHttpRequest();
  xhr.open('POST', '/upload');

  xhr.upload.onprogress = (e) => {
    if (e.lengthComputable) {
      document.getElementById('p').value = (e.loaded / e.total) * 100;
    }
  };
  xhr.onload = () => {
    document.getElementById('out').textContent = xhr.responseText;
  };
  xhr.onerror = () => alert('上传失败');

  xhr.send(form);
}
</script>

静态目录别直接暴露

如果上传目录需要能被访问:

app.use('/files', express.static(UPLOAD_DIR, {
  dotfiles: 'deny',
  index: false,
  setHeaders: (res) => {
    // 防止浏览器把上传的 html 当页面执行(XSS)
    res.setHeader('Content-Security-Policy', "default-src 'none'");
    res.setHeader('X-Content-Type-Options', 'nosniff');
  },
}));

X-Content-Type-Options: nosniff 和 CSP 这两行别省——否则别人上传一个含脚本的 .html 或伪装图片,在你的域名下就能执行,是个标准的存储型 XSS。

还要补什么

这份 demo 覆盖到"能用",上生产前通常还得加:

  • 鉴权:上传接口必须登录后才能调,否则变成免费图床
  • 限流:同 IP 频率限制,不然被刷
  • 病毒扫描:对外服务建议接 ClamAV
  • 对象存储:量大之后别存本地磁盘,换 S3/OSS,multer 换成 multer-s3
  • 清理:失败的临时文件定期清,不然 uploads/ 会堆满半成品