Node.js 中的安全图片上传

Express 示例中的上传示意图:浏览器、multer 内存存储、gallery.mjs 中的检查、磁盘上的文件和 express.static,失败时返回 400 或 413

图片上传端点让任何人都能把文件放到您的服务器上,再由您自己的域名提供出去。本页介绍 Express 示例在写入文件之前检查什么、之后如何提供这些文件,以及这些检查无法证明什么,并附上我们用来测试每个部分的请求。

服务器在写入前检查什么

顶部的示意图展示了检查顺序。multer 把表单读入内存(它的限制见Node.js 中的图片上传大小限制)。然后 saveImage() 执行三项检查,全部通过后才会写入磁盘:

JavaScript · gallery.mjs
// Allowed extensions, each with a check of the file's first bytes (its signature).
const SIGNATURES = {
  ".jpg": (b) => hex(b, 0, 3) === "ffd8ff",
  ".jpeg": (b) => hex(b, 0, 3) === "ffd8ff",
  ".png": (b) => hex(b, 0, 8) === "89504e470d0a1a0a",
  ".gif": (b) => ascii(b, 0, 6) === "GIF87a" || ascii(b, 0, 6) === "GIF89a",
  ".webp": (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP",
};
const hex = (bytes, start, end) => bytes.subarray(start, end).toString("hex");
const ascii = (bytes, start, end) => bytes.subarray(start, end).toString("latin1");

// True for "chair.jpg", false for ".chair.jpg", "chair.svg" or "chair".
export function isImageName(name) {
  return !name.startsWith(".") && Object.hasOwn(SIGNATURES, extensionOf(name));
}

export class GalleryError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

// A folder or file name: 1-64 letters, digits, space, - _ . and not starting with a dot.
// Also refused: a trailing dot or space (Windows drops them, so "a." would become "a")
// and Windows device names such as CON or NUL.
export function isValidName(name) {
  return (
    typeof name === "string" &&
    /^[A-Za-z0-9 _.-]{1,64}$/.test(name) &&
    !name.startsWith(".") &&
    !/[. ]$/.test(name) &&
    !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(name)
  );
}
  1. 扩展名必须是 .jpg、.jpeg、.png、.gif 或 .webp。这是一个允许列表,因此 .svg、.html 和 .php 会被拒绝,服务器无需逐一了解它们。SVG 是有意排除的:它可以包含脚本。
  2. 文件开头的字节必须与扩展名相符:JPEG 为 FF D8 FF,PNG 为 8 字节的 PNG 签名,GIF 为 GIF87a 或 GIF89a,WEBP 为 RIFF…WEBP。改名为 holiday.jpg 的脚本会在这一步失败。multer 的 req.file.mimetype 完全不使用:它是浏览器发送的 Content-Type,而浏览器可以发送任何值。
  3. 文件夹必须是存储根目录内的有效虚拟路径(见 Node.js 中的图片库文件夹)。
JavaScript · gallery.mjs
// Checks the name and the bytes, stores the file under a new random name, returns its URL.
export async function saveImage(root, folder, originalName, bytes) {
  const ext = extensionOf(originalName);
  const matches = Object.hasOwn(SIGNATURES, ext) && SIGNATURES[ext];
  if (!matches) {
    throw new GalleryError(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
  }
  if (bytes.length > MAX_BYTES) {
    throw new GalleryError(413, "The image is larger than 4,000,000 bytes.");
  }
  if (!matches(bytes)) {
    throw new GalleryError(400, "The file content is not a valid " + ext.slice(1).toUpperCase() + " image.");
  }

  if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
  const dir = await resolveFolder(root, folder);
  const storedName = baseNameOf(originalName) + "-" + randomBytes(4).toString("hex") + ext;
  const file = path.join(dir, storedName);
  await writeFile(file, bytes, { flag: "wx" }); // "wx" fails instead of overwriting
  return publicUrl(file);
}

原始文件名永远不会直接成为保存的文件名。它只保留 a-z、0-9 和 -,截断为 40 个字符,再加上八个随机十六进制字符。扩展名取自允许列表,并转为小写。以 "wx" 标志写入,意味着已有文件永远不会被替换。

这些检查无法证明什么

签名检查只能说明文件的开头是什么,而不能说明后面是什么。我们上传了一个名为 polyglot.jpg 的文件,它以 JPEG 字节 FF D8 FF E0 开头,后面接着 <html><script>alert(1)</script>。它被接受了。这在意料之中:要证明一个文件是完整有效的图片,就必须用 sharp 之类的图片库对它进行解码;如果要清除其中隐藏的内容,还需要重新编码。示例没有这样做。让这种文件变得无害的,是文件的提供方式。

如何提供文件

JavaScript · server.mjs
// Uploaded images. Only image files inside uploads/ are served (isServableUpload), so a
// stray .html or .svg, or a link to another folder, is never served from your site.
// dotfiles "ignore" answers 404 for .files and anything inside a .folder (Express 5's
// default, written out because Express 4 served files inside .folders).
// nosniff stops the browser from guessing a content type other than the image's own.
app.use(
  "/uploads",
  async (req, res, next) => ((await isServableUpload(req.path)) ? next() : res.status(404).end()),
  express.static(UPLOADS_DIR, {
    dotfiles: "ignore",
    index: false,
    setHeaders: (res) => res.setHeader("X-Content-Type-Options", "nosniff"),
  }),
);

// The editor, the shared browser file and the demo page.
if (EDITOR_DIR) app.use("/richtexteditor", express.static(EDITOR_DIR));
app.get("/rte-files.js", (req, res) => res.sendFile(RTE_FILES_JS));
app.use(express.static(path.join(import.meta.dirname, "public")));
  • isServableUpload() 只放行真实路径位于 uploads/ 内的图片文件名。我们手动在 uploads/ 中放了一个 stray.html,它得到 404;指向外部文件夹的链接也得到 404。
  • dotfiles: "ignore" 覆盖了上述检查遗漏的情况:点号开头的文件夹中的文件,例如 .secret/a.jpg。它得到 404。Express 5 默认就是这样处理的。在我们的测试中,Express 4.22 以 200 提供了 uploads/.secret/a.txt,所以示例显式写出了这个选项。
  • X-Content-Type-Options: nosniff 告诉浏览器沿用服务器发送的 Content-Type。上面的 polyglot 文件返回时带有 Content-Type: image/jpeg 和 nosniff,因此浏览器把它当作图片处理,永远不会运行其中的脚本。

示例留给您处理的部分

  • 谁可以上传。示例没有登录功能。服务器默认监听 127.0.0.1(可通过 HOST 更改),因此在试用期间,本机以外的任何地方都无法访问它。Node.js 中每个用户的图片文件夹介绍了登录功能的接入位置。
  • CSRF。Express 没有为这两个 POST 路由提供内置保护。如果您的登录使用 Cookie,其他站点就可以让已登录用户的浏览器向这些路由发送 POST。请像 Next.js 示例那样检查 Origin 标头,或者使用 CSRF 令牌。
  • 配额。没有任何机制能阻止某个用户每次 4 MB 地把磁盘填满。

测试

用 npm start 启动本指南的示例项目,然后发送本页提到的文件:

Shell
printf '<?php echo 1; ?>' > fake.jpg
curl -F "file=@fake.jpg" http://localhost:5101/rte/upload
{"error":"The file content is not a valid JPG image."}

curl -F "file=@photo.jpg;filename=x.svg" http://localhost:5101/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

curl -F "file=@photo.jpg" http://localhost:5101/rte/upload
{"url":"/uploads/photo-05a3e02b.jpg"}

curl -I http://localhost:5101/uploads/photo-05a3e02b.jpg
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Content-Type: image/jpeg

前两个响应是 HTTP 400。在图片库对话框中,同一个伪造文件会显示 "Upload failed for fake.jpg."。对话框使用的是自己的文本,而不是服务器返回的消息。

其他平台的同一指南

Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5 · Web Forms

延伸阅读