Created
July 24, 2026 18:34
-
-
Save Madrigal/27ccd834d4623bf5179f789199a90807 to your computer and use it in GitHub Desktop.
Reproduction scenario for https://github.com/aws/aws-sdk-go-v2/issues/3490
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| module sse-c-repro | |
| go 1.24.1 | |
| require ( | |
| github.com/aws/aws-sdk-go-v2 v1.43.0 | |
| github.com/aws/aws-sdk-go-v2/config v1.32.31 | |
| github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5 | |
| github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 | |
| ) | |
| require ( | |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect | |
| github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect | |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect | |
| github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect | |
| github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect | |
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect | |
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect | |
| github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect | |
| github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect | |
| github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect | |
| github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect | |
| github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect | |
| github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect | |
| github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect | |
| github.com/aws/smithy-go v1.27.3 // indirect | |
| ) | |
| replace ( | |
| github.com/aws/aws-sdk-go-v2 => /tmp/aws-sdk-go-v2 | |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream => /tmp/aws-sdk-go-v2/aws/protocol/eventstream | |
| github.com/aws/aws-sdk-go-v2/config => /tmp/aws-sdk-go-v2/config | |
| github.com/aws/aws-sdk-go-v2/credentials => /tmp/aws-sdk-go-v2/credentials | |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds => /tmp/aws-sdk-go-v2/feature/ec2/imds | |
| github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager => /tmp/aws-sdk-go-v2/feature/s3/transfermanager | |
| github.com/aws/aws-sdk-go-v2/internal/configsources => /tmp/aws-sdk-go-v2/internal/configsources | |
| github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 => /tmp/aws-sdk-go-v2/internal/endpoints/v2 | |
| github.com/aws/aws-sdk-go-v2/internal/v4a => /tmp/aws-sdk-go-v2/internal/v4a | |
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding => /tmp/aws-sdk-go-v2/service/internal/accept-encoding | |
| github.com/aws/aws-sdk-go-v2/service/internal/checksum => /tmp/aws-sdk-go-v2/service/internal/checksum | |
| github.com/aws/aws-sdk-go-v2/service/internal/presigned-url => /tmp/aws-sdk-go-v2/service/internal/presigned-url | |
| github.com/aws/aws-sdk-go-v2/service/internal/s3shared => /tmp/aws-sdk-go-v2/service/internal/s3shared | |
| github.com/aws/aws-sdk-go-v2/service/s3 => /tmp/aws-sdk-go-v2/service/s3 | |
| github.com/aws/aws-sdk-go-v2/service/sso => /tmp/aws-sdk-go-v2/service/sso | |
| github.com/aws/aws-sdk-go-v2/service/ssooidc => /tmp/aws-sdk-go-v2/service/ssooidc | |
| github.com/aws/aws-sdk-go-v2/service/sts => /tmp/aws-sdk-go-v2/service/sts | |
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Reproduction for https://github.com/aws/aws-sdk-go-v2/issues/3490 | |
| // | |
| // The S3 transfer manager's GetObject() calls HeadObject internally to discover | |
| // object size before issuing parallel ranged GETs. These HeadObject calls were | |
| // not forwarding SSE-C parameters from the user's input, causing S3 to reject | |
| // the request for SSE-C encrypted objects. | |
| // | |
| // This program: | |
| // 1. Generates a 256-bit AES key (or loads one from .sse-key if re-running) | |
| // 2. Creates an S3 bucket with SSE-C unblocked (blocked by default since April 2026) | |
| // 3. Uploads a 20MB object encrypted with SSE-C | |
| // 4. Attempts to download it via transfermanager.GetObject in GetObjectRanges mode | |
| // | |
| // Without the fix, step 4 fails because the internal HeadObject call is missing | |
| // the SSE-C headers and S3 returns 400 Bad Request. | |
| // | |
| // The SSE-C key is persisted to .sse-key so re-runs skip the upload step. | |
| package main | |
| import ( | |
| "context" | |
| "crypto/md5" | |
| "crypto/rand" | |
| "encoding/base64" | |
| "encoding/json" | |
| "fmt" | |
| "io" | |
| "log" | |
| "os" | |
| "strings" | |
| "github.com/aws/aws-sdk-go-v2/aws" | |
| "github.com/aws/aws-sdk-go-v2/config" | |
| "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager" | |
| "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager/types" | |
| "github.com/aws/aws-sdk-go-v2/service/s3" | |
| s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" | |
| ) | |
| const ( | |
| bucket = "sdk-sse-c-repro-891377375035" | |
| key = "test-sse-c-object" | |
| region = "us-east-1" | |
| keyFile = ".sse-key" | |
| objectMB = 20 // large enough to trigger parallel ranged GETs | |
| ) | |
| // sseKey holds the base64-encoded encryption key and its MD5, | |
| // persisted to disk so subsequent runs can skip re-uploading. | |
| type sseKey struct { | |
| Key string `json:"key"` | |
| KeyMD5 string `json:"key_md5"` | |
| } | |
| // generateAndSaveKey creates a random 32-byte AES key, computes its MD5, | |
| // and writes both (base64-encoded) to .sse-key for reuse. | |
| func generateAndSaveKey() sseKey { | |
| raw := make([]byte, 32) | |
| if _, err := rand.Read(raw); err != nil { | |
| log.Fatal("failed to generate key:", err) | |
| } | |
| sum := md5.Sum(raw) | |
| k := sseKey{ | |
| Key: base64.StdEncoding.EncodeToString(raw), | |
| KeyMD5: base64.StdEncoding.EncodeToString(sum[:]), | |
| } | |
| data, _ := json.MarshalIndent(k, "", " ") | |
| if err := os.WriteFile(keyFile, data, 0600); err != nil { | |
| log.Fatal("failed to save key file:", err) | |
| } | |
| fmt.Printf("Generated new SSE-C key and saved to %s\n\n", keyFile) | |
| return k | |
| } | |
| // loadKey reads a previously-generated key from .sse-key. | |
| func loadKey() (sseKey, bool) { | |
| data, err := os.ReadFile(keyFile) | |
| if err != nil { | |
| return sseKey{}, false | |
| } | |
| var k sseKey | |
| if err := json.Unmarshal(data, &k); err != nil { | |
| return sseKey{}, false | |
| } | |
| fmt.Printf("Loaded existing SSE-C key from %s\n\n", keyFile) | |
| return k, true | |
| } | |
| // ensureBucket creates the bucket and unblocks SSE-C. | |
| // Since April 2026, S3 blocks SSE-C by default on new buckets. We must | |
| // explicitly set BlockedEncryptionTypes to NONE via PutBucketEncryption. | |
| func ensureBucket(ctx context.Context, s3Client *s3.Client) { | |
| _, err := s3Client.CreateBucket(ctx, &s3.CreateBucketInput{ | |
| Bucket: aws.String(bucket), | |
| }) | |
| if err != nil && !strings.Contains(err.Error(), "BucketAlreadyOwnedByYou") && | |
| !strings.Contains(err.Error(), "BucketAlreadyExists") { | |
| log.Fatal("CreateBucket failed:", err) | |
| } | |
| // https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html | |
| // This was trickier to do in 2026 | |
| fmt.Println("==> Enabling SSE-C on bucket (blocked by default since April 2026)...") | |
| _, err = s3Client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{ | |
| Bucket: aws.String(bucket), | |
| ServerSideEncryptionConfiguration: &s3types.ServerSideEncryptionConfiguration{ | |
| Rules: []s3types.ServerSideEncryptionRule{ | |
| { | |
| ApplyServerSideEncryptionByDefault: &s3types.ServerSideEncryptionByDefault{ | |
| SSEAlgorithm: s3types.ServerSideEncryptionAes256, | |
| }, | |
| BucketKeyEnabled: aws.Bool(false), | |
| BlockedEncryptionTypes: &s3types.BlockedEncryptionTypes{ | |
| EncryptionType: []s3types.EncryptionType{s3types.EncryptionTypeNone}, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }) | |
| if err != nil { | |
| log.Fatal("PutBucketEncryption failed:", err) | |
| } | |
| } | |
| // uploadTestObject puts a 20MB object encrypted with the given SSE-C key. | |
| // The size is chosen to exceed the transfer manager's default part size (8MB), | |
| // ensuring parallel ranged GETs are triggered. | |
| func uploadTestObject(ctx context.Context, s3Client *s3.Client, k sseKey) { | |
| fmt.Printf("==> Uploading %dMB SSE-C encrypted object to s3://%s/%s ...\n", objectMB, bucket, key) | |
| body := strings.NewReader(strings.Repeat("A", objectMB*1024*1024)) | |
| _, err := s3Client.PutObject(ctx, &s3.PutObjectInput{ | |
| Bucket: aws.String(bucket), | |
| Key: aws.String(key), | |
| Body: body, | |
| SSECustomerAlgorithm: aws.String("AES256"), | |
| SSECustomerKey: aws.String(k.Key), | |
| SSECustomerKeyMD5: aws.String(k.KeyMD5), | |
| }) | |
| if err != nil { | |
| log.Fatal("PutObject failed:", err) | |
| } | |
| fmt.Println(" Upload successful.") | |
| } | |
| // objectExists checks whether the SSE-C object is accessible with the given key. | |
| // Used to skip re-uploading on subsequent runs. | |
| func objectExists(ctx context.Context, s3Client *s3.Client, k sseKey) bool { | |
| _, err := s3Client.HeadObject(ctx, &s3.HeadObjectInput{ | |
| Bucket: aws.String(bucket), | |
| Key: aws.String(key), | |
| SSECustomerAlgorithm: aws.String("AES256"), | |
| SSECustomerKey: aws.String(k.Key), | |
| SSECustomerKeyMD5: aws.String(k.KeyMD5), | |
| }) | |
| return err == nil | |
| } | |
| // runRepro exercises the bug: it calls transfermanager.GetObject with SSE-C | |
| // params in GetObjectRanges mode. The transfer manager internally calls | |
| // HeadObject to discover the object size. Without the fix, HeadObject doesn't | |
| // receive the SSE-C headers and S3 rejects the request. | |
| func runRepro(ctx context.Context, s3Client *s3.Client, k sseKey) { | |
| fmt.Println("\n==> Running transfermanager.GetObject with SSE-C params (GetObjectRanges mode)...") | |
| fmt.Println(" Internally this calls HeadObject without forwarding SSE-C params.") | |
| fmt.Println(" Expected result: 403 AccessDenied\n") | |
| tmClient := transfermanager.New(s3Client, func(o *transfermanager.Options) { | |
| o.GetObjectType = types.GetObjectRanges | |
| }) | |
| out, err := tmClient.GetObject(ctx, &transfermanager.GetObjectInput{ | |
| Bucket: aws.String(bucket), | |
| Key: aws.String(key), | |
| SSECustomerAlgorithm: aws.String("AES256"), | |
| SSECustomerKey: aws.String(k.Key), | |
| SSECustomerKeyMD5: aws.String(k.KeyMD5), | |
| }) | |
| if err != nil { | |
| fmt.Printf("[BUG REPRODUCED] transfermanager.GetObject failed:\n %v\n", err) | |
| return | |
| } | |
| n, _ := io.Copy(io.Discard, out.Body) | |
| fmt.Printf("[NOT REPRODUCED] GetObject succeeded — bug may already be fixed. Bytes read: %d\n", n) | |
| } | |
| func main() { | |
| ctx := context.Background() | |
| cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) | |
| if err != nil { | |
| log.Fatal("failed to load AWS config:", err) | |
| } | |
| s3Client := s3.NewFromConfig(cfg) | |
| // Load or generate the SSE-C key. On first run we generate and persist it; | |
| // on subsequent runs we load it from disk to avoid re-uploading. | |
| k, exists := loadKey() | |
| if !exists { | |
| k = generateAndSaveKey() | |
| ensureBucket(ctx, s3Client) | |
| uploadTestObject(ctx, s3Client, k) | |
| } else if !objectExists(ctx, s3Client, k) { | |
| // Key exists but object is gone (e.g. bucket was cleaned up) — re-upload | |
| fmt.Println("==> Object not found, re-uploading...") | |
| ensureBucket(ctx, s3Client) | |
| uploadTestObject(ctx, s3Client, k) | |
| } else { | |
| fmt.Printf("==> Object already exists at s3://%s/%s, skipping upload.\n\n", bucket, key) | |
| } | |
| runRepro(ctx, s3Client, k) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment