首页 / 文章

从上传到URL:在Express中安全存储与提供用户文件

了解 Express 应用程序应将上传的文件存储在何处,express.static 如何将文件夹映射到 URL,以及哪些机制能防止用户上传的内容成为安全漏洞。

发布者:REACTAPP.TOP阅读约 7 分钟1589 词

来源: Storing Uploaded Files and Serving Them in Express

用户选择一张头像照片,点击上传,片刻之后它就会出现在用户的页面上。这一简单的交互背后其实涉及三个重要的设计决策:文件实际存储的位置、服务器如何再次找到该文件,以及浏览器最终如何显示它。本指南将针对 Express 应用逐一讲解这三方面的内容,从选择存储位置到通过 URL 提供文件访问,最后还会介绍一些预防措施,避免上传功能成为攻击入口。

在磁盘上为上传文件指定存储位置

在文件能够被再次提供之前,必须先将其写入某个地方。在最简单的设置中,这个位置就是服务器自身文件系统中的一个文件夹,通常命名为 uploads,并将其放置在项目根目录中、源代码旁边,而非源代码内部:

project-root/
  uploads/
    avatars/
      user-42.png
      user-91.png
    documents/
      invoice-108.pdf
  src/
    app.js

按用途对文件进行分类,例如 avatars/ 和 documents/,能很快带来好处。随着文件数量增加,这种方式能让目录列表更易于管理,便于针对不同类别实施清理和备份策略,而且如后文所述,还能生成易于预测的URL。如果将所有类型的文件都放在同一个扁平文件夹中,早在出现性能问题之前就已经难以合理管理了。

在大多数应用中,数据库仅存储文件的引用信息,比如相对路径 avatars/user-42.png,而非文件本身或完整URL。存储相对路径意味着日后即使需要更换域名或切换到不同的存储后端,也无需重写每一行数据。

本地磁盘或外部对象存储

将文件保存在应用服务器上

如上图所示,直接将文件写入服务器磁盘无需额外基础设施。这种方式非常适合本地开发、原型设计,以及上传量较小且可预测的内部工具和应用程序。

将文件移至存储服务

生产环境中的系统通常会将上传任务交给专用的对象存储服务,例如 Amazon S3、Google Cloud Storage 或类似的服务,从而避免让应用服务器的磁盘承担相关任务。

