返回

S3 预签名 URL:解决 Unicode 文件名上传 403 错误

php

解决 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
}

梳理一下:

  1. 后端生成 S3 对象的 Key 时做了安全处理 ($safeName),确保 Key 本身符合 AWS 的规范。
  2. 但是,后端在生成预签名 URL 时,将原始的、未处理的 $filename 包含在了 Metadata 里。
  3. 前端上传时,也把这个原始的 filename 设置到了 x-amz-meta-filename 这个 HTTP Header 中。

问题就出在第 2 步和第 3 步的交互上。为啥呢?

刨根问底:为啥 Unicode 文件名会让 S3 翻脸?

这事儿得从 S3 的签名机制(特别是 SigV4)说起。

当你生成一个预签名 URL 时,AWS SDK(或者你自己实现的签名逻辑)会根据你的请求参数(包括 Bucket、Key、HTTP 方法、还有那些要随请求一起发送的 Headers ,比如 Content-Typex-amz-meta-* 等)计算出一个签名。这个签名会作为 URL 的一部分(通常是 X-Amz-Signature 查询参数)。

当客户端(比如浏览器里的 fetch)使用这个预签名 URL 发起请求时,它不仅要发 URL 本身,还必须附带上生成签名时考虑进去的那些 Headers ,并且这些 Headers 的值必须一模一样

S3 收到请求后,会做几件事:

  1. 解析请求 URL 和 Headers。
  2. 使用与生成预签名 URL 时相同的密钥和算法,根据收到的 Headers 和其他信息,重新计算一次签名
  3. 比较自己算出来的签名和 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 字符了。

原理:

  1. 后端: 在生成预签名 URL 之前 ,对要放入 Metadata 中的原始文件名 $filename 进行 URL 编码。这样,AWS SDK 在计算签名时,依据的就是这个已编码 的字符串。
  2. 前端: 从后端拿到响应后,响应的 metadata.filename 字段里已经是 URL 编码后的字符串 。在 fetchheaders 中设置 x-amz-meta-filename 时,直接使用这个已经编码好的字符串 即可。
  3. 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);
}

后续处理:

当你需要读取这个文件的元数据时(比如通过 HeadObjectGetObject),你拿到的 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 编码问题,但增加了系统其他部分的复杂度。