对象存储上传被拒(S3 / Firebase / Supabase 403)

修复 S3、Firebase Storage、Supabase Storage 上传 403——IAM 权限、签名 URL 过期、bucket 策略,以及 ACL 已禁用的坑。

前端用户传文件,浏览器调用 s3.putObject() / storage.upload(),返回 403:

AccessDenied: Access Denied
   <RequestId>...</RequestId>

或者 Firebase Storage:

storage/unauthorized: User does not have permission to access this object.

或者 Supabase:

new row violates row-level security policy "..."

一句话先救急: 先读错误响应体,再对号入座。S3 上最常见的是 bucket policy 里有一条显式 Deny,或者 IAM 漏了 s3:PutObject;而 2023 年以后新建的 bucket 默认禁用了 ACL,所以现在再去要 s3:PutObjectAcl 或带 ACL header,反而会直接导致失败。三大类根因:权限(IAM / rule / RLS policy)签名 URL(过期或签名输入不对)bucket 配置(policy / CORS / Block Public Access)

你属于哪一类?

错误 / Network 里的现象最可能的原因跳转
PUT 报 AccessDenied,GET 却正常IAM 缺 s3:PutObjectStep 2
AccessControlListNotSupported(400)向已禁用 ACL 的 bucket 发了 ACLACL 的坑
浏览器报的是 CORS error,不是 403bucket 没配 CORSStep 6
刚才还能传,几分钟后就 403签名 URL 过期Step 5
KMS.AccessDeniedException / 提到某个 key缺 KMS key 权限Step 2
storage/unauthorized(Firebase)Storage rule 拦了路径/大小/类型Step 3
42501 / “violates row-level security policy”Supabase 缺 INSERT policyStep 4

存储 403 看上去都一样,但实际分三路。错误响应体几乎总会透露是哪一路——动手改之前先认真读它。

常见原因

按命中率从高到低排。

1. bucket policy / Storage rules 太严

最常见。新 bucket 默认 deny-all 或 only-owner,前端用 anon key 在加策略前根本传不了。Supabase 尤其明显:在你给 storage.objects 建 RLS policy 之前,Storage 一个上传都不放行

如何判断:

  • S3: aws s3api get-bucket-policy --bucket your-bucket
  • Firebase: Console -> Storage -> Rules
  • Supabase: Dashboard -> Storage -> 选 bucket -> Policies

2. IAM 角色 / 用户没 PutObject

