S3 预签名 URL:解决 Unicode 文件名上传 403 错误
2025-04-18 08:04:28
解决 S3 预签名 URL 上传 Unicode 文件名 403 Forbidden 难题
问题来了:上传带 Unicode 字符的文件名,S3 咋就 403 了?
碰到了个挺诡异的 S3 问题。用后端生成的预签名 URL (presigned URL) 来上传文件,一切正常。可一旦原始文件名里包含了 Unicode 字符,比如像 ä
、ü
或者中文、日文这类非 ASCII 字符,前端 fetch
请求直接就报 403 Forbidden,连个具体的错误信息(response body)都没有,调试起来跟猜谜似的。怪就怪在,如果把文件名里的 ä
换成 a
,立马就好了!
看下代码,后端大概是这么生成预签名 URL 的(用 PHP S3 SDK 举例):
<?php
use Aws\S3\S3Client;
use Aws\Credentials\CredentialProvider;
// ... 获取 $userId 和 $filename (来自前端 POST 请求)
// 对 S3 Key 做安全处理,只允许字母数字、下划线、连字符和点
// 注意:这里处理的是 S3 对象键 (Key),不是原始文件名元数据
$safeName = trim(preg_replace('/[^a-z0-9\-_.]/i', '-', $filename), '-');
$key = sprintf('user-documents/%s/%s', $userId, $safeName);
// 准备存入 S3 元数据 (Metadata) 的信息
$metadata = [
'type' => 'USER_DOCUMENT',
'userId' => $userId,
// 把原始的、可能包含 Unicode 的文件名放进去了!
'filename' => $filename,
];
$s3 = new S3Client([
'region' => getenv('AWS_REGION'),
'version' => 'latest',
'credentials' => CredentialProvider::env(),
]);
// 生成 PutObject 命令的预签名 URL
$command = $s3->getCommand('PutObject', [
'Bucket' => getenv('AWS_BUCKET_USER_DATA'),
'Key' => $key,
'Metadata' => $metadata, // 元数据会影响签名
]);
$presignedRequest = $s3->createPresignedRequest($command, '+1 hour');
$uploadUrl = (string) $presignedRequest->getUri();
$response = [
'uploadUrl' => $uploadUrl,
'metadata' => $metadata, // 把元数据也返回给前端,方便设置 Header
];
// ... 返回 $response (JSON) 给前端
前端拿到 URL 和元数据后,就用 fetch
上传文件:
// 假设 file 是 <input type="file"> 里的 File 对象
// response 是从后端获取的 { uploadUrl, metadata }
const file = fileInput.files[0];
const response = await getUploadUrl(file.name); // 后端接口,内部逻辑如上 PHP 示例
try {
await fetch(response.uploadUrl, {
method: 'PUT',
headers: {
// 这里设置了自定义元数据 Header
// AWS 会自动给 'Metadata' 里的 key 加上 'x-amz-meta-' 前缀
'x-amz-meta-type': response.metadata.type,
'x-amz-meta-userid': response.metadata.userId,
// 问题关键!如果 response.metadata.filename 含 Unicode,请求可能失败
'x-amz-meta-filename': response.metadata.filename
},
body: file, // 文件内容作为请求体
});
console.log('文件上传成功!');
} catch (error) {
console.error('文件上传失败:', error); // Unicode 文件名可能导致这里抛出 403 错误
// 打印出的 error 可能类似: Error: File upload failed: 403 Forbidden
}
梳理一下:
- 后端生成 S3 对象的
Key
时做了安全处理 ($safeName
),确保Key
本身符合 AWS 的规范。 - 但是,后端在生成预签名 URL 时,将原始的、未处理的
$filename
包含在了Metadata
里。 - 前端上传时,也把这个原始的
filename
设置到了x-amz-meta-filename
这个 HTTP Header 中。
问题就出在第 2 步和第 3 步的交互上。为啥呢?
刨根问底:为啥 Unicode 文件名会让 S3 翻脸?
这事儿得从 S3 的签名机制(特别是 SigV4)说起。
当你生成一个预签名 URL 时,AWS SDK(或者你自己实现的签名逻辑)会根据你的请求参数(包括 Bucket、Key、HTTP 方法、还有那些要随请求一起发送的 Headers ,比如 Content-Type
、x-amz-meta-*
等)计算出一个签名。这个签名会作为 URL 的一部分(通常是 X-Amz-Signature
查询参数)。
当客户端(比如浏览器里的 fetch
)使用这个预签名 URL 发起请求时,它不仅要发 URL 本身,还必须附带上生成签名时考虑进去的那些 Headers ,并且这些 Headers 的值必须一模一样 。
S3 收到请求后,会做几件事:
- 解析请求 URL 和 Headers。
- 使用与生成预签名 URL 时相同的密钥和算法,根据收到的 Headers 和其他信息,重新计算一次签名 。
- 比较自己算出来的签名和 URL 里带的
X-Amz-Signature
是否一致。
如果签名不匹配,S3 就会认为请求被篡改过或者无效,直接返回 403 Forbidden。
那 Unicode 文件名是怎么搅乱这个过程的呢?
问题出在 HTTP Headers 对非 ASCII 字符的处理 上。标准的 HTTP Header 值按规定(RFC 7230 等)应该只包含可见的 ASCII 字符。虽然现在很多服务器和客户端对 Header 里的 UTF-8 字符有一定程度的容忍,但这并不是一个完全标准化的行为,尤其在涉及到签名校验这种需要精确匹配的场景下,就容易出问题。
当你把包含 Unicode 字符的原始文件名(比如 "测试文件ä.txt"
) 直接塞进 x-amz-meta-filename
Header 里时:
- 后端生成签名时 :AWS SDK 内部处理
Metadata
时,可能对这个 Unicode 字符串做了某种内部表示或编码,然后基于这个表示计算了签名。 - 前端发送请求时 :浏览器(或
fetch
)在发送这个 Header 时,它如何处理这个 Unicode 字符串?是直接按 UTF-8 发送?还是会尝试做某种编码?这可能因浏览器、环境而异。 - S3 校验签名时 :S3 服务器收到请求后,它看到
x-amz-meta-filename
Header 里的值。它会如何解析这个(可能包含非 ASCII 字节)的值?它期望的字节序列,很可能与前端实际发送过来的、或者与后端 SDK 生成签名时内部使用的表示方式,不一致 !
正是这种不一致,导致 S3 重新计算出的签名,跟你预签名 URL 里的那个签名对不上号,最终结果就是 403 Forbidden。 没有 Response Body 是因为 S3 在签名验证失败这种底层安全拒绝时,通常不会返回详细信息。
那个经过 preg_replace
处理的 $safeName
没问题,因为它只影响 S3 Key
,而 Key
通常只包含 ASCII 字符,并且在 URL 路径部分,会被标准地进行 URL 百分号编码,这个过程是明确且健壮的。问题在于 x-amz-meta-filename
这个自定义 Header 的值 。
动手修复:让 Unicode 文件名乖乖上传
明白了原因,解决方案就清晰了:确保后端生成签名时使用的元数据 Header 值,和前端实际发送的 Header 值,以及 S3 期望接收到的 Header 值,三者表示方式完全一致,并且符合 HTTP Header 的规范。
推荐两种主要思路:对 Header 值进行编码,或者干脆不把原始文件名放在 Header 里。
方案一:对元数据 Header 进行 URL 编码 (推荐)
这是最常见也比较推荐的做法。URL 编码(或称百分号编码)就是被设计用来在 URL(包括 Header 值在某些上下文中也可借鉴)中安全地传输任意字符的。它会把非 ASCII 字符或者 URL 中有特殊含义的 ASCII 字符转换成 %XX
的形式,这样整个字符串就只包含安全的 ASCII 字符了。
原理:
- 后端: 在生成预签名 URL 之前 ,对要放入
Metadata
中的原始文件名$filename
进行 URL 编码。这样,AWS SDK 在计算签名时,依据的就是这个已编码 的字符串。 - 前端: 从后端拿到响应后,响应的
metadata.filename
字段里已经是 URL 编码后的字符串 。在fetch
的headers
中设置x-amz-meta-filename
时,直接使用这个已经编码好的字符串 即可。 - S3: S3 收到请求,看到
x-amz-meta-filename
Header 的值是一个纯 ASCII 的 URL 编码字符串。它按照这个值去校验签名,因为与后端生成签名时使用的值完全一致(都是编码后的),签名校验就能通过。
代码修改:
后端 (PHP):
修改 $metadata
的构建部分:
<?php
// ... 前面代码不变 ...
// 对原始文件名进行 URL 编码
$encodedFilename = rawurlencode($filename); // 使用 rawurlencode (RFC 3986)
$metadata = [
'type' => 'USER_DOCUMENT',
'userId' => $userId,
// 把 URL 编码后的文件名放入元数据
'filename' => $encodedFilename, // !!! 修改点 !!!
];
// ... S3Client, getCommand, createPresignedRequest 代码不变 ...
// $command 会使用包含编码后文件名的 $metadata 去生成签名
$response = [
'uploadUrl' => $uploadUrl,
'metadata' => $metadata, // !!! 注意:现在返回给前端的 filename 是编码后的了 !!!
];
// ... 返回 $response ...
- 注意: 这里用了
rawurlencode()
而不是urlencode()
。rawurlencode()
遵循 RFC 3986 标准,会把空格编码成%20
,通常在路径和 Header 中更推荐。urlencode()
则会把空格编码成+
,主要用于application/x-www-form-urlencoded
的表单提交。
前端 (JavaScript):
fetch
部分的代码几乎不用改 ,因为后端已经把编码好的值通过 response.metadata.filename
返回了。
// ... 获取 file 和 response 的代码不变 ...
try {
await fetch(response.uploadUrl, {
method: 'PUT',
headers: {
'x-amz-meta-type': response.metadata.type,
'x-amz-meta-userid': response.metadata.userId,
// 直接使用从后端收到的、已经 URL 编码过的 filename
'x-amz-meta-filename': response.metadata.filename // !!! 无需在此处再编码 !!!
},
body: file,
});
console.log('文件上传成功!');
} catch (error) {
console.error('文件上传失败:', error);
}
后续处理:
当你需要读取这个文件的元数据时(比如通过 HeadObject
或 GetObject
),你拿到的 x-amz-meta-filename
的值将是 URL 编码后的字符串。你的应用程序需要记得对其进行解码,比如在 PHP 中用 rawurldecode()
,在 JavaScript 中用 decodeURIComponent()
。
// 示例:在 PHP 中读取元数据后解码
$objectMetadata = $s3->headObject([...])->toArray();
$originalFilename = isset($objectMetadata['Metadata']['filename'])
? rawurldecode($objectMetadata['Metadata']['filename'])
: null;
进阶使用技巧:
- URL 编码会增加字符串长度,不过对于一般的文件名来说,不太可能超出 S3 元数据的单个 Header 值大小限制(通常是几 KB)。
- 确保你的应用程序所有读取该元数据的地方都知道需要解码。
方案二:对元数据 Header 进行 Base64 编码
Base64 也是一种将二进制数据或任意文本转换成纯 ASCII 字符串的常用方法。它的原理和 URL 编码类似,都是为了解决传输非标准字符的问题。
原理:
基本同 URL 编码方案,只是编码/解码函数不同。后端 Base64 编码,前端直接发送编码后的值,应用程序读取时 Base64 解码。
代码修改:
后端 (PHP):
<?php
// ...
$encodedFilename = base64_encode($filename); // 使用 Base64 编码
$metadata = [
'type' => 'USER_DOCUMENT',
'userId' => $userId,
'filename' => $encodedFilename, // !!! 修改点 !!!
];
// ...
$response = [
'uploadUrl' => $uploadUrl,
'metadata' => $metadata, // 返回 Base64 编码后的文件名
];
// ...
前端 (JavaScript):
同样,直接使用后端返回的已编码字符串。
// ...
await fetch(response.uploadUrl, {
method: 'PUT',
headers: {
// ...其他 headers
'x-amz-meta-filename': response.metadata.filename // !!! 使用 Base64 编码后的值 !!!
},
body: file,
});
// ...
后续处理:
读取元数据后需要用相应的 Base64 解码函数,如 PHP 的 base64_decode()
或 JavaScript 的 atob()
(或者更健壮的库)。
// 示例:PHP 中解码
$objectMetadata = $s3->headObject([...])->toArray();
$originalFilename = isset($objectMetadata['Metadata']['filename'])
? base64_decode($objectMetadata['Metadata']['filename'])
: null;
安全建议:
- 重要提示:Base64 不是加密 !它只是编码,任何人都可以轻易解码。不要用它来传输敏感信息,除非你同时还用了其他加密手段。对于文件名这种非敏感信息,用作传输编码是 OK 的。
进阶使用技巧:
- Base64 编码通常会使数据大小增加约 33%。
- 相比 URL 编码,Base64 编码结果不包含
%
这类可能在某些上下文中仍需特殊处理的字符,结果更 "干净"(只包含字母、数字、+
、/
、=
)。但在 HTTP Header 中,两者都能工作。选择哪个看个人偏好或团队规范。
方案三:放弃在 Header 中传递原始文件名
如果原始文件名并不是非得通过 S3 元数据传递,也可以考虑其他方式存储。
原理:
干脆不把原始文件名放到 x-amz-meta-filename
Header 里。预签名 URL 的生成和前端的上传请求中都不包含这个 Header。签名校验自然就不会因此出错了。
在哪里存储原始文件名?
- 数据库: 在你的应用数据库里,创建一个表或者在现有表中加一列,记录 S3 对象 Key 与原始文件名的对应关系。上传完成后,将
$safeName
(S3 Key) 和原始$filename
存入数据库。之后需要原始文件名时,通过 S3 Key 去数据库查询。 - S3 对象标签 (Tags): S3 对象标签支持 UTF-8 字符,可以用来存储原始文件名。可以在
PutObject
请求中通过Tagging
参数直接设置标签(注意,带标签的预签名 URL 需要额外权限s3:PutObjectTagging
,并且标签本身也要参与签名计算,或者可以上传完成后再单独调用PutObjectTagging
添加标签)。查询标签也需要相应权限。
使用数据库可能是更常见和灵活的方式。
代码修改 (以数据库方案为例):
后端 (PHP):
生成预签名 URL 时,从 $metadata
中移除 filename
。
<?php
// ... 获取 $userId, $filename ...
// ... 计算 $safeName, $key ...
// 不再需要把原始 filename 放进 metadata
$metadata = [
'type' => 'USER_DOCUMENT',
'userId' => $userId,
// 'filename' => $filename, // <<< 移除这一行
];
// ... S3Client, getCommand, createPresignedRequest 代码不变 ...
// $command 的 'Metadata' 参数不再包含 filename
// 后端可以在上传成功的回调或确认逻辑中,
// 将 $key 和原始的 $filename 存储到数据库
// 例如:saveFileInfoToDb($key, $filename, $userId);
$response = [
'uploadUrl' => $uploadUrl,
'metadata' => $metadata, // 不再包含 filename 字段
];
// ... 返回 $response ...
前端 (JavaScript):
fetch
请求中移除 x-amz-meta-filename
Header。
// ... 获取 file 和 response 的代码不变 ...
try {
await fetch(response.uploadUrl, {
method: 'PUT',
headers: {
// 只设置必要的、没有 Unicode 问题的元数据
'x-amz-meta-type': response.metadata.type,
'x-amz-meta-userid': response.metadata.userId,
// 'x-amz-meta-filename': response.metadata.filename // <<< 移除这一行
},
body: file,
});
console.log('文件上传成功!');
// 这里可能需要通知后端上传完成,以便后端记录 Key 和原始文件名的映射
// await notifyBackendUploadComplete(key); // 这里的 key 可能需要从其他途径获得
} catch (error) {
console.error('文件上传失败:', error);
}
后续处理:
需要原始文件名时,你的应用程序得先知道 S3 的 Key ($safeName
),然后去数据库里查对应的原始文件名。
进阶使用技巧:
- 这种方法避免了 Header 编码问题,但也引入了额外的依赖(数据库查询)。
- 需要设计好上传成功后的确认机制,确保 S3 Key 和原始文件名的映射能被正确保存。
- 如果选用 S3 标签方案,要研究下标签的权限、成本和查询方式。
选择哪种方案取决于你的具体需求:
- 如果必须通过 S3 元数据传递原始文件名,方案一(URL 编码) 是最直接、最符合 Web 标准的做法。
- 方案二(Base64 编码) 效果类似,编码结果可能更简洁一点。
- 如果原始文件名可以不跟 S3 对象直接绑定,方案三(数据库存储或其他) 可以彻底绕开 Header 编码问题,但增加了系统其他部分的复杂度。