Node.js 中的安全图片上传
图片上传端点让任何人都能把文件放到您的服务器上,再由您自己的域名提供出去。本页介绍 Express 示例在写入文件之前检查什么、之后如何提供这些文件,以及这些检查无法证明什么,并附上我们用来测试每个部分的请求。
服务器在写入前检查什么
顶部的示意图展示了检查顺序。multer 把表单读入内存(它的限制见Node.js 中的图片上传大小限制)。然后 saveImage() 执行三项检查,全部通过后才会写入磁盘:
// 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)
);
}- 扩展名必须是 .jpg、.jpeg、.png、.gif 或 .webp。这是一个允许列表,因此 .svg、.html 和 .php 会被拒绝,服务器无需逐一了解它们。SVG 是有意排除的:它可以包含脚本。
- 文件开头的字节必须与扩展名相符:JPEG 为
FF D8 FF,PNG 为 8 字节的 PNG 签名,GIF 为GIF87a或GIF89a,WEBP 为RIFF…WEBP。改名为holiday.jpg的脚本会在这一步失败。multer 的req.file.mimetype完全不使用:它是浏览器发送的Content-Type,而浏览器可以发送任何值。 - 文件夹必须是存储根目录内的有效虚拟路径(见 Node.js 中的图片库文件夹)。
// 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 之类的图片库对它进行解码;如果要清除其中隐藏的内容,还需要重新编码。示例没有这样做。让这种文件变得无害的,是文件的提供方式。
如何提供文件
// 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 启动本指南的示例项目,然后发送本页提到的文件:
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