carlos / platform Public

A POST-policy form upload can still forge a clean verdict — the one route IAM cannot close open

The anti-forgery property is still open on one route, and it is the route an app actually uses. Found against a real server tonight, on worktree-store-scan-on-demand, before any deployment has scanning switched on.

This is the third finding in the same property. #18 has the first two.

What is open

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 ever passing through the app — carries its tags in a form field, not a header. Against MinIO RELEASE.2025-09-07T16-13-09Z, with the current app policy including the s3:RequestObjectTagKeys Deny that closes every other route:

a POST-policy upload carrying carlos-scan SUCCEEDED (204)

The control in the same test passes — a POST-policy upload without our tag keys also succeeds — so the route is permitted and working, and the Deny simply does not fire. MinIO does not populate s3:RequestObjectTagKeys for this handler.

An app can therefore mark its own uploads clean at the moment it stores them. The sweep finds a version-matching clean tag and never scans the object; carlos store scan status reports it clean, permanently.

This is the worst of the three, because it is not an exotic API call an attacker has to reach for. It is the standard way to take a file from a user — which is exactly the workload object-storage scanning exists for.

Why IAM cannot close it

s3:PutObjectTagging, x-amz-tagging on PutObject, CreateMultipartUpload and CopyObject with a REPLACE directive are all closed, and verified closed by mutation (remove the Deny, all four succeed; restore it, all four refuse). Those work because the gateway populates the condition key.

For POST policy it does not, and there is no other condition key that describes "this request is a form upload". A bucket policy uses the same condition keys and has the same gap. The tool does not reach.

Whether real AWS populates it here is unverified and worth checking — Tito's app buckets are AWS, so Tito may be unaffected. But self-hosters are in scope by decision, and the property has to hold on the gateway rather than on the paperwork. That was the lesson of #18 and it applies again.

What would close it

Make the tag unforgeable rather than unwritable. Sign it: a fourth tag carrying HMAC(deployment key, bucket|key|version|verdict|at), verified in readTag. A tag that does not verify is treated as no tag at all, so a forged one causes the object to be scanned rather than skipped — which is the safe direction.

That is gateway-independent, and it closes every route at once including the ones nobody has thought of yet. The IAM Denys stay as defence in depth, which is the right role for them.

It needs a key the console does not currently have. By deliberate design the console holds only CARLOS_SECRETS_PUBKEY, a public key — apiconfig.go says so explicitly: "the console holds no such key". So this needs a new deployment secret, which is the same machinery §6.3 introduces for callback signing (HMAC(master, sqid + "/" + app)), and that is a design decision rather than a patch. It should be decided once and used for both.

Why the test is not committed

AGENTS.md requires go test ./... clean before a commit lands, so the failing assertion is not on the branch — it would be the known-failing test this repo has just paid for once. The reproduction is below and drops straight back into TestDeliveredContractMinio the moment the fix lands. Nothing is lost but the red.

Gate

This must be closed before the first member grant, not before the first deploy. Steps 1–4 of the rollout 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 that verdict can be false.

The reproduction

