亚马逊AWS官方博客

Amazon CloudFront 签名 URL 遇上归因短链:403 原因与解法

摘要:用归因短链分发 CloudFront 签名 URL 时,短链追加的归因参数会让签名校验失败,下载返回 403。本文分析 canned policy 与 custom policy 的校验差异,给出改动最小的解法。


一、概述

使用 AppsFlyer OneLink、Adjust、Branch 等归因短链分发 Amazon CloudFront 签名 URL 时,短链可能会在跳转时追加归因参数,导致签名校验失败、下载返回 403。

本文分析 CloudFront 签名 URL 的校验机制,并给出改动最小的解决方案:将签发方式从 canned policy 改为 custom policy,Resource 写成「https://<域名>/<精确路径>?*」(注意是「路径?*」,不是「路径*」)。

二、背景

客户用 Amazon CloudFront 签名 URL 分发 Android APK,用签名防止安装包被盗链,并让下载链接限时有效。市场团队用 AppsFlyer OneLink 把下载链接包成短链,投放给各个渠道。测试时点击短链后下载 403,直接打开签名 URL 却一切正常。

三、为什么追加参数会 403

CloudFront 签名保护的不只是文件路径,而是整个 base URL:协议 + 域名 + 路径 + 你自己的 query 参数。只有 Expires、Signature、Key-Pair-Id、Policy、Hash-Algorithm 这几个保留参数不算在内。官方文档写得很明确:

If you add a query string to a signed URL after signing it, the URL returns an HTTP 403 status.

— Use signed URLs

两种 policy 的校验方式不一样,这是解法的关键:

  • Canned policy(默认使用 SHA-1 时,URL 带 Expires/Signature/Key-Pair-Id;使用 SHA-256 时还会带 Hash-Algorithm):Resource 由 CloudFront 根据请求 URL(路径加上除保留参数外的全部 query)现场拼出来,再做密码学校验。签发方没有地方写通配符,URL 多一个字节都会校验失败。
  • Custom policy(URL 带 Policy 参数):policy 原文跟着 URL 走。CloudFront 先校验 policy 和 signature 是否匹配,再用 policy 里的 Resource 去匹配实际请求 URL,而 Resource 支持通配符。

换成 Amazon S3 预签名 URL 也解决不了:SigV4 的 canonical query string 包含请求里的全部 query 参数,追加参数同样会导致签名不匹配(规则见 Authenticating Requests: Using Query Parameters (AWS Signature Version 4))。

3.1 如何确认是追加参数导致的

  1. 先确认短链服务的实际跳转行为。有些短链服务仅做跳转,通常不会主动追加参数;归因短链可能根据配置追加参数。无论使用哪种服务,都应以实际最终 URL 的逐字符对比结果为准。
  2. 用与客户端一致的 GET 请求追踪跳转,拿到最终 URL,和原始签名 URL 逐字符对比,看是否多了参数、混入了空格或换行、少了参数。例如:curl -sS -L -r 0-0 -A “<Android 设备的 User-Agent>” -o /dev/null -w “%{url_effective}\n” “<短链>”。
  3. 不要只凭错误码判断原因。签名过期、多了参数、路径不匹配、签名对不上,返回的都是 403 AccessDenied,响应体分不出具体原因(参考 How do I troubleshoot “403 Access Denied” errors related to a signed URL or signed cookies in CloudFront?)。看到 AccessDenied,先解码 Expires 或 Policy 确认链接没有过期,再做第 2 步的比对。

四、推荐方案:custom policy + ?*

把签发逻辑从 canned policy 改成 custom policy,Resource 的路径保持精确,只在末尾加 ?*,例如 “Resource”: “https://dxxxx.cloudfront.net/downloads/app.apk?*”。

? 是 query string 的分隔符,后面的 * 匹配任意 query 内容。归因短链追加任意非 CloudFront 保留的 query 参数都能通过,路径仍然锁死在这一个文件上。

放开 query 段不会影响归因:归因参数由归因平台和 App 内的 SDK 消费,下载源站无需处理这些参数。CloudFront 仍会在签名匹配、缓存键和源请求转发阶段处理 query string,因此需要通过 cache policy 和 origin request policy 控制其缓存和转发行为。

POC 实测结果如下。实验条件:Amazon S3 源站 + OAC,缓存策略为托管策略 CachingOptimized,未配置 origin request policy;同一组 OneLink 归因参数,用 curl -s -o /dev/null -w “%{http_code}” “<签名 URL>&pid=test” 这类命令发 GET 请求。

签发方式 不加参数 加 1 个参数 加全部归因参数
Canned policy 200 403 403
Custom policy,Resource 精确匹配 200 403 403
Custom policy,Resource = 「路径?*」 200 200 200

其中「路径?*」在不加参数时也返回 200。官方文档没有明说 ?* 能否匹配不带 query 的请求,这次实测证明可以,所以同一条签名 URL 加不加归因参数都能用。

4.1 最容易踩的坑:「路径?*」和「路径*」不是一回事

