carlos / platform Public

An app could mark its own object clean: the anti-forgery rule was enforced correctly and protected the wrong shape open

Found and fixed on worktree-store-scan-on-demand (6fba2b3), before any deployment has ever had scanning switched on. Filing it because the shape of the mistake is worth more than the fix.

The property

Every verdict object-storage scanning reports rests on one asymmetry: the console can write the carlos-scan tag and the app cannot. If an app can tag its own object, it can mark its own malware clean and the platform will report it clean.

storePolicyJSON said so in capitals. AGENTS.md carries it as a standing rule: s3:PutObjectTagging must never be added to the app user's Allow statement. A purpose-built test helper, assertNoPutObjectTagging, existed to enforce it and was called from every policy test.

All of that was true, and none of it was sufficient.

There are two ways to put a tag on an object

Leaving the permission out of the Allow closes one — PutObjectTagging, the explicit call.

The other is the x-amz-tagging header on PutObject itself, which attaches tags during the upload and never names the tagging action at all. That is why nothing caught it: assertNoPutObjectTagging searched the policy document for the string "s3:PutObjectTagging", and no search for an action name can find a route that names no action.

Against MinIO RELEASE.2025-09-07T16-13-09Z, stood up from CI's own contract fixture and handed the app policy verbatim, the upload succeeded with carlos-scan=clean attached.

AWS documents x-amz-tagging as requiring s3:PutObjectTagging, so a deployment whose app buckets are real AWS S3 was probably never exposed. That is where Tito's are — NewAWSProvisioner builds its IAM client with no endpoint override, deliberately ("IAM has no S3-compatible twin"), so app users are AWS IAM users whose keys authenticate against AWS. But CARLOS_STORE_ENDPOINT exists precisely so a self-hoster can point at something else, and self-hosters are in scope for this feature by decision. The property has to hold on the gateway, not on the paperwork.

What it would have bought an attacker

The sweep reads the tag before scanning and skips any object whose tagged version matches the current one. The version is etag@last-modified: the ETag is the app's own MD5, and the last-modified is the server clock at upload — so an app can upload, HEAD its own object, and retry until the stamp it guessed matches.

The object is then never scanned, and carlos store scan status reports it clean, indefinitely. The two-sentence audit answer the whole feature exists to produce becomes a lie the platform tells confidently. That is worse than having no scanner.

The obvious fix does not work

This is the part I would not have predicted.

Adding an explicit Deny on s3:PutObjectTagging and s3:DeleteObjectTagging was the first thing tried. The upload still succeeded — the gateway never attributed the header to that action, so denying the action denied nothing.

What closed it was denying s3:PutObject itself when the request carries one of our tag keys:

{"Effect": "Deny", "Action": ["s3:PutObject"], "Resource": "<bucket>/*",
 "Condition": {"ForAnyValue:StringEquals": {
   "s3:RequestObjectTagKeys": ["carlos-scan", "carlos-scan-version", "carlos-scan-at"]}}}

Both Denys are now in the policy, unconditionally — not gated on whether enforcement is on, because a store that is not enforcing is exactly where a forged clean tag would sit unnoticed longest.

What changed besides the policy

The tests had encoded the old belief in five places: "two statements when enforcement is off", "Effect":"Deny" used as a proxy for "enforcement is on", and the grep helper itself. All five now say what they meant, and assertNoPutObjectTagging checks both routes.

TestDeliveredContractMinio gains the live half, against the real server .amadan/ci.d/30-minio-contract already runs on every push. Verified end to end: the policy the code generates, handed to MinIO verbatim, full contract test green against it.

The lesson, which is not "add a Deny"

A rule stated as an absence — this permission must never be granted — can only ever be checked by looking for the thing that is absent. It cannot see a second door. The rule was necessary, it was correctly enforced, and it protected the wrong shape of the property.

The version that catches this class is the one that asserts the behaviour against a real implementation: not "the policy does not contain this string" but "this credential cannot put this tag, by any route we can think of". That test now exists and runs on every push, which is the only reason the next second door will be found before a deployment rather than after one.

Related: the same contract test now also pins that HeadObject and ListObjectsV2 agree on the version string, which was the other thing scheduled to be discovered by deploying.

Triage, 2026-09-05. Keeping this open, and saying why, because on its face it looks closable: the fix is written and the lesson is recorded.

It should not close yet for two reasons.

The fix has not landed. worktree-store-scan-on-demand is sixty commits ahead of main and unmerged. Until it lands, the policy in main still has the single door, and a discussion marked closed is one nobody re-reads.

The property is not actually closed. #19 found a fifth route — a POST-policy form upload, which carries its tags in a form field and which the s3:RequestObjectTagKeys Deny does not catch on MinIO. And in triaging that one today a sixth thing turned up that is worse than any of them: the verdict is bound to etag@RFC3339, which is second-granularity and cannot be improved (Modified comes from S3's Last-Modified, an HTTP-date with no sub-second component), and the scan GetObject pins neither VersionId nor IfMatch. So the bytes scanned are not the bytes the verdict describes. Details are in #19.

That matters here because it is the same lesson one turn further on. This write-up's closing argument is that the version which catches this class asserts behaviour against a real implementation — "this credential cannot put this tag, by any route we can think of" — rather than searching a policy document for an absent string. Right, and the contract test that came out of it is the reason the POST-policy route was found at all. But "by any route we can think of" is still a list, and the sixth finding is not a route at all: it is the identity the tag is bound to being weaker than the tag. No amount of testing the write path catches that.

The generalisation worth writing down alongside the one above: a rule stated as an absence cannot see a second door, and a verdict bound to an identity the attacker influences cannot see a second object. Both were true here, and the second was invisible while everyone was looking at the first.

So: leave open, close it together with #19 when the property actually holds, and keep the write-up — it is the clearest statement of the first half and it earned the test that found the rest.