💡 完整可运行示例可在 GitHub 上获取: load-untrusted-documents-safely-python
过去的做法很痛苦
你写了三行代码来渲染上传文档的缩略图。它们是这样写的,看起来也很正常:
with signature.Signature(upload_path) as sign:
save_page_preview(sign, thumbnail_path)
在 GroupDocs.Signature 26.9 之前,这几行代码会获取文档指向的每一个地址。Word 文件可以保存它本身不包含的图片——文件中存储的是一个 URL,任何打开它的程序都会下载该 URL。 在桌面上这是一项功能。 在接受上传的服务器上,这意味着发送文件的人决定了你的基础设施会请求哪些地址。
这种攻击有一个名称,服务器端请求伪造(SSRF),并且有三种常见形态。一个内部地址虽然无法从互联网访问,但可以从你的服务器访问,所以精心构造的文档可以让你的服务去获取 http://169.254.169.254/ 或本地主机上的管理端点。UNC 路径可以促使 Windows 主机向外部进行身份验证,从而把凭据交给攻击者控制的服务器。还有一种情况是链接到根本不响应的主机,这会让加载线程一直等待直至超时,是一种耗尽工作线程池的廉价方式,即使文档看起来毫无害。
这并不是文档库的 bug。遵循链接是格式本身的要求。令人不安的是,这种默认行为在代码审查中几乎不会被标记。
有更好的方法
安全的文档加载是 GroupDocs.Signature 在 Python 中的行为,它会拒绝发起这些请求。从 26.9 版本起,LoadOptions.skip_external_resources 的默认值为 True,因此相同的三行代码现在不再请求任何资源,而是在链接图片的位置渲染一个占位符。
这是一项默认设置的改变,而不是新功能——该属性早已存在。26.9 所改变的是当代码未显式指定时它的取值,这也是大多数服务唯一会使用的设置。
新方式:三种加载模式
步骤 1 - 对所有不可信的内容保持默认
不使用任何 LoadOptions:
with signature.Signature(source_path) as sign:
return save_page_preview(sign, preview_path)
不发起任何请求。预览会比原本的更小,而这个大小差异是最直观的证据,表明没有请求离开机器。
步骤 2 - 将你实际拥有的主机列入白名单
很多文档会链接到合法的资源:公司 CDN、内部图片服务器、模板库。只允许这些,其他全部拒绝:
load_options = LoadOptions()
load_options.whitelisted_resources = [trusted_address]
with signature.Signature(source_path, load_options) as sign:
return save_page_preview(sign, preview_path)
匹配规则值得注意。它对资源地址进行不区分大小写的子串匹配,这会让短片段变得危险:github 同样匹配 github.attacker.example/payload.png,正如它匹配你期望的主机。请使用 scheme、host 和 path——本示例将 raw.githubusercontent.com/groupdocs-signature/ 加入白名单。
步骤 3 - 有意地允许所有内容
仍可使用 26.9 之前的行为:
load_options = LoadOptions()
load_options.skip_external_resources = False
适用于你自己的应用生成的文档。需要注意的陷阱是:已废弃的 load_external_resources 属性极性相反,因此 skip_external_resources = False 相当于 load_external_resources = True。如果直接把旧属性的值拷贝过来,会在不报错的情况下把安全姿态翻转。
对比:之前 vs. 之后
相同文档、相同代码路径、三种加载策略。以下是示例 Result/ 文件夹中提交的文件大小,可直接核对而非盲目信任:
| 加载模式 | 预览大小 | 外发请求 |
|---|---|---|
| 默认(26.9 及以后) | 16,435 bytes | 无 |
| 白名单主机 | 51,738 bytes | 一个,指向允许的地址 |
| 所有资源(26.9 前默认) | 51,738 bytes | 每个链接资源一个 |
链接的图片大小为 35,303 bytes,正是上述差异的来源。只有把这两个数字并排对比,我才相信该设置的有效性,也建议你如此操作:读取属性的返回值只能告诉你配置了什么,而不是过程实际做了什么。
什么算作外部资源?
范围比人们想象的要窄,这也是升级通常不会引起波澜的原因。外部资源包括:链接的图片(而非嵌入的图片)、INCLUDEPICTURE 字段、演示文稿和电子表格中的链接图片,以及 SVG 引用的图片和样式表。嵌入的内容不受影响,因为它已经在文件内部,无需发起请求即可渲染。
这一区别构成了完整的安全边界。文档只有在存储地址而非字节时才会让你的服务器去请求外部资源,因此评估任意语料库时,只需统计有多少文件是“链接而非嵌入”。如果全部都是嵌入的,新默认不会产生任何额外开销,直接升级即可,无需进一步阅读。
实际案例:对上传文件进行签名
默认更改的典型场景。外部传来一个文档,你需要在其上添加签名:
with signature.Signature(source_path) as sign:
options = QrCodeSignOptions("Approved by GroupDocs.Signature")
options.encode_type = QrCodeTypes.QR
options.left = 400
options.top = 50
options.width = 120
options.height = 120
result = sign.sign(output_path, options)
在文档加载、签名或保存的过程中不会请求任何外部资源。签名后的输出仍保留原链接,因此以后在 Word 中打开时,图片仍会在用户本机上解析。跳过外部资源是服务器端的策略,而不是对文档本身的编辑——正是这点让它在代他人处理文件时安全可靠。
升级时还有哪些变化?
对大多数服务而言,没有可见的变化,这一点需要明确说明,因为如果安全默认会在所有地方改变行为,升级审查几乎不可能通过。签名、验证和搜索功能保持不变。唯一例外是预览:以前会显示链接的图片,现在会显示占位符——这正是预期的行为。如果链接的主机是你的,记得将其加入白名单;如果不是,则保持阻止。
另需单独说明的是 SVG。SVG 可以通过 URL 引用图片和样式表,这些引用同样属于外部资源,且 SVG 是常见的上传格式和 SSRF 向量。接受 SVG 头像并在服务器端渲染的服务正是此更改所要防护的典型场景。
一个 Python 细节:预览如何写入
PreviewOptions 接受两个流工厂而不是文件路径,普通的 Python 可调用对象即可满足需求:
def create_page_stream(page_data):
return open(preview_path, "wb")
def release_page_stream(page_data, page_stream):
page_stream.close()
preview_options = PreviewOptions(create_page_stream, release_page_stream)
preview_options.preview_format = PreviewOptions.PreviewFormats.PNG
sign.generate_preview(preview_options)
一个工厂为每页创建流,另一个负责释放。示例文档只有一页,所以只会写入一个文件;如果是多页输入,请在文件名中加入页码,否则每页都会覆盖前一页。
结论
默认已翻转,风险行为需要显式决定,而安全行为则保持不变。对不可信输入保持默认,对你自己的主机进行严格白名单过滤,并记住签名过程根本不需要网络。
如果想要比文件大小更强的检查方式,可以让测试文档指向你控制的主机,并在预览运行时观察该主机的访问日志。文件大小只能告诉你是否收到了字节;访问日志则能告诉你是否真的发起了请求,这两者在关键情况下往往不一致——例如白名单主机恰好不可达时,输出文件大小看起来与被阻止的情况完全相同。
将示例对你的任意文档运行一次,大约需要一分钟,便能通过三个文件大小,精准了解你的服务在代表文件发送者时到底请求了哪些资源。