使用S3 Signed URL上传文件时出现SignatureDoesNotMatch错误
问题根因
这套稳定运行3年的代码突发签名错误,直接触发原因是2022年7月AWS对S3预签名URL的校验逻辑做了静默收紧,之前不做强制校验的规则全部改为强校验,结合贴出的错误响应和代码,有三个明确的触发点:
- 后端生成预签名URL时错误使用了
GET方法,但前端上传实际发的是PUT请求,错误内容里<CanonicalRequest>开头第一行就是GET /tmp/xxx,这是旧版Laravel Vapor的已知bug,之前校验宽松时不会触发报错。 - 前端处理请求头的逻辑有漏洞:仅删除了首字母大写的
Host头,没有覆盖小写变体;同时axios/浏览器会自动追加Accept、User-Agent、X-Requested-With等额外请求头,这些头不在签名时的SignedHeaders列表内,直接导致签名计算值不匹配。 - 签名生成时
x-amz-acl参数被拼在查询串中,但前端实际把这个参数放在请求头发送,参数位置不匹配也会触发校验失败。
排查顺序(按修复效率从高到低)
- 先抓浏览器发往S3的真实请求,核对三个核心信息:
- 请求方法是否和预签名时指定的方法一致,当前错误已经明确存在GET/PUT方法不匹配问题,这是最高优先级排查项
- 实际发送的请求头列表,是否比后端返回的签名headers多了额外字段
- 查询串中
X-Amz-SignedHeaders列明的头,是否和实际发送的头完全一致,大小写、数量、值有任何差异都会报错
- 核对Laravel Vapor核心依赖版本:2022年7月AWS更新校验规则后,Laravel官方在
vapor-core 1.30.0版本专门修复了预签名URL请求方法错误的问题,低于这个版本的代码都会踩坑 - 核对AWS PHP SDK版本:当前使用的3.148.3是规则更新前的旧版本,对新的header排序、参数校验逻辑兼容存在缺陷
落地修复方案
- 升级依赖修复后端签名逻辑
直接把laravel/vapor-core升级到1.30.0以上版本,从根源修复签名时请求方法错误的问题;如果暂时无法升级依赖,手动在生成预签名URL的逻辑中强制指定请求方法为PUT。
同步把AWS PHP SDK升级到3.200以上版本,兼容新的签名校验规则。 - 修正前端上传的请求头处理逻辑
原有仅删除大写Host头的逻辑完全不满足新的校验要求,做三个改动即可:- 拿到后端返回的headers后,统一删除所有大小写形式的
Host头 - 显式设置上传时的
Content-Type和后端签名时传入的content_type完全一致,禁止浏览器自动猜测文件类型追加头 - 屏蔽axios自动追加的非签名列表内的公共头,禁止发送不在
SignedHeaders列表内的字段
修正后的核心上传代码参考:
async store(file, options = {}, fileAcl) { const response = await Vue.axios.post( "/vapor/signed-storage-url", { bucket: options.bucket || "", content_type: options.contentType || file.type, expires: options.expires || "", visibility: fileAcl || "", ignore_web_middleware: true }, { baseURL: options.baseURL || null, headers: options.headers || {} } ); let headers = response.data.headers; // 移除所有大小写变体的Host头 Object.keys(headers).forEach(key => { if (key.toLowerCase() === "host") delete headers[key]; }); // 强制指定Content-Type和签名值一致 headers["Content-Type"] = options.contentType || file.type; if (typeof options.progress === "undefined") { options.progress = () => {}; } const newInstance = axios.create(); // 清空axios默认公共头,避免自动追加鉴权、UA等无关头 newInstance.defaults.headers.common = {}; await newInstance.put(response.data.url, file, { headers: headers, transformRequest: [(data, reqHeaders) => { // 仅保留签名列表内的头和Content-Type,其余全部删除 Object.keys(reqHeaders).forEach(key => { const lowerKey = key.toLowerCase(); const isSignedHeader = Object.keys(response.data.headers).some(signKey => signKey.toLowerCase() === lowerKey); if (!isSignedHeader && lowerKey !== "content-type") { delete reqHeaders[key]; } }); return data; }], onUploadProgress: progressEvent => { options.progress(progressEvent.loaded / progressEvent.total); } }); response.data.extension = file.name.split(".").pop(); return response.data; } - 拿到后端返回的headers后,统一删除所有大小写形式的
- 紧急兜底方案
如果需要立刻恢复业务来不及升级依赖,先在S3桶的CORS配置中放开所有允许的头,同时在后端生成预签名URL时,把前端实际上传会带的Content-Type、x-amz-acl等头全部加入签名的SignedHeaders列表,确保签名计算覆盖所有实际发送的字段。
内容的提问来源于stack exchange,提问作者Ali Gajani
相关产品推荐
相关产品推荐