Two subtests plus the helper. There is no POST-policy presigner in the AWS SDK for Go, which is a fair part of why this route goes untested; the SigV4 for it is a four-step HMAC chain and needs no dependency.

		// POST POLICY: THE ROUTE THAT MATTERS MOST, and the only one with no
		// SDK helper — which is part of why it goes untested.
		//
		// A browser-direct upload is the standard way an app takes a file from
		// a user without proxying the bytes: the app signs a policy, the
		// browser POSTs a multipart form straight to S3. It is a completely
		// separate handler from PutObject, it carries its tags in a form
		// FIELD rather than a header, and it is exactly the shape an app
		// storing user uploads has. If `s3:RequestObjectTagKeys` is not
		// populated here, an app marks its own uploads clean at the moment of
		// upload and nothing else in this test file would notice.
		t.Run("not by a POST-policy form upload", func(t *testing.T) {
			status, body := postPolicyUpload(t, endpoint, key, secret, bucket,
				"contract-test/post-tagged.txt",
				"<Tagging><TagSet><Tag><Key>carlos-scan</Key><Value>clean</Value></Tag></TagSet></Tagging>")
			if status >= 200 && status < 300 {
				t.Fatalf("a POST-policy upload carrying carlos-scan SUCCEEDED (%d) — an app can mark its own uploads clean at the moment it stores them:\n%s", status, body)
			}
			if status != 403 {
				t.Fatalf("POST-policy upload = %d, want 403 AccessDenied:\n%s", status, body)
			}
		})

		// The control, for the same reason as the two above: if POST-policy
		// uploads were refused outright — MinIO not supporting them, a
		// malformed policy on our side — the assertion above would pass while
		// testing nothing at all.
		t.Run("but a POST-policy upload without our tags is allowed", func(t *testing.T) {
			const k = "contract-test/post-plain.txt"
			status, body := postPolicyUpload(t, endpoint, key, secret, bucket, k,
				"<Tagging><TagSet><Tag><Key>team</Key><Value>payments</Value></Tag></TagSet></Tagging>")
			if status < 200 || status >= 300 {
				t.Fatalf("POST-policy upload without our tags = %d — the refusal above was about the ROUTE, not the tag, and proves nothing:\n%s", status, body)
			}
			_, _ = client.DeleteObject(ctx, &awss3.DeleteObjectInput{
				Bucket: aws.String(bucket), Key: aws.String(k),
			})
		})
// postPolicyUpload performs a browser-style direct upload: a base64 policy
// document signed with SigV4, POSTed as a multipart form. There is no helper
// for this in the AWS SDK for Go, which is a good part of why this route goes
// untested — and it is the route an app actually uses to take a file from a
// user without proxying the bytes.
//
// Hand-rolled rather than pulled in: this repo adds no third-party
// dependencies, and the whole of SigV4 for a POST policy is a four-step HMAC
// chain over a date, a region, a service and a terminator.
func postPolicyUpload(t *testing.T, endpoint, akid, secret, bucket, key, taggingXML string) (int, string) {
	t.Helper()

	const region = "us-east-1"
	now := time.Now().UTC()
	amzDate := now.Format("20060102T150405Z")
	scopeDate := now.Format("20060102")
	credential := akid + "/" + scopeDate + "/" + region + "/s3/aws4_request"

	// The policy the browser would be handed. `starts-with` on $tagging is
	// what an app would write if it wanted the browser to choose tags — which
	// is precisely the permissive shape worth testing.
	policy := fmt.Sprintf(`{"expiration":%q,"conditions":[
		{"bucket":%q},
		{"key":%q},
		{"x-amz-algorithm":"AWS4-HMAC-SHA256"},
		{"x-amz-credential":%q},
		{"x-amz-date":%q},
		["starts-with","$tagging",""]
	]}`, now.Add(15*time.Minute).Format(time.RFC3339), bucket, key, credential, amzDate)
	encoded := base64.StdEncoding.EncodeToString([]byte(policy))

	mac := func(k []byte, s string) []byte {
		h := hmac.New(sha256.New, k)
		h.Write([]byte(s))
		return h.Sum(nil)
	}
	signing := mac(mac(mac(mac([]byte("AWS4"+secret), scopeDate), region), "s3"), "aws4_request")
	sig := hex.EncodeToString(mac(signing, encoded))

	var buf bytes.Buffer
	w := multipart.NewWriter(&buf)
	for _, f := range [][2]string{
		{"key", key},
		{"x-amz-algorithm", "AWS4-HMAC-SHA256"},
		{"x-amz-credential", credential},
		{"x-amz-date", amzDate},
		{"policy", encoded},
		{"x-amz-signature", sig},
		{"tagging", taggingXML},
	} {
		if err := w.WriteField(f[0], f[1]); err != nil {
			t.Fatal(err)
		}
	}
	// The file part must come last: S3 ignores anything after it.
	part, err := w.CreateFormFile("file", "upload.txt")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := part.Write([]byte("payload")); err != nil {
		t.Fatal(err)
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}

	// Path-style, matching the client the rest of this test builds.
	req, err := http.NewRequest(http.MethodPost, strings.TrimSuffix(endpoint, "/")+"/"+bucket, &buf)
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Content-Type", w.FormDataContentType())

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
	if err != nil {
		t.Fatal(err)
	}
	return resp.StatusCode, string(body)
}

