SN-2026-003: s3:versionid Condition Evaluation and Request Condition Hardening
SN-2026-003 and SN-2026-005 cover a set of S3 IAM policy evaluation fixes shipped in Server 20260804 (80c2c9d03, 2f55347f7, eee05a17c, and silo-pkg v3.12.0 commit 2b087a1; pgsty/silo#40, pgsty/silo#53):
SN-2026-003(80c2c9d03):getConditionValuespopulatedargs.ConditionValues["versionid"] = []string{""}even when a request carried noversionId, causing IAMNullconditions ons3:versionidto evaluate as if the key were always present; fixing that alone would have opened aDeleteObjectsXML<VersionId>bypass without per-entry rebinding inauthorizeRequestWithTags.- Condition-source isolation (
2f55347f7): Separated query parameters, HTTP headers, internal server keys, ands3:ExistingObjectTag/<key>vs.s3:RequestObjectTag/<key>so untrusted query strings or headers cannot spoof internal condition keys. SN-2026-005(2b087a1/eee05a17c): AddedParseConfigStrictto reject degenerate bare resource prefixes ("arn:aws:s3:::"and"*arn:aws:s3:::") when writing IAM and bucket policies.
Conclusions first
- Unconditional empty slice inverted
Nullchecks: Insilo-pkg,nullFunc.evaluatetests whether a condition key is absent by checkinglen(rvalues) == 0. BecausegetConditionValuesunconditionally assignedargs.ConditionValues["versionid"] = []string{vid}(producing[]string{""}whenvid == ""),len(rvalues)was always1—makingNull: {"s3:versionid": "true"}alwaysfalseandNull: {"s3:versionid": "false"}alwaystrue. - Fixing
getConditionValuesalone breaksDeleteObjects: In multi-objectDeleteObjects(POST /?delete), the target<VersionId>is inside each XML<Object>element, not in the URL query string. IfgetConditionValuessimply omits"versionid"when the URL query has noversionId, a policy denying versioned deletes (Deny s3:DeleteObjectVersionorCondition: {"Null": {"s3:versionid": "false"}}) would fail to matchDeleteObjectsXML entries unlessauthorizeRequestWithTagsrebindsConditionValues["versionid"]fromReqInfo.VersionID. - Whitespace trimming closes a
Denybypass: Version-reading handlers calledstrings.TrimSpace(r.URL.Query().Get("versionId")), whereasgetConditionValuesdid not trim whitespace. A request with?versionId=V%20bypassedDeny StringEquals {"s3:versionid": "V"}while still deleting or reading versionV.
Why Null on s3:versionid Was Inverted
In silo-pkg/bucket/policy/condition/nullfunc.go, the IAM Null condition operator checks whether a condition key is missing from the request context:
However, in cmd/signature-v4-utils.go, getConditionValues populated "versionid" at the end of the function without checking whether vid was non-empty:
When a caller sent an unversioned request (DELETE /bucket/key with no versionId query parameter), vid was "", and args["versionid"] became []string{""} (len == 1). Consequently:
- A policy allowing only unversioned operations via
"Null": {"s3:versionid": "true"}evaluatedlen(rvalues) == 0asfalseand denied valid unversioned requests. - A policy restricting version-specific operations via
"Null": {"s3:versionid": "false"}evaluated!f.v && len(rvalues) > 0astrueand matched even when noversionIdwas supplied.
The Trap: Why Omitting Empty vid Alone Opens a DeleteObjects Bypass
The obvious one-line fix—only writing args["versionid"] = []string{vid} when vid != ""—turned out to hide a dangerous authorization bypass in DeleteMultipleObjectsHandler (POST /{bucket}?delete).
Unlike single-object DELETE /{bucket}/{object}?versionId=V, a multi-object DeleteObjects request carries no versionId in the URL query string. Instead, the request body contains an XML list of <Object><Key>...</Key><VersionId>V</VersionId></Object> entries. Inside DeleteMultipleObjectsHandler, the server loops over each XML entry, sets r.WithContext (ReqInfo.VersionID = obj.VersionID), and calls authorizeRequestWithTags for DeleteObjectAction.
Consider what happens if an administrator configures a policy that allows normal delete-marker creation (s3:DeleteObject when s3:versionid is null) or denies permanent version deletion via:
Because authorizeRequestWithTags calls getConditionValues(r, ...)—which inspects r.Form.Get("versionId") on the HTTP request URL rather than ReqInfo.VersionID—omitting empty vid in getConditionValues without propagating ReqInfo.VersionID means args["versionid"] is absent during every per-object check in DeleteObjects. Worse still, an attacker could pass a decoy query parameter on the batch URL (POST /mybucket?delete&versionId=decoy) while putting the protected <VersionId>target-v1</VersionId> inside the XML body!
The Two-Part Fix (80c2c9d03)
Commit 80c2c9d03 fixes both halves together:
- Omit
"versionid"whenvid == ""ingetConditionValues: - Rebind
"versionid"fromReqInfo.VersionIDforDeleteObjectActioninauthorizeRequestWithTags(cmd/auth-handler.go):
Why rebind conditionValues["versionid"] only when action == iampolicy.DeleteObjectAction rather than for every action? Because in CopyObjectHandler, ReqInfo.VersionID holds the destination query parameter (if any), whereas the GetObjectAction authorization check on the copy source must evaluate s3:versionid from x-amz-copy-source (?versionId=... on the source URL). Restricting the ReqInfo.VersionID override to DeleteObjectAction ensures both single-object DeleteObjectHandler and per-entry DeleteMultipleObjectsHandler evaluate the exact version being deleted—and explicitly delete(conditionValues, "versionid") when reqInfo.VersionID is empty so that a URL query decoy (POST /bucket?delete&versionId=query-level-decoy) cannot spoof an unversioned XML entry.
Closing the Whitespace Normalization Gap
While auditing every caller that reads versionId, we noticed that object handlers (GetObjectHandler, HeadObjectHandler, DeleteObjectHandler, PutObjectRetentionHandler, etc.) extract the query parameter with:
In contrast, getConditionValues read r.Form.Get("versionId") (and u.Query().Get("versionId") from x-amz-copy-source) without strings.TrimSpace.
If a policy denied access to a specific version via "StringEquals": {"s3:versionid": "v1"}, a caller requesting ?versionId=v1%20 (with a trailing space) had "versionid": ["v1 "] during policy evaluation—evading the StringEquals deny rule—after which the handler trimmed "v1 " to "v1" and operated on the protected version. Applying strings.TrimSpace to both query versionId and x-amz-copy-source versionId inside getConditionValues closes that mismatch.
Call-Chain Impact and Condition-Source Isolation (2f55347f7)
| Operation | Source of s3:versionid |
Pre-20260804 Behavior |
Server 20260804 Behavior |
|---|---|---|---|
GetObject / DeleteObject (no versionId) |
Absent | ["versionid"] = [""] (Null:true fails) |
Key omitted (Null:true matches) |
GetObject / DeleteObject (?versionId=v1%20) |
URL query versionId |
Policy sees "v1 ", handler uses "v1" |
Both policy and handler see "v1" |
CopyObject (x-amz-copy-source: /b/k?versionId=v1) |
x-amz-copy-source query |
Untrimmed vid |
Trimmed vid |
DeleteObjects (POST /?delete with <VersionId>) |
Per-entry ReqInfo.VersionID |
Ignored XML <VersionId>; read URL query |
Rebound per XML entry; URL decoy ignored |
In companion commit 2f55347f7 (pgsty/silo#53), we also hardened getConditionValues across all other request condition keys:
- Reserved internal keys: Client query parameters and HTTP headers can no longer overwrite server-derived condition keys (
username,userid,principaltype,authtype,signatureversion,tlsVersion,cipherSuite). s3:signatureAge: Computed directly from the validated SigV4 timestamp rather than trusting client-supplied values.- Tag condition separation:
s3:ExistingObjectTag/<key>is populated exclusively from stored object tags, ands3:RequestObjectTag/<key>exclusively fromx-amz-taggingon the incoming request.
SN-2026-005: Bare ARN Prefix Rejection
During the same IAM condition audit, we investigated SN-2026-005 (silo-pkg v3.12.0 commit 2b087a1, wired into Server 20260804 in eee05a17c): what happens when a policy contains a degenerate resource string with an empty bucket and object suffix, such as "arn:aws:s3:::" or "*arn:aws:s3:::"?
Because the legacy parser stripped the arn:aws:s3::: prefix and accepted an empty resource pattern "", that pattern could never match any real bucket or object. In an Allow statement, it was merely a dead rule—but in a Deny statement on Resource or an Allow statement using NotResource, a degenerate arn:aws:s3::: pattern failed open!
To prevent administrators from saving policies that silently fail open without risking startup failures on existing clusters:
silo-pkg v3.12.0addspolicy.ParseConfigStrict, which rejects degenerate bare ARN prefixes (arn:aws:s3:::and*arn:aws:s3:::).- Server
20260804(eee05a17c) callsParseConfigStricton all policy write paths (bucket policies, named IAM policies, and service-account/STS inline policies), while keeping stored policy load paths on permissiveParseConfigso existing deployments never fail to boot.
Why SN-2026-003 Is Tracked as an Advisory Rather Than a CVE
Exploiting the s3:versionid evaluation bugs required an authenticated caller operating against a policy that explicitly used s3:versionid conditions (Null, StringEquals, or StringNotEquals). Standard s3:DeleteObjectVersion action checks were unaffected unless conditioned on s3:versionid. We track the condition evaluation and DeleteObjects rebinding fixes under SN-2026-003 and the bare ARN prefix rejection under SN-2026-005.
Scope Boundaries and Related Fixes
- Multi-Delete Object Lock governance bypass (
75a6734e4/#104): While auditingDeleteMultipleObjectsHandler, we noted how per-entry headers and retention checks interact; Server 20260903 (75a6734e4,SN-2026-008) subsequently tightenedDeleteMultipleObjectsHandlersox-amz-bypass-governance-retentionis only evaluated against the specific locked entry rather than across the batch. - Snowball
PutObjectExtractminio.versionIdPAX header: Noted during ourversionIdaudit and closed in Server 20261018 (SN-2026-016,14042b5aa). - Other condition keys (
username,userid,signatureversion,authType): We intentionally scoped80c2c9d03tos3:versionidrather than changing empty-slice semantics for identity keys that are always defined on authenticated requests.
Verification
Regression tests in cmd/signature-v4-utils_test.go, cmd/auth-handler_test.go, and cmd/bucket-policy_test.go verify:
Null: {"s3:versionid": "true"}andNull: {"s3:versionid": "false"}across versioned and unversionedGetObject,DeleteObject,CopyObject, andDeleteObjectscalls,?versionId=v1%20whitespace trimming againstDeny StringEquals {"s3:versionid": "v1"},POST /bucket?delete&versionId=query-level-decoywith both versioned and unversioned XML<Object>entries,- Rejection of
arn:aws:s3:::and*arn:aws:s3:::viaParseConfigStrict.
Source References
80c2c9d03—s3:versionidcondition omission, whitespace trimming, andDeleteObjectsper-entry rebinding (SN-2026-003)2f55347f7— request condition-source isolation (#53)eee05a17c/silo-pkg@2b087a1—ParseConfigStrictbare ARN prefix rejection (SN-2026-005)
Summary
Shipped in Server 20260804, 80c2c9d03 and eee05a17c (silo-pkg v3.12.0) ensure accurate s3:versionid condition evaluation across single-object and batch DeleteObjects operations while rejecting degenerate bare S3 ARN prefixes on policy writes.