s3-policy-evaluation-boundaries

📅 2026/8/12 16:53:56
s3-policy-evaluation-boundaries
只给了 bucket/* 的权限却能改桶级配置S3 策略求值的三条边界一份给审计用的只读角色Resource 只写了arn:aws:s3:::data/*在 AWS 上跑了两年没出过事。搬到自建的 S3 兼容集群之后同一份策略、同一套凭证那个角色开始能调通桶级的写接口。策略一个字没改行为变了。问题不在策略写错在于策略求值的实现细节在不同 S3 实现之间是有偏差的。桶策略的语法看着像标准但标准只规定了字段长什么样没规定每个实现怎么把一次请求映射成资源和条件值。这中间有三条边界任何一条对不齐你写的 Deny 就可能形同虚设。边界一bucket 和 bucket/* 是两个资源AWS 的规矩很干脆桶级操作s3:ListBucket、s3:GetBucketPolicy、s3:PutBucketVersioning这一类的 Resource 必须是裸桶 ARN对象级操作s3:GetObject、s3:PutObject的 Resource 必须带/*。两者错配时AWS 在策略校验阶段就会甩回Action does not apply to any resource(s) in statement。所以在 AWS 上下面这份策略是纯对象权限碰不到任何桶配置{Version:2012-10-17,Statement:[{Sid:ObjectsOnly,Effect:Allow,Action:[s3:GetObject,s3:PutObject,s3:DeleteObject],Resource:arn:aws:s3:::data/*}]}自建实现不一定这么理解。把bucket/*当成普通通配去匹配bucket本身是个很自然的实现偷懒——通配匹配那段代码不区分资源类型data/*在字符串层面覆盖了data底下的一切顺带把桶自己也覆盖进去了。这不是假想。MinIO 的社区分支 Silo 在 2026-08-04 的版本里专门修了这条一个只写了bucket/*的 Allow此前能授权十二个敏感的桶级写操作公告编号 SN-2026-004。升级说明给的动作是在策略里为这些受保护的写补上裸桶 ARN同时提供临时开关MINIO_API_LEGACY_BUCKET_RESOURCE_MATCHon恢复旧行为——公告里写明了这只是迁移期的临时控制。公告摘要没有逐条列出是哪十二个操作。与其猜不如在自己的集群上量一遍拿只有对象权限的那套凭证挨个去打桶级写接口看返回的到底是不是 403。#!/usr/bin/env bash# 用「只有 bucket/* 对象权限」的凭证探测能碰到哪些桶级写exportAWS_ACCESS_KEY_IDobjects-onlyexportAWS_SECRET_ACCESS_KEY***EP--endpoint-url https://s3.internal:9000Bprobe-throwaway# 用可丢弃的桶下面几条是真会改状态的probe(){desc$1;shiftifout$(aws $EP$21);thenechoREACHABLE$descelseecho$out|grep-qAccessDenied\echodenied$desc\||echoother$desc-$(echo$out|head-1)fi}probeput-bucket-versionings3api put-bucket-versioning--bucket$B\--versioning-configurationStatusEnabled probeput-bucket-taggings3api put-bucket-tagging--bucket$B\--taggingTagSet[{Keyprobe,Value1}]probedelete-bucket-policys3api delete-bucket-policy--bucket$Bprobeput-bucket-acls3api put-bucket-acl--bucket$B--aclprivate任何一行打出REACHABLE就说明这个实现把对象授权外溢到了桶级。关掉版本控制、删掉桶策略这种事本来不该由一个只读审计角色做到。边界二条件键的值是服务端算的还是客户端塞的条件键其实分两类混在一条 Condition 里写的时候特别容易忘服务端计算s3:versionid服务端实际操作的那个版本、s3:signatureAge、aws:SourceIp、对象上已有的标签。客户端提供请求里带的标签、自定义 header、query 参数。要是某个实现在求值时让客户端输入盖住了本该服务端算的那一类基于条件的 Deny 就能被绕开。同一批修复里的 SN-2026-003 说的就是这件事内部条件名不允许由客户端值提供请求标签和对象已有标签被拆开s3:signatureAge被限制在已验证的预签名请求里。其中最阴的是s3:versionid在批量删除下的行为。假设你写了这么一条护栏禁止删掉某个受保护的版本{Sid:DenyProtectedVersionDelete,Effect:Deny,Action:s3:DeleteObjectVersion,Resource:arn:aws:s3:::data/*,Condition:{StringEquals:{s3:versionid:3HL4kqCxf3vjVBH40Nrjfkd}}}单条DeleteObjectVersion走这条 Deny 没问题。但DeleteObjectsMulti-Delete一次请求里能带 N 个 key version如果实现只在请求级绑定一次s3:versionid、不按条目重新绑定条件就匹配不上任何一条整批放行。这是典型的 fail-open护栏偏偏在最需要它的批量场景下静默失效。SN-2026-003 的修法是让s3:versionid在没有指定版本时不存在而不是给个空值并在DeleteObjects的每个条目上重新绑定。所以验收脚本必须专门打批量路径importboto3 s3boto3.client(s3,endpoint_urlhttps://s3.internal:9000)KEY,VIDreports/q2.parquet,3HL4kqCxf3vjVBH40Nrjfkd# 单条删除期望 AccessDeniedtry:s3.delete_object(Bucketdata,KeyKEY,VersionIdVID)print(FAIL single: 删掉了Deny 没生效)excepts3.exceptions.ClientErrorase:print(ok single:,e.response[Error][Code])# 批量删除同一条 Deny期望同样被拒resps3.delete_objects(Bucketdata,Delete{Objects:[{Key:KEY,VersionId:VID}],Quiet:False},)ifresp.get(Deleted):print(FAIL multi: 批量路径绕过了 Deny)else:print(ok multi:,[e[Code]foreinresp.get(Errors,[])])关键在于delete_objects不抛异常——它在一个 200 响应里分别返回Deleted和Errors两个列表。只写 try/except 是测不出问题的很多人的验收脚本就漏在这一步。边界三aws:SourceIp 在反向代理后面看到的是谁只要请求经过 nginx、HAProxy 或者云上的 LB存储服务端拿到的对端地址就是代理的地址。这时候两类策略同时坏掉基于aws:SourceIp放行办公网段的 Allow实际对所有能打到代理的人生效基于aws:SourceIp拉黑某个网段的 Deny永远匹配不上。审计日志里记的来源 IP 也是代理的事后追责同样无从下手。Silo 这一版加了MINIO_API_TRUSTED_PROXIES显式声明哪些对端地址可信、可以按转发头还原真实客户端不设置则保持历史行为。代理侧得把头传对location / { proxy_pass http://s3_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }配完必须验一次从策略允许的网段和不允许的网段各打一发确认后者是 403 而不是 200。同时把绕过代理的直连路径封死——否则攻击者直接怼后端端口自己伪造一个X-Forwarded-For就能变成任意 IP。信任代理这个前提只有在必须经过代理成立时才成立。代价在哪把这三条边界收紧不是免费的升级会打断既有授权。原先靠bucket/*顺带拿到桶级写权限的服务升级后会突然 403。这类隐性依赖通常没人记得只能靠灰度和日志一点点捞。兼容开关能救急但开着它等于把洞留在原地得给自己定个关闭日期。补 ARN 是体力活。策略散落在 IAM 用户、组、桶策略、Terraform 模板里逐条给桶级动作补裸桶 ARN还得分辨哪些动作确实需要。策略越老越难改。可信代理引入新的假设。一旦声明代理可信网络层就必须保证没有旁路。这个约束落在运维手里扩容加节点的时候最容易被忘掉。反过来不收紧的代价是你根本不知道自己的 Deny 有没有生效。这三条里任意一条对不齐安全评审看到的策略和集群实际执行的策略就是两份东西。照着做把生产上所有含 Deny 的桶策略挑出来每条 Deny 配一个本该被拒的请求样例。用只有对象权限的凭证跑一遍桶级写探测上面那段 bash记下所有REACHABLE的接口。每条带条件键的 Deny单条路径和批量路径各测一次DeleteObjects必测。检查存储节点前面有没有代理有的话确认aws:SourceIp类策略还有没有意义并显式配置可信代理 封死直连端口。把这些断言塞进 CI每次升级存储版本重跑一遍——策略求值的行为是会随版本变的这批修复就是现成的例子。自建 S3 这两年的迭代方向很大一部分是在把这类实现自由度收回到 AWS 的语义上。RustFS 在 IAM 与桶策略这块也在持续往标准语义靠代码在 github.com/rustfs/rustfs。不过无论用哪家上面这套验收都值得在自己的集群上跑一遍策略写得对和策略被执行得对是两件事。参考来源RustFS GithubRustFS官网rustfs.comAWS 官方S3 桶级 / 对象级 ARN 规则docs.aws.amazon.com/AmazonS3/source-address-trust/)AWS 官方S3 桶级 / 对象级 ARN 规则docs.aws.amazon.com/AmazonS3