签名 URL 刷新导致审批失效?别急着删掉所有 query

一个真实而隐蔽的失败是:平台草稿连续两次回读,标题、正文和图片对象都相同,内容 SHA 却不一样。原因不是有人改稿,而是图片 CDN 每次返回了新的过期时间和签名参数。
如果批准令牌绑定原始 Markdown SHA,签名刷新会让刚签发的批准立即失效;如果为了省事删除所有 URL 查询参数,又可能把本应不同的资源合并。这个问题的核心不是"哈希算法怎么选",而是安全边界应该忽略什么、继续绑定什么。
冲突:密码学正确,业务语义却错位
SHA-256 对一个字符的变化都敏感,这是优点。可审批系统关心的可能是"图片对象有没有换",而不是"访问这个对象的短期凭证有没有刷新"。
因此需要同时承认两件事:
- 原始 Markdown 的确变了,不能伪装成字节完全一致。
- 如果只变了受信任图片 URL 的短期签名,发布对象可能仍是同一个。
把两个判断压成一个 SHA,迟早会让存储完整性和业务身份互相打架。
推荐决策:双哈希,而不是放宽一个哈希

我的推荐是保留两条证据链:
python
strict_sha = sha256(raw_markdown)
approval_sha = sha256(canonicalize_signed_images(raw_markdown))
strict_sha 服务于存储、审计和逐字回读;approval_sha 只服务于已经定义过等价关系的批准或幂等边界。批准记录仍绑定文章 ID、revision 和草稿 ID,稳定摘要不能单独成为通行证。
规范化函数也必须是窄能力:只处理 Markdown 图片 URL,只接受 HTTPS,只命中明确的 CDN 域名,并且只删除已经验证会刷新的签名字段。
为什么不能直接 url.split('?')[0]
因为 query 未必都是凭证。尺寸、格式、语言、版本甚至对象选择都可能由 query 决定。下面三种变化必须区分:
signature、expires刷新:在白名单契约下可以视为同一对象。/bucket/a.png变成/bucket/b.png:对象变了,必须拒绝旧批准。?version=1变成?version=2:如果版本影响内容,也必须拒绝。
安全规范化不是"去掉噪声",而是把经过证据确认的易变凭证与资源身份分离。
最小实现与关键测试
python
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
EPHEMERAL = {"signature", "expires", "policy"}
TRUSTED = {"img.example.com"}
def canonical_url(url: str) -> str:
parsed = urlsplit(url)
if parsed.scheme != "https" or parsed.hostname not in TRUSTED:
return url
stable_query = [
(key, value)
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
if key not in EPHEMERAL
]
return urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, urlencode(stable_query), "")
)

测试不能只覆盖"签名刷新后相等",还必须证明边界没有被放宽:
- 域名改变,稳定摘要必须改变。
- 对象路径改变,稳定摘要必须改变。
- 正文任意字符改变,稳定摘要必须改变。
- 保留型 query 改变,稳定摘要必须改变。
- 规范化执行两次与执行一次结果相同。
失败恢复怎么做
即使摘要稳定,发布结果未知时也不能重放批准。正确顺序仍是:先读草稿或目标对象的当前状态;若已经生成文章 ID,就验证审核页或公开页;只有明确未提交,才基于当前 revision 重新批准。
这能防止两个相反的问题:签名刷新造成假冲突,以及网络超时造成重复提交。
收束
内容指纹不是纯技术细节,而是业务身份的可执行定义。设计它时最该问的不是"用什么哈希",而是"哪些变化代表换了对象,哪些只是访问凭证刷新"。
一个可靠答案通常是双哈希加窄规范化:严格证据不丢,业务批准不被临时签名误伤,任何域名、对象路径和正文变化仍然 fail closed。
发布前门禁
- 重点呈现方案冲突与取舍
- 保留严格 SHA 与稳定 SHA 两套证据
- 对域名、对象路径和正文变化保持拒绝
- 未把单个平台现象夸大为所有 CDN 的统一规则