在 GraphQL 中处理文件上传

GraphQL 在设计之初并未考虑文件上传。虽然在技术上可以实现,但这样做需要扩展传输层,并会在安全性和可靠性方面引入多种风险。

本指南解释了为什么通过 GraphQL 上传文件存在问题,并提出了更安全的替代方案。

为什么上传具有挑战性

GraphQL 规范是与传输无关且与序列化无关的(尽管 HTTP 和 JSON 是社区中最常见的组合)。GraphQL 旨在处理来自客户端的相对较小的请求,在设计时并未考虑处理二进制数据。

相比之下,文件上传通常处理二进制数据,如图像和 PDF —— 许多编码(包括 JSON)无法直接处理这些数据。一种选择是在我们的编码中再进行编码(例如在 JSON 中使用 base64 编码的字符串),但这效率低下,且不适合较大的二进制文件,因为它不容易支持流式处理。相反,multipart/form-data 是传输二进制数据的常用选择;但它也有其自身的复杂性。

在 GraphQL 上支持上传通常涉及采用社区惯例,其中最流行的是 GraphQL 多部分请求规范 (GraphQL multipart request specification)。该规范已在许多语言和框架中成功实现,但实现它的用户必须非常小心,以确保不会引入安全性或可靠性问题。

需要注意的风险

重复变量导致的内存耗尽

GraphQL 操作允许同一个变量被多次引用。如果一个文件上传变量被重复使用,底层的流可能会被多次读取或过早耗尽。这可能导致错误行为或内存耗尽。

一种安全的做法是使用可信文档 (Trusted Documents) 或验证规则,以确保每个上传变量仅被引用一次。

操作失败时的流泄漏

GraphQL 分阶段执行:先验证,后执行。如果验证失败或授权检查提前终止了执行,上传的文件流可能永远不会被消耗。如果您的服务器缓冲或保留了这些流,可能会导致内存泄漏。

为了避免这种情况,请确保在请求结束时终止所有流,无论它们是否在解析器 (resolver) 中被消耗。另一种值得考虑的选择是立即将传入的文件写入临时存储,并将引用(如文件名)传递给解析器。确保在请求完成后,无论成功还是失败,都要清理该存储。

跨站请求伪造 (CSRF)

multipart/form-data 在 CORS 规范中被归类为“简单”请求,不会触发预检请求 (preflight check)。如果没有明确的 CSRF 保护,您的 GraphQL 服务器可能会在不知情的情况下接受来自恶意源的上传。

超大或过量的有效载荷

攻击者可能会提交非常大的上传内容,或者在未使用的变量名下包含多余的文件。接受并缓冲这些内容的服务器可能会不堪重负。

强制执行请求大小上限,并拒绝任何未在多部分有效载荷的 map 字段中明确引用的文件。

不可信的文件元数据

文件名、MIME 类型和内容等信息永远不应被信任。为了降低风险:

  • 清理文件名以防止路径遍历或注入问题。
  • 独立于声明的 MIME 类型嗅探文件类型,并拒绝不匹配的文件。
  • 验证文件内容。注意特定格式的漏洞利用,如邮包炸弹 (zip bombs) 或恶意构建的 PDF。

建议:使用签名 URL

最安全且最具扩展性的方法是完全避免通过 GraphQL 上传文件。相反:

  1. 使用 GraphQL mutation 向您的存储提供商(例如 Amazon S3)请求一个签名的上传 URL。
  2. 使用该 URL 直接从客户端上传文件。
  3. 提交第二个 mutation 将上传的文件与您的应用程序数据关联起来(或者使用自动触发的过程,如 Amazon Lambda 来完成同样的操作)。

您应该确保这些文件上传仅保留很短的时间,这样即使攻击者只完成了步骤 1 和 2,也不会耗尽您的存储空间。在处理文件上传(步骤 3)时,应视情况将文件移动到更永久的存储中。

这种做法清晰地分离了职责,保护了您的服务器免受二进制数据处理的影响,并符合现代 Web 架构的最佳实践。

如果您仍选择支持上传

如果您的应用程序确实需要通过 GraphQL 进行文件上传,请谨慎操作。您至少应该:

  • 使用一个维护良好的 GraphQL 多部分请求规范 的实现。
  • 强制执行上传变量仅被引用一次的规则。
  • 将上传内容流式传输到磁盘或云存储——避免在内存中缓冲它们。
  • 确保在请求结束时始终终止流,无论它们是否被消耗。
  • 应用严格的请求大小限制并验证所有字段。
  • 将文件名、类型和内容视为不可信数据。

下一课

授权

了解如何通过类型级和字段级授权模式来保护您的 GraphQL API。

前往下一课 教程