carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package objstore
2
3import (
4 "bytes"
5 "context"
6 "crypto/hmac"
7 "crypto/md5"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/hex"
11 "errors"
12 "fmt"
13 "io"
14 "mime/multipart"
15 "net/http"
16 "os"
17 "strings"
18 "testing"
19 "time"
20
21 "github.com/aws/aws-sdk-go-v2/aws"
22 "github.com/aws/aws-sdk-go-v2/credentials"
23 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
24 s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
25 "github.com/aws/smithy-go"
26)
27
28// TestDeliveredContractMinio pins spec §1's delivered contract from outside
29// the package: given exactly the four values a consuming app receives after
30// grant (endpoint, key, secret, bucket) plus a second bucket the scoped user
31// was never granted, build an S3 client the way that consuming app would —
32// not via NewAWSProvisioner or any objstore helper — and exercise Put/Get/
33// List/Delete plus cross-bucket denial against a real (Minio) S3-compatible
34// server.
35//
36// This is deliberately not a unit test against a stub: internal/objstore's
37// own tests already cover the provisioner logic with fakes. What has never
38// been exercised anywhere in this tree is the actual wire contract a member
39// app's client.Options() produces from delivered env, against a real
40// S3-compatible implementation. Skipped by default because it needs a
41// running Minio; CI's 30-minio-contract step (.amadan/ci.d/30-minio-contract)
42// sets CARLOS_MINIO_TEST=1 and stands one up, as a plain process on
43// loopback rather than a container. That step is now the only place this
44// test runs live anywhere, so treat a change to it as a change to what
45// this test is worth.
46func TestDeliveredContractMinio(t *testing.T) {
47 if os.Getenv("CARLOS_MINIO_TEST") != "1" {
48 t.Skip("set CARLOS_MINIO_TEST=1 (with a running Minio and the MINIO_* env vars) to run the delivered-contract test; see .amadan/ci.d/30-minio-contract, which stands one up")
49 }
50
51 endpoint := requireEnv(t, "MINIO_ENDPOINT")
52 key := requireEnv(t, "MINIO_SCOPED_KEY")
53 secret := requireEnv(t, "MINIO_SCOPED_SECRET")
54 bucket := requireEnv(t, "MINIO_BUCKET")
55 deniedBucket := requireEnv(t, "MINIO_DENIED_BUCKET")
56
57 ctx := context.Background()
58
59 // Built exactly the way a consuming app builds it from delivered env:
60 // static credentials, region us-east-1 (spec §1's delivered region),
61 // BaseEndpoint + UsePathStyle for addressing, and the checksum
62 // WhenRequired pair — the same four option lines aws.go's
63 // NewAWSProvisioner applies for a non-empty endpoint, mirrored here
64 // independently rather than by calling into objstore's own client
65 // construction, since the whole point is to pin what a member app gets,
66 // not to test this package's internals against themselves.
67 client := awss3.New(awss3.Options{
68 Region: "us-east-1",
69 Credentials: credentials.NewStaticCredentialsProvider(key, secret, ""),
70 BaseEndpoint: aws.String(endpoint),
71 UsePathStyle: true,
72 RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
73 ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
74 })
75
76 const objectKey = "contract-test/hello.txt"
77 const body = "hello from the delivered contract test"
78
79 t.Run("put", func(t *testing.T) {
80 _, err := client.PutObject(ctx, &awss3.PutObjectInput{
81 Bucket: aws.String(bucket),
82 Key: aws.String(objectKey),
83 Body: bytes.NewReader([]byte(body)),
84 })
85 if err != nil {
86 t.Fatalf("PutObject: %v", err)
87 }
88 })
89
90 t.Run("get round-trips the body", func(t *testing.T) {
91 out, err := client.GetObject(ctx, &awss3.GetObjectInput{
92 Bucket: aws.String(bucket),
93 Key: aws.String(objectKey),
94 })
95 if err != nil {
96 t.Fatalf("GetObject: %v", err)
97 }
98 defer out.Body.Close()
99 got, err := io.ReadAll(out.Body)
100 if err != nil {
101 t.Fatalf("read GetObject body: %v", err)
102 }
103 if string(got) != body {
104 t.Fatalf("GetObject body = %q, want %q", got, body)
105 }
106 })
107
108 t.Run("list shows the key", func(t *testing.T) {
109 out, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
110 Bucket: aws.String(bucket),
111 })
112 if err != nil {
113 t.Fatalf("ListObjectsV2: %v", err)
114 }
115 if !containsKey(out.Contents, objectKey) {
116 t.Fatalf("ListObjectsV2 contents = %v, want to contain %q", keyNames(out.Contents), objectKey)
117 }
118 })
119
120 // THE VERSION A VERDICT BINDS TO HAS TO BE THE SAME STRING FROM BOTH
121 // DOORS, and this is the only place that can be checked against a real
122 // server rather than argued about.
123 //
124 // The sweep never calls HeadObject: it takes ETag, size and last-modified
125 // off the listing page (sweep.go:31) and binds its tag to that
126 // ScanObject.Version(). Status does the opposite — it heads the object to
127 // find the current version for itself (clamav.go:199) — and then compares
128 // the tag it read against ITS version, character for character
129 // (`taggedVersion != obj.Version()`).
130 //
131 // So if a gateway reports last-modified differently on the two calls —
132 // rounding one where it truncates the other — a tag the sweep just wrote
133 // reads as "nothing is known" to every door, the drain rescans, re-tags
134 // with the Head version, and the next sweep finds THAT stale and rescans
135 // again. A scan ping-pong that costs money and never converges, from two
136 // strings that differ in a second.
137 //
138 // RFC3339 truncates, so the two agree unless the server rounds. That is a
139 // narrow window, which is exactly why it wants a test and not a memo.
140 t.Run("head and list agree on the version", func(t *testing.T) {
141 lst, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
142 Bucket: aws.String(bucket),
143 Prefix: aws.String(objectKey),
144 })
145 if err != nil {
146 t.Fatalf("ListObjectsV2: %v", err)
147 }
148 var listed ScanObject
149 for _, o := range lst.Contents {
150 if aws.ToString(o.Key) != objectKey {
151 continue
152 }
153 // Exactly what s3Lister.ListPage builds (sweep.go).
154 listed = ScanObject{Key: objectKey, ETag: normalizeETag(aws.ToString(o.ETag))}
155 if o.LastModified != nil {
156 listed.Modified = *o.LastModified
157 }
158 if o.Size != nil {
159 listed.Size = *o.Size
160 }
161 }
162 if listed.ETag == "" {
163 t.Fatalf("ListObjectsV2 did not return %q", objectKey)
164 }
165
166 head, err := client.HeadObject(ctx, &awss3.HeadObjectInput{
167 Bucket: aws.String(bucket),
168 Key: aws.String(objectKey),
169 })
170 if err != nil {
171 t.Fatalf("HeadObject: %v", err)
172 }
173 // Exactly what clamavScanner.Status builds (clamav.go:212-219).
174 headed := ScanObject{Key: objectKey, ETag: normalizeETag(aws.ToString(head.ETag))}
175 if head.LastModified != nil {
176 headed.Modified = *head.LastModified
177 }
178
179 if listed.Version() != headed.Version() {
180 t.Fatalf("the sweep and the status door would bind different versions to the same bytes:\n list: %s\n head: %s\nEvery verdict the sweep writes reads as unknown, forever.", listed.Version(), headed.Version())
181 }
182 // Size only comes from the listing in the sweep's path, but Status
183 // reports it too and the drain uses it to decide the ceiling.
184 if head.ContentLength != nil && listed.Size != *head.ContentLength {
185 t.Fatalf("size disagrees: list %d, head %d", listed.Size, *head.ContentLength)
186 }
187 })
188
189 // THE ANTI-FORGERY PROPERTY, against a real IAM rather than a policy
190 // document. Everything about a clean verdict rests on one asymmetry: the
191 // console can write the carlos-scan tag and the app cannot. The app's
192 // Allow has no s3:PutObjectTagging and never will (aws.go's policy, and
193 // the standing rule in AGENTS.md) — but "the permission is absent" is only
194 // half a proof. The other half is that the server actually requires it,
195 // by BOTH routes, and that half has never been exercised anywhere.
196 //
197 // The second route is the one that would be missed. x-amz-tagging on a
198 // PutObject sets tags without any call named PutObjectTagging, and an
199 // implementation that only gates the explicit call would hand an app the
200 // ability to upload a payload pre-marked clean.
201 t.Run("the app cannot tag its own object", func(t *testing.T) {
202 t.Run("not by PutObjectTagging", func(t *testing.T) {
203 _, err := client.PutObjectTagging(ctx, &awss3.PutObjectTaggingInput{
204 Bucket: aws.String(bucket),
205 Key: aws.String(objectKey),
206 Tagging: &s3types.Tagging{TagSet: []s3types.Tag{
207 {Key: aws.String("carlos-scan"), Value: aws.String("clean")},
208 }},
209 })
210 requireDenied(t, err, "PutObjectTagging")
211 })
212
213 t.Run("and not by x-amz-tagging on upload", func(t *testing.T) {
214 _, err := client.PutObject(ctx, &awss3.PutObjectInput{
215 Bucket: aws.String(bucket),
216 Key: aws.String("contract-test/pre-tagged.txt"),
217 Body: bytes.NewReader([]byte("payload claiming to be clean")),
218 Tagging: aws.String("carlos-scan=clean"),
219 })
220 // Clean up if it somehow succeeded, so the delete subtest below
221 // is not reasoning about a bucket this one left dirty.
222 if err == nil {
223 _, _ = client.DeleteObject(ctx, &awss3.DeleteObjectInput{
224 Bucket: aws.String(bucket),
225 Key: aws.String("contract-test/pre-tagged.txt"),
226 })
227 }
228 requireDenied(t, err, "PutObject with x-amz-tagging")
229 })
230 })
231
232 // THE OTHER ROUTES. "By either route" was two routes, and the property has
233 // more than two.
234 //
235 // Each of these is authorised as plain `s3:PutObject` and carries tags
236 // somewhere other than the `x-amz-tagging` header of a simple PUT, so the
237 // Deny added for that header is not obviously in the path. Whether a gateway
238 // populates `s3:RequestObjectTagKeys` in these handlers is a gateway-specific
239 // fact of exactly the kind that already bit this design once — which is why
240 // they are asserted against a real server rather than reasoned about.
241 t.Run("the app cannot tag by any other route", func(t *testing.T) {
242 // Multipart: any upload over the SDK's threshold takes this path, so
243 // an app does not even have to choose it deliberately.
244 t.Run("not by CreateMultipartUpload", func(t *testing.T) {
245 const key = "contract-test/multipart-tagged.bin"
246 out, err := client.CreateMultipartUpload(ctx, &awss3.CreateMultipartUploadInput{
247 Bucket: aws.String(bucket),
248 Key: aws.String(key),
249 Tagging: aws.String("carlos-scan=clean"),
250 })
251 if err == nil {
252 // Nothing was completed, but the upload id exists; clean it up so
253 // the bucket is not left holding a live multipart session.
254 _, _ = client.AbortMultipartUpload(ctx, &awss3.AbortMultipartUploadInput{
255 Bucket: aws.String(bucket), Key: aws.String(key), UploadId: out.UploadId,
256 })
257 }
258 requireDenied(t, err, "CreateMultipartUpload with x-amz-tagging")
259 })
260
261 // Copy with REPLACE: the app already has GetObject and PutObject, so it
262 // can copy its own object over itself and name the tags on the way.
263 t.Run("not by CopyObject with a REPLACE tagging directive", func(t *testing.T) {
264 const src = "contract-test/copy-src.txt"
265 if _, err := client.PutObject(ctx, &awss3.PutObjectInput{
266 Bucket: aws.String(bucket),
267 Key: aws.String(src),
268 Body: bytes.NewReader([]byte("payload")),
269 }); err != nil {
270 t.Fatalf("seed the copy source: %v", err)
271 }
272 defer client.DeleteObject(ctx, &awss3.DeleteObjectInput{
273 Bucket: aws.String(bucket), Key: aws.String(src),
274 })
275
276 const dst = "contract-test/copy-tagged.txt"
277 _, err := client.CopyObject(ctx, &awss3.CopyObjectInput{
278 Bucket: aws.String(bucket),
279 Key: aws.String(dst),
280 CopySource: aws.String(bucket + "/" + src),
281 TaggingDirective: s3types.TaggingDirectiveReplace,
282 Tagging: aws.String("carlos-scan=clean"),
283 })
284 if err == nil {
285 _, _ = client.DeleteObject(ctx, &awss3.DeleteObjectInput{
286 Bucket: aws.String(bucket), Key: aws.String(dst),
287 })
288 }
289 requireDenied(t, err, "CopyObject with a REPLACE tagging directive")
290 })
291
292 // THE CONTROLS, WITHOUT WHICH THE TWO ABOVE PROVE NOTHING.
293 //
294 // Both assert AccessDenied. If these operations were denied anyway —
295 // because the app policy never permitted multipart or copy in the
296 // first place — the assertions would pass while saying nothing at all
297 // about tagging, and a later policy that lost the tag-key Deny would
298 // still look green. So each route is exercised again WITHOUT our tag
299 // keys and must succeed: that is what makes the refusals above
300 // attributable to the tag and not to the verb.
301 t.Run("but multipart itself is allowed", func(t *testing.T) {
302 const key = "contract-test/multipart-plain.bin"
303 out, err := client.CreateMultipartUpload(ctx, &awss3.CreateMultipartUploadInput{
304 Bucket: aws.String(bucket),
305 Key: aws.String(key),
306 })
307 if err != nil {
308 t.Fatalf("CreateMultipartUpload without tags = %v — the refusal above was about the VERB, not the tag, and proves nothing", err)
309 }
310 _, _ = client.AbortMultipartUpload(ctx, &awss3.AbortMultipartUploadInput{
311 Bucket: aws.String(bucket), Key: aws.String(key), UploadId: out.UploadId,
312 })
313 })
314
315 t.Run("and copy itself is allowed", func(t *testing.T) {
316 const src = "contract-test/copy-control-src.txt"
317 const dst = "contract-test/copy-control-dst.txt"
318 if _, err := client.PutObject(ctx, &awss3.PutObjectInput{
319 Bucket: aws.String(bucket), Key: aws.String(src),
320 Body: bytes.NewReader([]byte("payload")),
321 }); err != nil {
322 t.Fatalf("seed: %v", err)
323 }
324 defer client.DeleteObject(ctx, &awss3.DeleteObjectInput{
325 Bucket: aws.String(bucket), Key: aws.String(src),
326 })
327 _, err := client.CopyObject(ctx, &awss3.CopyObjectInput{
328 Bucket: aws.String(bucket),
329 Key: aws.String(dst),
330 CopySource: aws.String(bucket + "/" + src),
331 })
332 if err != nil {
333 t.Fatalf("CopyObject without tags = %v — the refusal above was about the VERB, not the tag, and proves nothing", err)
334 }
335 _, _ = client.DeleteObject(ctx, &awss3.DeleteObjectInput{
336 Bucket: aws.String(bucket), Key: aws.String(dst),
337 })
338 })
339
340 // THE ROUTE IAM CANNOT CLOSE, AND WHAT ACTUALLY CLOSES IT.
341 //
342 // A browser-direct POST-policy upload carries its tags in a form
343 // field. MinIO does not populate s3:RequestObjectTagKeys for that
344 // handler, so the Deny that stops every other route does not fire and
345 // THE UPLOAD SUCCEEDS — carlos-scan=clean and all. No IAM condition
346 // key describes a form upload, and a bucket policy uses the same keys,
347 // so nothing in a policy document prevents this.
348 //
349 // It does not need to. The tag is written and it means nothing.
350 //
351 // THIS TEST PERFORMS THE WHOLE ATTACK, and it has to, because the
352 // obvious version of it is vacuous. A forged tag carrying a nonsense
353 // carlos-scan-version is rejected on the version comparison and never
354 // reaches the signature check at all — so it passes with verification
355 // disabled, which is exactly what the first draft of this test did.
356 //
357 // The real attack is available to the app: the ETag of a single-part
358 // upload is the MD5 of bytes it chose, and last-modified is the server
359 // clock at upload to the second, which it can guess and retry until it
360 // lands. So the loop below guesses, checks, and retries — and only
361 // then asserts. A forged tag with a MATCHING version is believed by
362 // every check except the one that matters.
363 t.Run("a POST-policy forged tag with a matching version is still not a verdict", func(t *testing.T) {
364 const forged = "contract-test/post-forged.txt"
365 body := []byte("a payload the app would like called clean")
366 etag := fmt.Sprintf("%x", md5.Sum(body))
367
368 defer client.DeleteObject(ctx, &awss3.DeleteObjectInput{
369 Bucket: aws.String(bucket), Key: aws.String(forged),
370 })
371
372 var landed string
373 for attempt := 0; attempt < 8 && landed == ""; attempt++ {
374 guess := time.Now().UTC().Truncate(time.Second)
375 want := etag + "@" + guess.Format(time.RFC3339)
376
377 status, resp := postPolicyUploadBody(t, endpoint, key, secret, bucket, forged, body,
378 "<Tagging><TagSet>"+
379 "<Tag><Key>carlos-scan</Key><Value>clean</Value></Tag>"+
380 "<Tag><Key>carlos-scan-version</Key><Value>"+want+"</Value></Tag>"+
381 "<Tag><Key>carlos-scan-at</Key><Value>"+guess.Format(time.RFC3339)+"</Value></Tag>"+
382 "</TagSet></Tagging>")
383 if status < 200 || status >= 300 {
384 // A gateway that refuses this is a better world, and this
385 // test should say so rather than quietly passing.
386 t.Skipf("this gateway refused a tagged POST-policy upload (%d) — the IAM condition key reaches it here:\n%s", status, resp)
387 }
388 head, err := client.HeadObject(ctx, &awss3.HeadObjectInput{
389 Bucket: aws.String(bucket), Key: aws.String(forged),
390 })
391 if err != nil {
392 t.Fatal(err)
393 }
394 obj := ScanObject{Key: forged, ETag: normalizeETag(aws.ToString(head.ETag))}
395 if head.LastModified != nil {
396 obj.Modified = *head.LastModified
397 }
398 if obj.Version() == want {
399 landed = want
400 }
401 }
402 if landed == "" {
403 t.Skip("could not land a forged tag on a matching version in eight attempts — the attack is real but this run did not reproduce it")
404 }
405
406 // The tag really is on the object, with a version that really does
407 // match. Everything short of the signature now says "clean".
408 tags, err := client.GetObjectTagging(ctx, &awss3.GetObjectTaggingInput{
409 Bucket: aws.String(bucket), Key: aws.String(forged),
410 })
411 if err != nil {
412 t.Fatal(err)
413 }
414 var gotVerdict, gotVersion string
415 for _, tg := range tags.TagSet {
416 switch aws.ToString(tg.Key) {
417 case TagVerdict:
418 gotVerdict = aws.ToString(tg.Value)
419 case TagVersion:
420 gotVersion = aws.ToString(tg.Value)
421 }
422 }
423 if gotVerdict != string(VerdictClean) || gotVersion != landed {
424 t.Fatalf("the forgery is not in place (verdict=%q version=%q), so the refusal below proves nothing", gotVerdict, gotVersion)
425 }
426
427 // And the part that matters. The engine is nil deliberately:
428 // reaching it would mean the scanner decided to scan, which is the
429 // right outcome but not what this asserts. This asserts the forged
430 // verdict was not believed.
431 sc := &clamavScanner{
432 s3: client, engine: nil, maxBytes: 1 << 20,
433 signer: testSigner(t, "a-real-deployment-key", ""),
434 }
435 v, err := sc.Verdict(ctx, bucket, forged, landed)
436 if err != nil {
437 t.Fatalf("Verdict over a forged tag: %v", err)
438 }
439 if v != VerdictUnknown {
440 t.Fatalf("verdict = %q for a tag the APP wrote, on a real gateway, with a matching version — an app just marked its own malware clean", v)
441 }
442 })
443
444 // And the same control for the plain PUT route, which has the same
445 // hazard: an ordinary untagged upload must still work, or the Deny is
446 // firing on every write and the store is simply broken.
447 t.Run("and an ordinary tagged-with-something-else upload is allowed", func(t *testing.T) {
448 const key = "contract-test/foreign-tag.txt"
449 _, err := client.PutObject(ctx, &awss3.PutObjectInput{
450 Bucket: aws.String(bucket),
451 Key: aws.String(key),
452 Body: bytes.NewReader([]byte("payload")),
453 Tagging: aws.String("team=payments"),
454 })
455 if err != nil {
456 t.Fatalf("PutObject with a tag of the app's own = %v — the Deny is keyed on OUR tag names and must not touch a tenant's", err)
457 }
458 _, _ = client.DeleteObject(ctx, &awss3.DeleteObjectInput{
459 Bucket: aws.String(bucket), Key: aws.String(key),
460 })
461 })
462 })
463
464 t.Run("delete removes it", func(t *testing.T) {
465 _, err := client.DeleteObject(ctx, &awss3.DeleteObjectInput{
466 Bucket: aws.String(bucket),
467 Key: aws.String(objectKey),
468 })
469 if err != nil {
470 t.Fatalf("DeleteObject: %v", err)
471 }
472 out, err := client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
473 Bucket: aws.String(bucket),
474 })
475 if err != nil {
476 t.Fatalf("ListObjectsV2 after delete: %v", err)
477 }
478 if containsKey(out.Contents, objectKey) {
479 t.Fatalf("ListObjectsV2 after delete still contains %q", objectKey)
480 }
481 })
482
483 t.Run("cross-bucket denial", func(t *testing.T) {
484 _, err := client.PutObject(ctx, &awss3.PutObjectInput{
485 Bucket: aws.String(deniedBucket),
486 Key: aws.String(objectKey),
487 Body: bytes.NewReader([]byte(body)),
488 })
489 if err == nil {
490 t.Fatalf("PutObject to %q: want AccessDenied, got success", deniedBucket)
491 }
492 var apiErr smithy.APIError
493 if !errors.As(err, &apiErr) {
494 t.Fatalf("PutObject to %q: err = %v (%T), want a smithy.APIError", deniedBucket, err, err)
495 }
496 if apiErr.ErrorCode() != "AccessDenied" {
497 t.Fatalf("PutObject to %q: error code = %q, want %q (message: %s)", deniedBucket, apiErr.ErrorCode(), "AccessDenied", apiErr.ErrorMessage())
498 }
499 })
500}
501
502// requireEnv fetches name from the environment, failing the test fast with
503// a clear message if it is unset — a missing var here means the CI job's
504// env block and this test's expectations have drifted, not that the
505// contract itself is broken.
506func requireEnv(t *testing.T, name string) string {
507 t.Helper()
508 v := os.Getenv(name)
509 if v == "" {
510 t.Fatalf("%s is not set (required when CARLOS_MINIO_TEST=1)", name)
511 }
512 return v
513}
514
515// containsKey and keyNames are small helpers over ListObjectsV2's Contents so
516// the assertions above read as intent, not index arithmetic.
517func containsKey(objs []s3types.Object, key string) bool {
518 for _, o := range objs {
519 if aws.ToString(o.Key) == key {
520 return true
521 }
522 }
523 return false
524}
525
526func keyNames(objs []s3types.Object) []string {
527 names := make([]string, len(objs))
528 for i, o := range objs {
529 names[i] = aws.ToString(o.Key)
530 }
531 return names
532}
533
534// requireDenied is the assertion both tagging routes need: not merely "it
535// failed", but AccessDenied specifically. A malformed request, a 404 or a
536// signature problem would also return an error, and any of them would let a
537// broken policy pass this test while proving nothing about permission.
538func requireDenied(t *testing.T, err error, what string) {
539 t.Helper()
540 if err == nil {
541 t.Fatalf("%s SUCCEEDED — an app can mark its own object clean, and every clean verdict this deployment reports is forgeable", what)
542 }
543 var apiErr smithy.APIError
544 if !errors.As(err, &apiErr) {
545 t.Fatalf("%s: err = %v (%T), want a smithy.APIError carrying AccessDenied", what, err, err)
546 }
547 if apiErr.ErrorCode() != "AccessDenied" {
548 t.Fatalf("%s: error code = %q, want AccessDenied (message: %s)", what, apiErr.ErrorCode(), apiErr.ErrorMessage())
549 }
550}
551
552// postPolicyUpload performs a browser-style direct upload: a base64 policy
553// document signed with SigV4, POSTed as a multipart form. There is no helper
554// for this in the AWS SDK for Go, which is a good part of why this route goes
555// untested — and it is the route an app actually uses to take a file from a
556// user without proxying the bytes.
557//
558// Hand-rolled rather than pulled in: this repo adds no third-party
559// dependencies, and the whole of SigV4 for a POST policy is a four-step HMAC
560// chain over a date, a region, a service and a terminator.
561func postPolicyUpload(t *testing.T, endpoint, akid, secret, bucket, key, taggingXML string) (int, string) {
562 t.Helper()
563 return postPolicyUploadBody(t, endpoint, akid, secret, bucket, key, []byte("payload"), taggingXML)
564}
565
566// postPolicyUploadBody is the same with the bytes chosen by the caller, which
567// the forgery test needs: the ETag of a single-part upload is the MD5 of those
568// bytes, and predicting it is half of what makes the attack available.
569func postPolicyUploadBody(t *testing.T, endpoint, akid, secret, bucket, key string, body []byte, taggingXML string) (int, string) {
570 t.Helper()
571
572 const region = "us-east-1"
573 now := time.Now().UTC()
574 amzDate := now.Format("20060102T150405Z")
575 scopeDate := now.Format("20060102")
576 credential := akid + "/" + scopeDate + "/" + region + "/s3/aws4_request"
577
578 // The policy the browser would be handed. `starts-with` on $tagging is
579 // what an app would write if it wanted the browser to choose tags — which
580 // is precisely the permissive shape worth testing.
581 policy := fmt.Sprintf(`{"expiration":%q,"conditions":[
582 {"bucket":%q},
583 {"key":%q},
584 {"x-amz-algorithm":"AWS4-HMAC-SHA256"},
585 {"x-amz-credential":%q},
586 {"x-amz-date":%q},
587 ["starts-with","$tagging",""]
588 ]}`, now.Add(15*time.Minute).Format(time.RFC3339), bucket, key, credential, amzDate)
589 encoded := base64.StdEncoding.EncodeToString([]byte(policy))
590
591 mac := func(k []byte, s string) []byte {
592 h := hmac.New(sha256.New, k)
593 h.Write([]byte(s))
594 return h.Sum(nil)
595 }
596 signing := mac(mac(mac(mac([]byte("AWS4"+secret), scopeDate), region), "s3"), "aws4_request")
597 sig := hex.EncodeToString(mac(signing, encoded))
598
599 var buf bytes.Buffer
600 w := multipart.NewWriter(&buf)
601 for _, f := range [][2]string{
602 {"key", key},
603 {"x-amz-algorithm", "AWS4-HMAC-SHA256"},
604 {"x-amz-credential", credential},
605 {"x-amz-date", amzDate},
606 {"policy", encoded},
607 {"x-amz-signature", sig},
608 {"tagging", taggingXML},
609 } {
610 if err := w.WriteField(f[0], f[1]); err != nil {
611 t.Fatal(err)
612 }
613 }
614 // The file part must come last: S3 ignores anything after it.
615 part, err := w.CreateFormFile("file", "upload.txt")
616 if err != nil {
617 t.Fatal(err)
618 }
619 if _, err := part.Write(body); err != nil {
620 t.Fatal(err)
621 }
622 if err := w.Close(); err != nil {
623 t.Fatal(err)
624 }
625
626 // Path-style, matching the client the rest of this test builds.
627 req, err := http.NewRequest(http.MethodPost, strings.TrimSuffix(endpoint, "/")+"/"+bucket, &buf)
628 if err != nil {
629 t.Fatal(err)
630 }
631 req.Header.Set("Content-Type", w.FormDataContentType())
632
633 resp, err := http.DefaultClient.Do(req)
634 if err != nil {
635 t.Fatal(err)
636 }
637 defer resp.Body.Close()
638 out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
639 if err != nil {
640 t.Fatal(err)
641 }
642 return resp.StatusCode, string(out)
643}
644