一次「有没有防盗链」的例行检查,最后挖出了三个更严重的问题:存储桶是公共读写、应用在用一把能建桶的万能钥匙、删了素材没法回滚。这篇记录完整的收敛过程,重点在如何给应用建一个真正最小权限的 RAM 子账号,以及怎么验证它「刚好够用、多一点都不给」。
文中的桶名、域名、账号 ID 已做脱敏,代码和 JSON 可直接复用。
一、起因:防盗链检查挖出的真问题
原本只是想确认「素材有没有配防盗链」,结果用 curl 直接探测存储桶,四个结论一个比一个糟:
| 探测方式 | 返回 | 含义 |
|---|---|---|
| 无签名直连对象 | 206 Partial Content | 桶是公有读,任何人都能下 |
带外站 Referer 直连 | 206 | 没配 Referer 白名单 |
匿名 PUT | 200,内容能读回 | 公有写 |
匿名 DELETE | 204,对象真被删了 | 可以被匿名删除 |
| 匿名列举桶 | 403 AccessDenied | 这一项反而是对的 |
公有读还能理解为「配置图省事」,公有写就是事故级别的:任何人可以覆盖你的模板素材、往你的域名下塞任意文件、或者直接 DELETE 掉线上资源。
更麻烦的是叠加上层设计:素材清单接口是公开的,返回里带了每个图层的对象键(assets/tpl-xxx/v3/avatar/<sha256>.png)。桶公有读 + 键外泄 = 付费素材可被直接批量下载,绕过会员和单购。
二、先止血:桶层面的三件事
动手改权限之前,先把桶本身的风险关掉,顺序是先止血再收敛,避免中途出现「改了一半,两边都不安全」。
1)ACL 改为私有读
const OSS = require("ali-oss");
const client = new OSS({ region: "oss-cn-beijing", accessKeyId, accessKeySecret, bucket });
// 改之前先留档,便于回滚
const before = await client.getBucketACL(bucket);
console.log("before:", before.acl); // public-read-write
await client.putBucketACL(bucket, "private");
// 回滚:await client.putBucketACL(bucket, "public-read-write");改私有不影响业务的前提是:读路径本来就不依赖匿名访问。我们的素材要么走服务端代理(带签名),要么由服务端现场签 OSS URL,客户端从不持密钥。如果你的项目里存在硬编码的公网直链,先把它改掉再动 ACL。
2)开版本控制
await client.putBucketVersioning(bucket, "Enabled");私有化解决「谁能读」,解决不了「删了怎么恢复」。开了版本控制后,覆盖和删除都会留历史版本:删除只产生 delete marker,业务侧 get 照旧 404、list 照旧看不到,但旧内容还能按 versionId 捞回来。
3)开访问日志
日志要写到一个独立桶(同地域、私有读),别写回源桶,否则日志自己也会被记录、越滚越多:
const logBucket = "your-bucket-logs";
await client.putBucket(logBucket);
await client.putBucketLogging(bucket, logBucket, "access-logs/"); // 视 SDK 版本而定注意:ali-oss的putBucketLogging(name, prefix)把name同时当作源桶和目标桶,只能写同一个桶。要写独立桶得自己构造?logging的请求体,这一步用一次性脚本做,不要放进业务代码。
三、为什么必须换成 RAM 子账号
桶锁好了,剩下的风险全在钥匙上。排查时发现:应用用的 AccessKey 能成功调用 CreateBucket。这意味着它不是最小权限凭证,一旦泄露,攻击面是整个账号的 OSS 资源,而不只是自己的几个桶。
更隐蔽的是信任链:桌面端自动更新的校验依据(版本清单 + 安装包)都在同一台服务器上,服务器被拿下就等于对所有用户下发任意程序。这时候一把「能改配置」的云凭证就是放大器。
正确做法:给应用单独建一个 RAM 用户,只授数据面权限,不授任何管理面权限。
四、创建子账号的完整流程
4.1 别用「快速开始」
RAM 控制台的「快速开始」提供了几个场景模板,第一个就是「用于程序访问的超级用户」,绑定 PowerUserAccess。
这是个陷阱:PowerUserAccess 除了账号管理、计费外,几乎什么都能干——建桶、删桶、改 ACL、开子账号。用它等于没收敛,只是把「主账号 AK」换成了「长得像子账号的主账号 AK」。
手动创建,不选任何场景模板。
4.2 创建用户
「身份管理 → 用户 → 创建用户」:
| 字段 | 填什么 | 原因 |
|---|---|---|
| 登录名称 | app-oss | 创建后不可修改 |
| 显示名称 | 业务名-OSS | 便于识别 |
| 访问方式 | 只勾「使用永久 AccessKey 访问」 | 程序调用够用 |
| 控制台访问密码 | 不勾 | 少一个可爆破的入口 |
| MFA | 不勾 | 程序访问用不上 |
页面底部有个「我确认必须创建 AccessKey」的勾选框,必须勾上,否则只会创建出用户、不会生成密钥,还得再进详情页补一步。
创建结果页会显示 AccessKey ID 和 AccessKey Secret。Secret 只显示这一次,关掉页面后没有任何方式再查回来,只能禁用重建。
4.3 创建自定义策略(关键一步)
不要用系统策略(AliyunOSSFullAccess 也是全权限)。走「权限管理 → 权限策略 → 创建权限策略 → 脚本编辑」。
策略的内容不能拍脑袋写,要先摸清应用实际调用了哪些 OSS 操作。我的做法是直接统计 SDK 调用面:
grep -o "client\.\(get\|put\|delete\|list\|head\|multipartUpload\|signatureUrl\)[A-Za-z]*" \
server/src/ossMaterials.ts | sed 's/.*client\.//' | sort | uniq -c得到的结果是:put、get、head、delete、deleteMulti、list、multipartUpload、signatureUrl。对应到 RAM action 就是:
| 应用调用 | 需要的 action |
|---|---|
get / head | oss:GetObject |
put | oss:PutObject |
delete / deleteMulti | oss:DeleteObject |
list | oss:ListObjects(桶级资源) |
multipartUpload | InitiateMultipartUpload / UploadPart / CompleteMultipartUpload / AbortMultipartUpload / ListParts |
signatureUrl | 无需额外权限(签名在本地算,实际访问仍走 GetObject) |
最终策略:
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:GetObject",
"oss:PutObject",
"oss:DeleteObject",
"oss:InitiateMultipartUpload",
"oss:UploadPart",
"oss:CompleteMultipartUpload",
"oss:AbortMultipartUpload",
"oss:ListParts"
],
"Resource": [
"acs:oss:*:*:your-bucket/*",
"acs:oss:*:*:your-font-bucket/*"
]
},
{
"Effect": "Allow",
"Action": ["oss:ListObjects"],
"Resource": [
"acs:oss:*:*:your-bucket",
"acs:oss:*:*:your-font-bucket"
]
}
]
}三个最容易漏的点:
oss:ListObjects是桶级资源,不是对象级。上面的Resource写bucket/*只覆盖对象操作,列举必须单独一条写acs:oss:*:*:bucket。漏了的表现不是报错,而是「素材列表莫名其妙是空的」。- 分片上传要五个 action。大于分片阈值的文件会走
InitiateMultipartUpload → UploadPart → CompleteMultipartUpload,只给PutObject会在大文件上失败。 - 如果项目里短信和 OSS 复用同一组凭证,策略里必须加短信权限,否则验证码会在主流程之外静默失败:
{
"Effect": "Allow",
"Action": ["dysms:SendSms", "dysms:SendBatchSms"],
"Resource": ["*"]
}这一条是我实际踩到的:代码里短信客户端直接复用了 OSS_ACCESS_KEY_ID,换 Key 时如果只想着 OSS,注册和找回密码会全线挂掉,而且日志里只有一行「发送失败」。
4.4 绑定授权
两条路径,效果等价:
- 用户页正向:用户详情 → 「权限管理」→ 新增授权 → 选自定义策略;
- 策略页反向:策略详情 → 「授权管理」→ 新增授权 → 选用户。
这一步有个高频错误:策略列表默认展示「所有策略类型」,第一行就是标着高危的 AdministratorAccess。手快点错,前面全白做。把筛选切成「自定义策略」再搜名字,最终只应看到一条授权。
4.5 创建 AccessKey
用户详情 → 「认证管理」→ 创建 AccessKey。Secret 只显示一次,直接写进服务器 .env,不要经过聊天工具、截图或文档。
五、上线前先验证:正向要通,反向必须拒
顺序很重要:先用新凭证做权限探测,确认无误再改线上配置。 直接改 .env 的风险是「策略没配好 → 全站 403 → 你还得怀疑是不是代码问题」。
探测脚本同时包含正向和反向用例:
const OSS = require("ali-oss");
const bucket = "your-bucket";
const client = new OSS({ region, accessKeyId: process.env.PROBE_AK, accessKeySecret: process.env.PROBE_SK, bucket });
async function probe(label, run) {
try { console.log(label, "= ok", await run()); }
catch (e) { console.log(label, "= FAIL", e.code, e.message.slice(0, 60)); }
}
await probe("list", () => client.list({ prefix: "_probe/", "max-keys": 5 }));
await probe("get", () => client.get("字体文件/known.ttf"));
await probe("put", () => client.put("_probe/a.txt", Buffer.from("ok")));
await probe("delete", () => client.delete("_probe/a.txt"));
// 反向:这些必须被拒
await probe("get_bucket_acl", () => client.getBucketACL(bucket));
await probe("put_bucket_acl", () => client.putBucketACL(bucket, "private"));
await probe("create_bucket", () => client.putBucket("should-fail-probe"));期望输出(前四项 ok,后三项 FAIL):
list = ok
get = ok
put = ok
delete = ok
get_bucket_acl = FAIL AccessDenied
put_bucket_acl = FAIL AccessDenied
create_bucket = FAIL AccessDenied如果全部 FAIL,不是策略写错,而是策略根本没绑上——第一次探测时我就遇到这个,报错是 The bucket you access does not belong to you.,典型的「RAM 用户零策略」。策略绑了但缺 action 时,通常是一部分 ok 一部分 FAIL,不会全军覆没。
短信权限怎么验证又不真发短信:用一个非法号码调用发送接口,看返回的错误码。
const result = await client.sendSms(new SendSmsRequest({ phoneNumbers: "10000000000", ... }));
// 有权限:code = isv.MOBILE_NUMBER_ILLEGAL(手机号码格式错误)
// 没权限:抛 Forbidden.RAM / Forbidden.AccessKeyisv.* 说明鉴权通过、只是参数不合法;Forbidden.* 才是权限问题。这样既验证了权限,又不会真的下发短信。
六、切换与回滚
cd /path/to/project
cp .env .env.bak-$(date +%Y%m%d-%H%M%S)
# 用脚本替换,避免密钥出现在命令历史里
NEWID=xxx NEWSEC=yyy python3 - <<'PY'
import os, re
p = ".env"
s = open(p).read()
s = re.sub(r"^OSS_ACCESS_KEY_ID=.*$", "OSS_ACCESS_KEY_ID=" + os.environ["NEWID"], s, flags=re.M)
s = re.sub(r"^OSS_ACCESS_KEY_SECRET=.*$", "OSS_ACCESS_KEY_SECRET=" + os.environ["NEWSEC"], s, flags=re.M)
open(p, "w").write(s)
PY
chmod 600 .env
pm2 restart your-api --update-env怎么证明「应用真的在用新 Key」
这一步最容易被糊弄:接口返回 200 不代表换了 Key,因为旧 Key 也通。
正确做法是找一个能反查出凭证身份的出口。如果应用会给客户端签发 OSS 签名 URL,那么 URL 里必然带 OSSAccessKeyId 参数:
curl -s https://your-domain/api/materials/catalog | python -c "
import sys, json, urllib.parse
d = json.load(sys.stdin)
def walk(n):
for a in n.get('assets', []):
if a.get('url'): return a
for c in n.get('children', []):
r = walk(c)
if r: return r
url = walk(d['root'])['url']
print(urllib.parse.parse_qs(urllib.parse.urlparse(url).query)['OSSAccessKeyId'][0])
"输出应该是新的 AccessKey ID。这一招比看日志可靠得多,也不需要打开密钥本身。
回滚:.env.bak-<时间戳> 拷回去,重启即可。所以旧 Key 不要立刻禁用,至少留 24 小时。
七、踩坑清单
| 现象 | 原因 | 处理 |
|---|---|---|
| 换 Key 后全站 403 | 策略没绑,或绑了错策略 | 回滚 .env,先补授权 |
| 素材列表空白但不报错 | 缺 oss:ListObjects | 单独加桶级资源授权 |
| 大文件上传失败、小图正常 | 缺分片上传 action | 补齐 5 个 multipart action |
| 验证码发不出去 | 策略漏了 dysms:SendSms | 短信复用 OSS 凭证时必查 |
| 401/403 改了不生效 | 忘了 --update-env | 不能只 pm2 restart |
| Secret 找不回来 | 关掉了创建结果页 | 只能禁用旧 Key 重建 |
授权后出现 AdministratorAccess | 在「所有策略类型」里误勾 | 解绑,只留自定义策略 |
八、收尾:旧 Key 的退役节奏
- 新 Key 验证通过、线上稳定运行 24 小时;
- 控制台把旧 AccessKey 设为禁用(先别删除,留作最后回滚);
- 再观察一周,确认没有遗漏的调用方(定时脚本、监控、第三方集成最容易漏),才删除。
换 Key 时还有一个容易忽略的盲区:同一个账号下可能有多处使用这把 Key。包括 CI、备份脚本、日志采集、以及短信这类「看起来跟 OSS 无关」的服务。换之前用 grep -r "OSS_ACCESS_KEY_ID" 全盘扫一遍,比事后排障便宜得多。
九、最后:为什么应用凭证不该有管理权限
这次收敛的边界是:
- 允许:对象的读、写、删、列举、分片上传;
- 拒绝:桶 ACL、桶创建删除、日志、版本控制、生命周期。
也就是说,应用凭证即使泄露,攻击者也只能在已有的桶里读写数据,改不了安全配置、关不掉日志、建不了新桶扩散。
代价是:以后做桶级运维(开版本控制、调生命周期、改 ACL)必须用主账号走控制台,不能用应用凭证顺手做。这个「不方便」正是隔离的意义——让两套权限无法互相提权。
一个可复用的判断标准:如果你的应用凭证能修改「谁能访问」这个问题的答案,那它的权限就过大了。
附:策略文件(可直接复制)
完整版见文中的 JSON。使用时替换:
your-bucket/your-font-bucket→ 你的实际桶名;- 如果不需要短信,删掉
dysms那一段; - 如果项目跨地域或用了多个账号,
acs:oss:*:*:里的*可以收窄成具体 region。
附:验收清单
[ ] RAM 用户只开 API 访问,无控制台登录
[ ] 只绑定自定义策略,无 PowerUserAccess / AdministratorAccess
[ ] 正向:list / get / put / delete 全部 ok
[ ] 反向:GetBucketAcl / PutBucketAcl / CreateBucket 全部 AccessDenied
[ ] 短信权限用非法号码探测,返回 isv.* 而非 Forbidden.*
[ ] .env 已备份,权限 600
[ ] 重启后签名 URL 里的 OSSAccessKeyId 是新 ID
[ ] 旧 Key 保持可用 24 小时后再禁用


