对象存储上传被拒:3 个原因 + 修复路径

S3 / Firebase Storage / Supabase Storage 上传 403——IAM / 签名 URL / bucket 策略。

你的前端用户传文件,调用 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 "..."

存储 403 看似一样,但根因有三大类:bucket 策略 / IAM 权限 / 签名 URL。错误信息里通常已经透露线索,认真读它。

常见原因

按命中率从高到低:

1. bucket policy / Storage rules 太严

最常见。新 bucket 默认 deny-all 或 only-owner。前端用 anon key 上传根本没权限。

如何判断

  • S3: aws s3api get-bucket-policy --bucket xxx 看策略
  • Firebase: Console → Storage → Rules
  • Supabase: Dashboard → Storage → 选 bucket → Policies

2. IAM 角色 / 用户没 PutObject

S3 经典:你的 server 用 IAM Role 有 s3:GetObject 但漏了 s3:PutObject。GET 没事,PUT 403。

如何判断:AWS Console → IAM → 你的 Role → Policies → 看是否包含 s3:PutObjects3:PutObjectAcl

3. 签名 URL 过期 / 签错

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

或者签名时用错 secret key、错 region、错 bucket name。

如何判断:上传失败时 URL 已经创建超过 expires 时间。

4. CORS 拦截浏览器直传

浏览器跨域 PUT 到 S3 需要 bucket CORS 允许。新 bucket 默认 CORS 是禁的。

如何判断:浏览器 console 报 CORS error 而非 403。

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

某些策略限制 Content-Length / Content-Type

"Condition": {
  "NumericLessThan": {"s3:content-length-range": 5242880}  // 5MB
}

传 10MB 就拒。

如何判断:错误含 “ContentLengthExceeded” 或 “ContentType not allowed”。

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

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

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

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

如何判断:错误指向 Storage rules 里的具体行号。

最短修复路径

Step 1:抓完整错误响应

# 重新触发上传
# 浏览器 Network → 找失败请求 → Response

# S3 示例
<?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 提示具体原因;Firebase/Supabase 会直接说”policy X denied”。

Step 2:S3 IAM 修复

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

# 2. 加 PutObject
cat > /tmp/upload-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject", "s3:PutObjectAcl"],
    "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

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
                   && request.resource.contentType.matches('image/.*');
    }
    // 公开 assets
    match /public/{allPaths=**} {
      allow read: if true;
      allow write: if false;
    }
  }
}

部署:firebase deploy --only storage

Step 4:Supabase Storage Policies

-- Dashboard SQL editor
INSERT INTO storage.objects (bucket_id, name, owner)
VALUES ('avatars', 'test.jpg', auth.uid());

-- 加 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 加。

Step 5:签名 URL 修复

// 上传前刚生成
async function uploadFile(file: File) {
  const { url } = await fetch('/api/get-upload-url', {
    method: 'POST',
    body: JSON.stringify({ filename: file.name })
  }).then(r => r.json());

  // 立即上传,不要拖
  await fetch(url, { method: 'PUT', body: file });
}

// server 端
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const url = await getSignedUrl(s3Client, new PutObjectCommand({
  Bucket: 'x',
  Key: filename,
}), { expiresIn: 900 });  // 15 分钟

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:客户端时钟检查

签名验证依赖系统时间。如果 client 时钟飘 > 15 分钟:

# macOS
sudo sntp -sS time.apple.com

# Linux
sudo timedatectl set-ntp true

预防建议

  • bucket policy / Storage rules 进 git,PR review 能看到权限变更
  • 上传路径文档化:哪个 bucket、什么 path、谁能写、谁能读
  • 签名 URL expiry 设短(5-15 分钟),客户端拿到立刻上传
  • 浏览器直传必配 CORS,开发时就用生产 domain 测
  • IAM 最小权限:分开 read-only role 和 write role
  • 不要在前端代码硬编码 AWS secret key(用签名 URL 或 STS)
  • 监控 4xx upload 比例,> 1% 就有规则错
  • 用 emulator / local stack 在本地复现 S3 / Firebase rules,避免生产试错

相关阅读

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