Imports it needs: crypto/hmac, crypto/sha256, encoding/base64, encoding/hex, fmt, mime/multipart, net/http, strings, time.

Triage, 2026-09-05. Read-only: no code changed, nothing merged. Two findings that change this issue — one narrows it, one widens it.

Real AWS is almost certainly not exposed

I put this to a second model (GPT-5.6, independently, with no access to my reasoning) and then checked its answer against the AWS documentation myself rather than taking it on trust. AWS states it directly:

> s3:RequestObjectTagKeys — ... This is useful when adding tags to objects > using the PutObjectTagging and PutObject, and POST object requests.

POST object is named explicitly. So the Deny that closes the other routes should fire here on real AWS, and this reads as a MinIO / compatible-store gap rather than a fleet-wide one. That materially changes the gate: Tito's app buckets are real AWS, so Tito is likely unaffected, and "must be closed before the first member grant" applies to self-hosters and to CI, not to the flagship.

I want to be precise about what is and is not established. The MinIO result is measured and real. The AWS claim is documentation, not an observation. The test that settles it is worth stating because the obvious version of it proves the wrong thing: give a test principal BOTH s3:PutObject and s3:PutObjectTagging, with only the conditional PutObject Deny, and POST a policy form carrying our tag keys. If that refuses, the condition key fired. If you test with a principal that lacks PutObjectTagging, a refusal only proves AWS checked the tagging action — which is exactly the confusion that produced finding #18.

Also worth noting from the same page: it names PutObjectTagging, PutObject and POST object. It does not name CreateMultipartUpload or CopyObject. Those two are verified closed against MinIO, and unverified against AWS.

The signed tag would sign a lie

This is the part that matters more, and it is not in the write-up above.

ScanObject.Version() (internal/objstore/sweep.go:50) is normalizeETag(o.ETag) + "@" + o.Modified.UTC().Format(time.RFC3339). The comment beside it makes the security argument explicitly — the ETag alone is MD5 and "chosen-prefix MD5 collisions are practical", so last-modified is carrying the other half of the defence.

RFC3339 is second granularity. And that cannot be fixed by reaching for RFC3339Nano, because Modified comes from S3's Last-Modified (clamav.go:213, sweep.go:89) and an HTTP-date has no sub-second component by definition. The precision is not being discarded by our formatting; it was never available.

So the second half of the defence is a one-second window, against an attacker who already controls the first half.

Compounding it: clamav.go:252 does GetObject with no VersionId and no IfMatch. The bytes scanned are not pinned to the version the verdict is written against, and tag() writes onto whatever object is current.

The consequence is worse than tag forgery. An attacker who lands a colliding overwrite inside that window gets the platform to mint a genuine, correctly signed, clean verdict over malicious bytes. An HMAC does not help — the signature would be perfectly valid. It would make the lie harder to detect, not harder to tell.

What I think this means for the fix

The HMAC closes "an app writes a tag the console did not write". It does not close "the tag the console wrote does not describe the bytes that are there now", and the second is the property the feature actually needs.

A server-issued VersionId closes both: pin the GET to it, bind the verdict to it, and the object identity stops being anything the app can influence. That costs bucket versioning and a contract test per backend, and it removes the need for a new deployment secret — which was the part of the HMAC design that made it a §6.3-sized decision rather than a patch.

An IfMatch: etag on the scan GET is a cheap interim narrowing (the scan fails rather than scanning bytes it will not tag), but it leaves the MD5 collision the comment already calls practical.

Recommendation: do not build the HMAC yet. Decide VersionId vs signed tag first, because if it is VersionId then the new deployment secret is not needed at all and the §6.3 coupling goes away. Both open questions are yours.

Not filing the version-string half separately — it is the same property and splitting it would lose the connection. Say if you would rather it were its own thread.