S3 经典坑:server 用的 IAM Role 有 s3:GetObject 却漏了 s3:PutObject,于是 GET 没事、PUT 报 403。还有两个常见陷阱:对象级 action(s3:PutObjects3:GetObject)的 Resource ARN 要以 /* 结尾,而 s3:ListBucket 针对的是 bucket ARN、不带 /*,两者搞混就 403;另外只要任何地方(bucket policy、SCP、身份策略)有一条显式 Deny,它会盖过所有 Allow

如何判断: AWS Console -> IAM -> 你的 Role -> Policies -> 搜 s3:PutObject。server 端调用时,只有在 CloudTrail trail 上开启了 S3 data events,才能看到具体被拒的 action。

3. 签名 URL 过期,或签名输入不对

const url = await getSignedUrl(s3, {
  Bucket: 'x', Key: 'y',
  Expires: 60  // 60 秒过期
});
// 用户 5 分钟后才点上传 -> 403

或者签名时用错了 secret key、错 region、错 bucket。签名还覆盖了请求头(比如 Content-Type):如果浏览器实际发的 Content-Type 和你签名时的不一致,S3 会返回 SignatureDoesNotMatch

如何判断: 上传时刻已超过 URL 的 expiry,或者错误 CodeSignatureDoesNotMatch 而非 AccessDenied

4. CORS 拦截浏览器直传

浏览器跨域 PUT 到 S3,需要 bucket 的 CORS 配置放行。新 bucket 默认没有 CORS。

如何判断: 浏览器 console 报的是 CORS error(预检 OPTIONS 被拦),而不是一个干净的 403 响应体。

5. 文件大小 / Content-Type 不在允许范围

有些策略限制 Content-LengthContent-Type

"Condition": {
  "NumericLessThanEquals": {"s3:content-length-range": 5242880}
}

传 10MB 就被拒。Firebase rules 同理,用 request.resource.size(单位是字节)和 request.resource.contentType 来限制。

如何判断: 错误里提到 content-length 或 content-type 条件,或失败的 Firebase rule 引用了 request.resource.size

6. anon 用户上传到需要 auth 的路径

Firebase Storage 和 Supabase 常按路径分权限:

allow write: if request.auth != null && request.auth.uid == userId;

未登录用户传到 /users/{uid}/ 必然失败。

如何判断: 错误指向某条具体 rule,或 Supabase 对该 INSERT 返回 42501

2023 年以后 ACL 的坑(过时教程基本都踩在这)

从 2023 年 4 月起,每个新建 S3 bucket 默认就是 Object Ownership = Bucket owner enforced,也就是彻底禁用 ACL。截至 2026 年 6 月,这仍是默认值,也是 AWS 推荐的设置。

你必须知道的后果:

  • 在 IAM policy 里加 s3:PutObjectAcl 已经没意义;而在 PUT 时发送 ACL header(比如 x-amz-acl: bucket-owner-full-control 或任何 canned ACL)现在会返回:

    AccessControlListNotSupported: The bucket does not allow ACLs

    (HTTP 400,不是 403——但一样让上传失败。)那些叫你”加上 bucket-owner-full-control ACL”的老教程,正是这里的直接祸根。

  • 修复: 把 ACL 去掉。从 PutObjectCommand / putObject 参数里删掉 ACL: 'public-read' / 'bucket-owner-full-control',也不要设 x-amz-acl。要公开读就用 bucket policy。每个上传上来的对象,bucket owner 本来就自动拥有。

  • 只有在你确实要逐对象设 ACL 的少数场景下,才需要 s3:PutObjectAcl(并重新打开 ACL 设置)。绝大多数应用都应保持 ACL 关闭。

最短修复路径

Step 1:抓完整错误响应

# 重新触发上传,然后:
# 浏览器 DevTools -> Network -> 找失败请求 -> Response 标签
<!-- S3 返回的是这种 XML -->
<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>AccessDenied</Code>
  <Message>Access Denied</Message>
  <RequestId>...</RequestId>
  <Resource>/your-bucket/your-key</Resource>
</Error>

S3 的 CodeMessage 给出具体原因(AccessDeniedSignatureDoesNotMatchAccessControlListNotSupportedKMS.AccessDeniedException);Firebase 和 Supabase 会直接点名是哪条 rule 或哪张表拒的。

Step 2:S3 IAM 修复

# 1. 看当前 role 权限
aws iam get-role-policy --role-name your-role --policy-name your-policy

# 2. 加 PutObject(注意:不加 PutObjectAcl——见上面的 ACL 坑)
cat > /tmp/upload-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject"],
    "Resource": "arn:aws:s3:::your-bucket/*"
  }]
}
EOF

aws iam put-role-policy \
  --role-name your-role \
  --policy-name upload-policy \
  --policy-document file:///tmp/upload-policy.json

如果对象用 SSE-KMS 加密,同一个身份还需要在该 key 上有 kms:GenerateDataKeykms:Decrypt,否则即使 S3 权限正确也会报 KMS.AccessDeniedException。再确认 bucket policy 里没有显式 Deny,以及你本以为能成功的公开 PUT 没有被 Block Public Access 悄悄拦掉。

Step 3:Firebase Storage Rules

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // 用户文件夹:只有本人能写
    match /users/{userId}/{allPaths=**} {
      allow read: if request.auth.uid == userId;
      allow write: if request.auth.uid == userId
                   && request.resource.size < 10 * 1024 * 1024  // 10MB,单位字节
                   && request.resource.contentType.matches('image/.*');
    }
    // 公开 assets
    match /public/{allPaths=**} {
      allow read: if true;
      allow write: if false;
    }
  }
}

request.resource.size 单位是字节(所以 10MB 写成 10 * 1024 * 1024),request.resource.contentType 是客户端发上来的类型——MIME 类型对不上,哪怕文件本身没问题也会被 rule 拒。部署:

firebase deploy --only storage

想不碰生产就测 rules,用 Storage 模拟器:firebase emulators:start --only storage

Step 4:Supabase Storage Policies

Supabase Storage 默认不放行任何上传——你要在 storage.objects 上加 policy。普通上传只需要一条 INSERT policy;但如果用了 upsert: true,还得有 UPDATESELECT,否则即使普通上传正常,upsert 也会报 42501

-- Dashboard -> SQL editor。只做上传,这条 INSERT policy 就够:
CREATE POLICY "Users upload to own folder" ON storage.objects
  FOR INSERT TO authenticated
  WITH CHECK (
    bucket_id = 'avatars'
    AND (storage.foldername(name))[1] = auth.uid()::text
  );

或者在 Dashboard -> Storage -> 选 bucket -> Policies 用 UI 加。如果是从可信的 server 调用,可以在 Authorization header 里用 service role key 完全绕过 RLS——但这个 key 绝不能下发到浏览器。

Step 5:签名 URL 修复

// 客户端:上传前现签,拿到立即 PUT。
async function uploadFile(file: File) {
  const { url } = await fetch('/api/get-upload-url', {
    method: 'POST',
    body: JSON.stringify({ filename: file.name, contentType: file.type })
  }).then(r => r.json());

  await fetch(url, {
    method: 'PUT',
    body: file,
    headers: { 'Content-Type': file.type }  // 必须和签名时一致
  });
}
// server 端
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand } from '@aws-sdk/client-s3';

const url = await getSignedUrl(s3Client, new PutObjectCommand({
  Bucket: 'x',
  Key: filename,
  ContentType: contentType,  // 签名时带上浏览器将要发送的同一个 header
}), { expiresIn: 900 });  // 15 分钟

如果你签名时带的 Content-Type(或任何 header)和浏览器实际回传的不完全一致,得到的是 SignatureDoesNotMatch 而非 AccessDenied——这本身就是一个有用的判断线索。

Step 6:S3 CORS

cat > /tmp/cors.json <<EOF
{
  "CORSRules": [{
    "AllowedOrigins": ["https://yourdomain.com"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }]
}
EOF

aws s3api put-bucket-cors --bucket your-bucket \
  --cors-configuration file:///tmp/cors.json

Step 7:客户端时钟检查

签名验证依赖系统时间。如果客户端时钟飘超过 15 min,S3 会拒绝请求(RequestTimeTooSkewed):

# macOS
sudo sntp -sS time.apple.com

# Linux
sudo timedatectl set-ntp true

怎么确认真的修好了

  1. 从真实前端(不只是 curl)重跑同一个上传——浏览器直传还会走 CORS,而 server 端测试会跳过这一段。
  2. DevTools -> Network 里,PUT 应返回 200(S3)或 200/201(Firebase/Supabase),且没有 CORS 预检报错。
  3. 确认对象真的落盘了:aws s3 ls s3://your-bucket/your-key,或在 Firebase/Supabase 的 Storage 浏览器里看。
  4. 也测一下反例——超大文件或未登录用户应当仍被拒。如果什么都能传成功,说明你的规则放得太开了。

FAQ

为什么 GET 行、PUT 却 403? 读和写是两套权限。你的 IAM policy(或 Storage rule / RLS policy)给了 s3:GetObject 但没给 s3:PutObject。把写 action 加到真正执行/签名上传的那个身份上。

我照教程加了 s3:PutObjectAcl 和 bucket-owner-full-control ACL,还是失败,为什么? 因为 2023 年 4 月之后新建的 bucket 默认禁用了 ACL(Object Ownership = Bucket owner enforced)。发任何 ACL 都会返回 AccessControlListNotSupported。把请求里的 ACL 整个删掉即可,对象 bucket owner 本来就拥有。

刚才还能传,几分钟后就 403 了,变了什么? 基本都是签名 URL 过期。别去纠结调短——而是在上传前现签 URL、拿到立刻 PUT,并确认客户端时钟没飘(RequestTimeTooSkewed)。

Supabase:普通上传能成,但 upsert: true42501,为什么? upsert 等于 insert 加 update 加 read。你需要在 storage.objects 上同时有 INSERTUPDATESELECT 三条 policy,而不只是 INSERT

Firebase rule 看着没错却仍被拒,该查什么? 确认 request.auth 非 null(上传那一刻用户确实已登录),以及 request.resource.contentType 匹配你的 .matches(...) 模式。大小单位是字节,所以 < 5 * 1024 * 1024 会拦掉一切超过 5MB 的文件。

浏览器报的是 CORS error 而不是 403——同一套修法吗? 不是。CORS 失败意味着预检在真正的 PUT 之前就被拒了。按 Step 6 用你真实的 origin 配上 bucket CORS;权限本身可能已经是好的。

预防建议

  • 把 bucket policy / Storage rules / RLS policy 进 git,让 PR review 能看到权限变更。
  • 上传路径文档化:哪个 bucket、什么 path、谁能写、谁能读。
  • 签名 URL expiry 设短(5-15 min),并签名客户端将要发送的同一组 header(Content-Type);拿到 URL 立刻上传。
  • 浏览器直传必配 CORS,开发时就用生产 domain 测。
  • 保持 S3 ACL 关闭(默认状态),用 bucket policy 而非 ACL 来授权。
  • IAM 最小权限:分开 read-only 和 write 角色;绝不在前端硬编码 AWS secret key——用签名 URL 或 STS。
  • 监控上传 4xx 比例,持续 > 1% 通常说明规则配错了。
  • 用 Firebase 模拟器或 LocalStack 在本地复现 S3 / Firebase / Supabase 规则,别在生产里试错。

相关阅读

标签: #后端 #排查 #排查