💡 完整的工作示例可在 GitHub 上获取:
skip-external-resources-when-signing-dotnet

介绍

Word 文档可以包含一个不在文件中的图片。文档保存了一个地址,任何打开它的程序都会去获取该地址。在桌面上这是一项功能——当源图片更新时,文档中的图片也会更新。而在接受上传的服务器上,这意味着发送文件给你的人员决定了你的基础设施会请求哪些 URL。

安全文档加载是 GroupDocs.Signature 在 .NET 中的行为,它会拒绝发起这些请求。从 26.9 版本起,LoadOptions.SkipExternalResources 默认值为 true。本文将对同一文档的三种加载模式进行比较,展示如何只允许一个主机而不允许全部主机,并说明为何对不受信任的文件进行签名根本不需要网络访问。

为什么这比听起来更重要

该攻击有一个名称——服务器端请求伪造(SSRF)——并且有三种具体形态。

  • 一个内部地址在互联网不可达,但在你的服务器上可达,因此精心构造的文档可以让你的服务去获取 http://169.254.169.254/ 或本地主机上的管理员端点,并根据你对结果的处理泄露信息。
  • 文档中的 UNC 路径可以促使 Windows 主机向外部进行身份验证,将凭据交给攻击者控制的服务器。
  • 指向永不响应的主机的链接会使加载线程一直阻塞直至超时,这是一种耗尽工作池的廉价方式。

我原本以为这只是理论上的担忧,直到我看到一个测试文档通过一个根本不该发起外部请求的服务拉取图片。此过程并不需要文档库本身的漏洞。遵循链接是格式的要求;关键在于你的服务器是否应该满足这个请求。

方法 1 —— 新的默认行为

不使用任何 LoadOptions:

using var signature = new Signature(sourcePath);
return SavePagePreview(signature, previewPath);

不会进行任何请求。预览会在链接图片的位置显示一个空占位符,生成的 PNG 文件也会比原本更小。文件大小的差异是最直观的证据,表明没有请求离开机器。

哪些特性算作外部资源?链接图片(而非嵌入的图片)、INCLUDEPICTURE 字段、演示文稿和电子表格中的链接图片,以及 SVG 引用的图像和样式表。嵌入的内容不受影响——它已经在文件内部。

方法 2 —— 白名单单个地址

大量文档会链接到合法的资源:公司 CDN、内部图片服务器、模板库。只允许这些,而不允许其他:

var loadOptions = new LoadOptions
{
    WhitelistedResources = new List<string> { trustedAddress }
};

using var signature = new Signature(sourcePath, loadOptions);

匹配规则值得注意。它对资源地址进行不区分大小写的子字符串匹配,这意味着一个短片段可能很危险:github 同时匹配 github.attacker.example/payload.png 和你真正想要的主机。请使用完整的 scheme、host 和 path——示例中白名单的是 raw.githubusercontent.com/groupdocs-signature/。

方法 3 —— 允许所有

26.9 之前的行为仍然可用:

var loadOptions = new LoadOptions { SkipExternalResources = false };

对你自己的应用生成的文档来说是合理的。需要提醒的一点是:已废弃的 LoadExternalResources 属性极性相反,因此 SkipExternalResources = false 实际上等同于 LoadExternalResources = true。如果直接把旧属性的值复制过去,就会在不报错的情况下把安全姿态翻转。

三种模式对比:何时使用哪种

模式 适用场景 主要优势 限制
默认(跳过) 用户上传、电子邮件、合作伙伴文件 不会发起任何外部请求 链接图片会显示为占位符
白名单 链接到你拥有的主机的文档 保持合法链接正常工作 子字符串匹配需要足够长且唯一的片段
全部允许 由你自己的系统生成的文件 预览效果与原始完全一致 恢复了默认情况下已移除的 SSRF 风险

签名时需要这些资源吗?

不需要,这也是实际收益所在。使用默认加载设置应用 QR 码签名时,加载、签名或保存文档的过程中不会请求任何外部资源:

var options = new QrCodeSignOptions("Approved by GroupDocs.Signature")
{
    EncodeType = QrCodeTypes.QR,
    Left = 400,
    Top = 50,
    Width = 120,
    Height = 120
};

SignResult result = signature.Sign(outputPath, options);

签名后的输出仍保留原来的链接,因此以后打开文档的用户仍会在自己的机器上解析该图片。跳过是服务器端的策略,而不是对文档本身的编辑——这正是它在代他人处理文件时安全的原因。

升级后会有什么变化

对大多数服务而言,表面上看不到任何变化,这一点值得明确说明,因为安全默认行为的改变如果影响到所有地方,升级审查时很可能会被阻止。唯一例外是:预览或缩略图原本会显示链接图片,现在会显示占位符;这正是默认行为发挥作用的表现。如果该主机是你的,解决办法是添加白名单条目;如果文档来自外部,则接受占位符即可。

最直接的检查方式就是示例中使用的:在三种模式下渲染同一文档并比较输出文件大小。如果默认模式和白名单模式的预览大小相同,说明两种情况下都没有进行请求——这通常意味着主机对该机器不可达,而不是白名单失效,示例会打印相应提示。

预览助手(因为不太直观)

上面三种模式中有两种会调用一个小助手,值得展示,因为 PreviewOptions 并不接受文件路径:

var previewOptions = new PreviewOptions(
    pageData => File.Create(previewPath),
    (pageData, pageStream) => pageStream.Dispose())
{
    PreviewFormat = PreviewOptions.PreviewFormats.PNG
};

signature.GeneratePreview(previewOptions);

它接受两个流工厂——一个用于为每页创建流,一个用于释放流。示例文档只有一页,所以只会写入一个文件;如果是多页输入,请在文件名中加入页码,否则每页都会覆盖前一页。

最佳实践

  • 将任何非自己生成的内容视为不可信,包括来自安全姿态良好的合作伙伴的文件。
  • 让白名单片段足够长以消除歧义,并在 CDN 变更时进行审查。
  • 切勿将 SkipExternalResources 的值直接设为之前赋给 LoadExternalResources 的值。
  • 通过输出文件大小来验证,而不是仅凭设置;看似正确的配置和实际未发起请求是两回事。

SVG 的处理

特别说明一下,因为 SVG 既是常见的上传格式,也是常见的 SSRF 向量。SVG 可以通过 URL 引用图像和样式表,这些引用同样属于外部资源——默认会被跳过、可以加入白名单、也可以恢复。接受 SVG 头像或徽标并在服务器端渲染的服务正是此更改要防护的场景。

如果你的流水线接受用户上传的 SVG,默认设置就是你想要的;当你的模板需要从你自己运行的主机拉取共享样式表时,可使用白名单。

结论

默认行为已改为需要显式决定才会进行风险操作,而安全的默认则无需任何额外配置。对不可信输入保持默认,针对你自己的主机进行细粒度白名单,并记住签名本身从未需要网络。将示例对你的任意文档运行一次,只需一分钟,就能通过三个文件大小准确告知你的服务到底抓取了哪些内容。

其他资源