custom policy 的通配规则里有一条例外:

A trailing asterisk in the path section implies an asterisk in the query string section. For example, http://example.com/hello* is equivalent to http://example.com/hello*\?*.

— Create a signed URL using a custom policy

引文里的反斜杠是文档用来标记分段的写法,Resource 里实际写 ?*,不要写成 \?*。

所以如果写成 …/app.apk*,query 确实放开了,但路径也一起被通配了:app.apk.bak、app.apk/anything 都能用这条签名访问,权限被意外放大。正确写法是 …/app.apk?*。

4.2 签发代码(Python,botocore 自带的 CloudFrontSigner)

import datetime
from botocore.signers import CloudFrontSigner
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
CF_DOMAIN = "dxxxx.cloudfront.net"
KEY_PAIR_ID = "KXXXXXXXXXXXXX"          # CloudFront 公钥 ID
PRIVATE_KEY_PEM = b"..."                # 建议从 AWS Secrets Manager 读取,不要硬编码
_private_key = serialization.load_pem_private_key(PRIVATE_KEY_PEM, password=None)
def _rsa_signer(message: bytes) -> bytes:
    # CloudFront 默认使用 RSA-SHA1
    return _private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())
signer = CloudFrontSigner(KEY_PAIR_ID, _rsa_signer)
def make_signed_url(object_key: str, ttl_hours: int = 72) -> str:
    # object_key 要和客户端实际请求的路径编码一致。
    # 文件名含空格或中文时,先用 urllib.parse.quote 编码,否则签名和请求可能对不上。
    base_url = f"https://{CF_DOMAIN}/{object_key}"
    expire_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=ttl_hours)
    # 路径精确,只放开 query string 段。
    # 不要写成 f"{base_url}*",那样会把路径也通配掉。
    policy = signer.build_policy(f"{base_url}?*", expire_at)
    # 传入 policy 即生成 custom policy 签名 URL(带 Policy= 参数)
    return signer.generate_presigned_url(base_url, policy=policy)

CloudFront 也支持 SHA-256 签名(URL 里带 Hash-Algorithm=SHA256),默认仍是 SHA-1,用法见官方文档(参考 Amazon CloudFront now supports SHA-256 for signed URLs and signed cookies)。

4.3 代价与配套配置

  • 拿到这条 URL 的人可以在后面追加任意非 CloudFront 保留的 query 参数。源站是否受影响,取决于 CloudFront 会不会把 query string 转发给源站,见下一条。
  • 检查 cache policy 和 origin request policy:确认 query string 既不在 cache key 里,也不转发给源站(例如使用托管缓存策略 CachingOptimized,且不配置转发 query string 的 origin request policy;如果 distribution 仍使用 legacy cache settings,确认 Query string forwarding and caching 设为 None)(参考 Control origin requests with a policy、Use managed cache policies)。这样做有两个好处:
    • 缓存命中率:如果 query string 进入 cache key,每个渠道的归因参数组合都会成为一个独立的缓存对象。对 APK 这类大文件,命中率下降会带来大量回源流量。
    • 源站安全:不转发时,Amazon S3 收不到追加的参数,行为与原来一致。如果转发,S3 会处理其中能够识别的参数。例如,拿到 URL 的人可以追加 response-content-type=text/html,使 S3 将对象响应的 Content-Type 覆盖为 text/html;追加 versionId 还可能读取旧版本,前提是 bucket policy 授予了 s3:GetObjectVersion(参考 Amazon S3 GetObject API)。因此,在本文的静态文件下载场景中,应关闭 query string 转发。
  • 签名有效期要覆盖投放周期:代码里的 TTL 是 72 小时,但短链往往要投放好几周。签名 URL 过期后短链同样会 403,很容易和参数问题混淆。可以按投放周期设置过期时间,或者定期重新签发,并更新 OneLink 的目标地址。
  • 如果源站本身依赖 query string 返回不同内容(比如图片处理参数、动态接口),放开 query 段的影响需要单独评估。
  • custom policy 的 URL 比 canned 长一些(多了 base64 编码的 policy),APK 下载场景无所谓。
  • 需要改签发端,改动量很小。

五、总结

归因短链追加参数导致签名 URL 返回 403。对静态文件下载场景,把签发方式改为 custom policy,Resource 写成「精确路径?*」,并关掉 query string 转发,即可在不新增基础设施的前提下兼容归因参数。

➡️ 下一步行动:

相关产品:

*前述特定亚马逊云科技生成式人工智能相关的服务目前在亚马逊云科技海外区域可用。亚马逊云科技中国区域相关云服务由西云数据和光环新网运营,具体信息以中国区域官网为准。

本篇作者

瞿天悦

亚马逊云科技解决方案架构师,负责基于亚马逊云科技的云计算方案和架构咨询。


AWS 架构师中心:云端创新的引领者

探索 AWS 架构师中心,获取经实战验证的最佳实践与架构指南,助您高效构建安全、可靠的云上应用