选择的重要性

  • 磁盘容量可能较小且使用寿命较短。许多托管平台在每次部署时都会重新创建或替换实例,因此仅存在于本地磁盘上的文件可能在下次发布时就消失不见。
  • 多个实例不会共享磁盘。一旦进行水平扩展,保存在某个实例上的文件对其他实例来说是不可见的。集中式存储能让所有实例看到相同的数据。
  • 对象存储正是为这类需求而设计的。其核心功能在于数据持久性、复制能力、备份功能以及大规模场景下的高效传输,而通用应用服务器则侧重于运行代码的优化。
  • 这并不意味着本地存储就是错误的。对于学习项目或规模极小的应用而言,它确实是合理的首选方案;而且下面提到的这些做法(有序的路径、可预测的URL、严格的验证)在日后迁移到外部存储时依然适用。将存储相关代码封装在包含save和getUrl函数的小模块中,能显著降低迁移的难度。

    让 express.static 服务该文件夹

    文件虽已保存,浏览器仍需一种方式来获取它。Express 提供了专门的静态文件中间件来实现这一功能:

    const express = require("express");
    const app = express();
    
    app.use("/uploads", express.static("uploads"));
    

    这行代码将 uploads 目录挂载在 /uploads URL 前缀下。对于任何路径以该前缀开头的请求,Express 会在该文件夹内的对应位置查找文件,如果存在,则以合适的内容类型和缓存头信息将其流式返回。无需为每个文件编写处理程序。

    这与普通的 API 路由思维模式不同。无需编写加载数据并构建响应的逻辑,只需根据路径直接发布整个目录。由此可得出两个实际注意事项:

    • 文件夹路径是相对于 Node 进程启动的目录来确定的,而非代码所在的文件。使用绝对路径,比如通过 path.join(__dirname, „..“, „uploads“) 生成的路径,可以在应用从不同工作目录启动时避免意外情况。
    • 放入该文件夹的任何文件都会变为公开可访问。只需挂载那些需要公开的目录即可。

    如何将保存的文件转换为 URL

    一旦设置了静态文件服务,磁盘上的路径与 URL 中的路径就会一一对应:

    File on disk:     uploads/avatars/user-42.png
    Accessible at:    https://yourapp.com/uploads/avatars/user-42.png
    

    由于这种一对一的映射关系,前端可以直接引用该文件,例如在图片标签中使用,而无需专门的 API 接口来下载文件内容:

    <img src="https://yourapp.com/uploads/avatars/user-42.png" alt="User avatar" />
    

    这就是第一部分中提到的文件夹结构发挥作用的地方。你在磁盘上选择的任何布局都会直接体现在客户端通过URL看到的界面中,因此整洁且一致的目录结构能带来同样整洁且一致的链接。此外,这样还可以根据数据库中存储的相对路径来构建URL,从而将域名和前缀统一保存在同一个配置值中。

    用户上传内容的安全防护措施

    上传功能往往是被滥用的高发目标,所以“上传功能能正常使用”仅仅是个起点。以下各项措施分别针对不同的安全漏洞。

    确认文件的真实类型

    文件扩展名只是客户端自行选择的文本,没有任何实际意义。应检查真实的文件类型,最好通过读取文件的开头字节来判断,而非依赖所声明的MIME类型,且只接受该功能所需的格式。例如,头像接口应仅接收图片,不得接受其他类型的内容。SVG和HTML这类格式需要格外小心,因为浏览器可能会执行其中嵌入的脚本。

    限制文件大小

    为每次上传设置最大尺寸限制。如果没有限制,无论是无意还是故意上传的超大文件都可能占满磁盘空间或耗尽带宽。应在解析请求时即实施限制,这样服务器就能提前停止读取,而不会先接收整个文件内容。

    自行生成文件名

    绝不要使用客户端提供的名称来保存文件。该名称可能包含异常字符或诸如 ../../config.js 这样的路径穿越序列,其目的在于绕过上传目录的限制。应在服务器上创建一个新名称,例如随机标识符加上经过验证的文件扩展名。这样一来还能避免两个用户都上传 photo.jpg 时互相覆盖文件的情况。

    将上传文件夹隔离起来

    应将上传的文件存储在与源代码及配置明显分开的目录中,并确保静态中间件仅暴露该目录。如果误将项目根目录挂载出来,那么任何猜出路径的人都能获取到环境文件和源代码。

    考虑由哪个地址提供这些文件

    从与主应用相同域提供用户上传的内容可能会引发跨站脚本等问题,因为恶意文件会以您网站的源地址身份运行。因此,许多生产系统会将上传内容存储在外部存储或独立的子域名中。通过发送 X-Content-Type-Options: nosniff 头部信息,以及对于不应以内联方式显示的下载内容发送 Content-Disposition: attachment 头部信息,可以进一步降低风险。

    常见问题

    每个项目都需要使用 S3 或类似服务吗?

    并非必须。对于小型项目或学习用途,本地磁盘更为简单且完全足够。只有当您将应用部署在具有临时文件系统的平台上,或需要让多个实例访问相同文件时,外部存储才变得重要。

    express.static 需要额外的包吗?

    不,这是 Express 内置的功能。解析 multipart 表单上传的文件通常需要像 Multer 这样的库,但提供已保存的文件则无需。

    保留原始文件名可以吗?

    最好不要。使用服务器生成的名称可以避免奇怪字符、相同名称的冲突,以及通过精心设计的文件名进行的攻击。如果用户需要查看原始名称,应将其作为元数据存储在数据库中。

    能否限制对上传文件的访问?

    可以,但仅靠 express.static 是做不到的,因为它会将文件交给任何知道其 URL 的人。对于私有文件,需要专门的路由,在流式传输文件之前进行身份验证和授权检查;如果是使用外部存储,则应为获准访问的用户生成有效期短的链接。

    总结

    一个完善的上传功能能够依次解答四个问题。文件应存储在哪里:选择简单的服务器磁盘存储,还是选择能在部署和扩展过程中保持不变的对象存储?应用日后如何定位这些文件:是通过有序的文件夹结构以及存储在数据库中的相对路径?浏览器将如何获取这些文件:是通过映射到明确URL前缀的express.static,还是通过专为私有文件设置的受保护路由?又是什么措施保障文件安全:类型验证、大小限制、服务器生成的文件名,以及范围受限且最好独立的文件服务地址。解决了这些问题,其余功能实现起来就相对简单了。