| 1 | package objstore |
| 2 | |
| 3 | import ( |
| 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. |
| 46 | func 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. |
| 506 | func 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. |
| 517 | func 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 | |
| 526 | func 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. |
| 538 | func 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. |
| 561 | func 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. |
| 569 | func 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 | |