plan: the one tagging route IAM cannot close, and it is the common one
Discussion #19. Third finding in the same property, and the worst. A POST-policy form upload — the browser-direct pattern, where an app signs a policy and the browser POSTs a multipart form straight to S3 without the bytes passing through the app — carries its tags in a form FIELD. Against MinIO, with the s3:RequestObjectTagKeys Deny in place that closes every other route, an upload carrying carlos-scan=clean SUCCEEDS. 204. The control in the same run passes, so the route works and the Deny simply does not fire: the gateway does not populate that condition key for the handler. So an app marks its own uploads clean at the moment it stores them, the sweep finds a version-matching clean tag and never scans the object, and the status door reports clean forever. This is the worst of the three because it is not an exotic call an attacker reaches for. It is the standard way to take a file from a user — which is exactly the workload object-storage scanning exists for. IAM DOES NOT REACH IT. There is no condition key describing "this is a form upload", and a bucket policy uses the same keys. The fix is to make the tag unforgeable rather than unwritable: a signed fourth tag, HMAC(deployment key, bucket|key|version|verdict|at), verified in readTag, so a tag that does not verify is treated as no tag and the object gets scanned — failing in the safe direction. That closes every route at once, including the ones nobody has thought of yet, and demotes the IAM Denys to defence in depth, which is the right job for them. It needs a key the console does not have. By deliberate design it holds only CARLOS_SECRETS_PUBKEY — apiconfig.go says so in as many words — so this is a new deployment secret, the same machinery §6.3 introduces for callback signing. That is a design decision, not a patch, and it should be decided once and used for both. Not something to invent at one in the morning. THE TEST IS NOT COMMITTED, and that is deliberate. AGENTS.md requires the suite clean before a commit lands, and a red assertion on the branch is the known-failing test this repo has just spent a night paying for once. The full reproduction — both subtests and the hand-rolled SigV4 POST-policy helper, since the AWS SDK for Go has no presigner for it — is in #19 and drops straight back in when the fix lands. Nothing is lost but the red. It gates THE FIRST MEMBER GRANT, not the first deploy. Rollout steps 1-4 are inert. The moment a member's store is granted and swept, a clean verdict is being reported, and this is the route by which it can be false. Also recorded: the two routes that turned out to be already closed — multipart and copy-with-REPLACE — are done and pinned, and the version- tagging actions are in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEkJh1ESqiA84quAHJV4K6
1 file changed,
+37
−14
docs/superpowers/plans/2026-09-04-scanning-to-deployable.md+37 −14
diff --git a/docs/superpowers/plans/2026-09-04-scanning-to-deployable.md b/docs/superpowers/plans/2026-09-04-scanning-to-deployable.md| index 602391c56..62b83038a 100644 |
| --- a/docs/superpowers/plans/2026-09-04-scanning-to-deployable.md |
| +++ b/docs/superpowers/plans/2026-09-04-scanning-to-deployable.md |
| @@ -214,18 +214,37 @@ which delayed them behind another team. |
| - [x] Head/List version agreement — a contract subtest, on every push. |
| - [x] The app cannot tag its own object, **by either route** — same test. |
| -- [ ] **THREE MORE TAGGING ROUTES, untested and each authorised as plain |
| - `s3:PutObject`.** "By either route" is two routes; the property has more: |
| - `CreateMultipartUpload` with `x-amz-tagging` (any upload over the SDK's |
| - multipart threshold), `CopyObject` with `x-amz-tagging-directive: REPLACE`, |
| - and — the one that matters most — a **POST-policy form upload with a |
| - `tagging` field**, which is the standard direct-from-browser pattern, i.e. |
| - exactly what an app storing user uploads does. Whether a gateway populates |
| - `s3:RequestObjectTagKeys` in those handlers is the same gateway-specific fact |
| - that already bit once. Three more contract subtests; cheap. |
| -- [ ] Add `s3:PutObjectVersionTagging`/`s3:DeleteObjectVersionTagging` to the |
| - first Deny. Nothing in `internal/objstore` ever calls `PutBucketVersioning`, |
| - so it costs nothing and closes the shape before a bucket is ever versioned. |
| +- [ ] **THE POST-POLICY ROUTE IS OPEN, AND IAM CANNOT CLOSE IT — discussion |
| + #19.** Verified against MinIO: a browser-direct form upload carrying |
| + `carlos-scan=clean` **succeeds** (204) with the current policy, because the |
| + gateway does not populate `s3:RequestObjectTagKeys` for that handler. The |
| + control passes, so the route works and the Deny simply does not fire. It is |
| + the worst of the three findings in this property, because it is not an exotic |
| + call — it is the standard way to take a file from a user, which is the |
| + workload scanning exists for. |
| + |
| + The fix is to make the tag **unforgeable rather than unwritable**: a signed |
| + fourth tag, `HMAC(deployment key, bucket|key|version|verdict|at)`, verified in |
| + `readTag`, so a tag that does not verify is treated as no tag and the object |
| + is scanned. Gateway-independent, and it closes every route including the ones |
| + nobody has thought of. It needs a key the console does not have — by design it |
| + holds only `CARLOS_SECRETS_PUBKEY` — so it is the same new deployment secret |
| + §6.3 introduces for callback signing, and should be decided once for both. |
| + |
| + **This gates the first member grant, not the first deploy.** Rollout steps 1–4 |
| + are inert. The moment a member's store is granted and swept, a clean verdict |
| + is being reported, and this is the route by which it can be false. |
| +- [x] Two more tagging routes, closed and now pinned (`0fcba4b`): |
| + `CreateMultipartUpload` with `x-amz-tagging` and `CopyObject` with a REPLACE |
| + directive. Both **were** open in the sense that nothing tested them; the |
| + `s3:RequestObjectTagKeys` Deny already covered them, verified by mutation — |
| + remove the Deny and all of them succeed. Each carries a control asserting the |
| + verb itself still works, without which the refusal proves nothing. |
| +- [ ] ~~THREE MORE TAGGING ROUTES, untested and each authorised as plain |
| + `s3:PutObject`.~~ Done, above — and the third one is a hole, not a check. "By either route" is two routes; the property has more: |
| +- [x] `s3:PutObjectVersionTagging`/`s3:DeleteObjectVersionTagging` added to the |
| + first Deny (`0fcba4b`) — unreachable today, which is when a shape is cheapest |
| + to close. |
| - [ ] `x-amz-tagging` against **real AWS** — and for all the routes above, not |
| just the header on `PutObject`. AWS documents that one as requiring |
| `s3:PutObjectTagging`; it is far less explicit about multipart and POST |
| @@ -388,8 +407,12 @@ section is not shipped twice. |
| Not the timestamps — that is settled and pinned. The live ones: |
| -- **`x-amz-tagging` on real AWS behaving like MinIO**, by any of the routes in |
| - Phase 0.5. Then the policy fixes are not belt-and-braces on Tito but the only |
| +- **Real AWS not populating `s3:RequestObjectTagKeys` for POST policy either** |
| + (#19). Then the signed-tag fix is not a hardening for self-hosters, it is the |
| + only thing standing between an app and a forged clean verdict on Tito, and it |
| + moves ahead of tasks 11–17 rather than beside them. |
| +- **`x-amz-tagging` on real AWS behaving like MinIO**, by any of the other |
| + routes in Phase 0.5. Then the policy fixes are not belt-and-braces on Tito but the only |
| thing between an app and a forged clean verdict — which changes the urgency of |
| Phase 0.5, not the shape of Phase 1: the fixes ride the first half either way. |
| - **The `enforce` Deny not denying.** A `storePolicyJSON` change rewrites every |