diff --git a/presigned.go b/presigned.go index 7b067b9..f2d50ab 100644 --- a/presigned.go +++ b/presigned.go @@ -80,7 +80,8 @@ func (s3 *S3) GeneratePresignedURL(in PresignedInput) string { // Add host to Headers signedHeaders := map[string][]byte{} for k, v := range in.ExtraHeaders { - signedHeaders[k] = []byte(v) + // AWS requires header names to be lowercase per spec + signedHeaders[strings.ToLower(k)] = []byte(v) } signedHeaders["host"] = []byte(hostname) @@ -142,12 +143,7 @@ func (s3 *S3) GeneratePresignedURL(in PresignedInput) string { for i, k := range sortedQS { h.Write([]byte(awsURIEncode(k))) h.Write([]byte{'='}) - // X-Amz-SignedHeaders already has properly formatted semicolons, retain as is. - if k == HdrXAmzSignedHeaders { - h.Write([]byte(queryString[k])) - } else { - h.Write([]byte(awsURIEncode(queryString[k]))) - } + h.Write([]byte(awsURIEncode(queryString[k]))) if i < len(sortedQS)-1 { h.Write([]byte{'&'}) } @@ -225,12 +221,7 @@ func (s3 *S3) GeneratePresignedURL(in PresignedInput) string { for i, k := range sortedQS { b.WriteString(awsURIEncode(k)) b.WriteRune('=') - // X-Amz-SignedHeaders already has properly formatted semicolons - if k == HdrXAmzSignedHeaders { - b.WriteString(queryString[k]) - } else { - b.WriteString(awsURIEncode(queryString[k])) - } + b.WriteString(awsURIEncode(queryString[k])) if i < len(sortedQS)-1 { b.WriteRune('&') } @@ -293,6 +284,7 @@ func (s3 *S3) GeneratePresignedUploadPartURL(in PresignedMultipartInput) string } // Add host to Headers + // AWS requires header names to be lowercase per spec signedHeaders := map[string][]byte{ "host": []byte(hostname), } @@ -351,11 +343,7 @@ func (s3 *S3) GeneratePresignedUploadPartURL(in PresignedMultipartInput) string h.Write([]byte(awsURIEncode(k))) h.Write([]byte{'='}) // X-Amz-SignedHeaders already has properly formatted semicolons, retain as is. - if k == HdrXAmzSignedHeaders { - h.Write([]byte(queryString[k])) - } else { - h.Write([]byte(awsURIEncode(queryString[k]))) - } + h.Write([]byte(awsURIEncode(queryString[k]))) if i < len(sortedQS)-1 { h.Write([]byte{'&'}) } @@ -424,12 +412,7 @@ func (s3 *S3) GeneratePresignedUploadPartURL(in PresignedMultipartInput) string for i, k := range sortedQS { b.WriteString(awsURIEncode(k)) b.WriteRune('=') - // X-Amz-SignedHeaders already has properly formatted semicolons - if k == HdrXAmzSignedHeaders { - b.WriteString(queryString[k]) - } else { - b.WriteString(awsURIEncode(queryString[k])) - } + b.WriteString(awsURIEncode(queryString[k])) if i < len(sortedQS)-1 { b.WriteRune('&') } diff --git a/presigned_test.go b/presigned_test.go index 91e9ec3..4d1f606 100644 --- a/presigned_test.go +++ b/presigned_test.go @@ -1,6 +1,9 @@ package simples3 import ( + "fmt" + "io" + "net/http" "os" "strings" "testing" @@ -83,20 +86,105 @@ func TestS3_GeneratePresignedURL_ExtraHeader(t *testing.T) { os.Getenv("AWS_S3_SECRET_KEY"), ) s.Endpoint = os.Getenv("AWS_S3_ENDPOINT") - dontwant := "" - if got := s.GeneratePresignedURL(PresignedInput{ + got := s.GeneratePresignedURL(PresignedInput{ Bucket: os.Getenv("AWS_S3_BUCKET"), ObjectKey: "test2.txt", Method: "GET", Timestamp: nowTime(), ExpirySeconds: 3600, ExtraHeaders: map[string]string{ - "x-amz-meta-test": "test", + "X-Amz-Meta-Test": "test", + "Content-Length": "12345", }, - }); got == dontwant { - t.Errorf("S3.GeneratePresignedURL() = %v, dontwant %v", got, dontwant) + }) + if got == "" { + t.Errorf("S3.GeneratePresignedURL() returned empty string") + } + wantSignedHeaders := "X-Amz-SignedHeaders=content-length%3Bhost%3Bx-amz-meta-test" + if !strings.Contains(got, wantSignedHeaders) { + t.Errorf("S3.GeneratePresignedURL() missing expected SignedHeaders format. Want to contain: %v, URL: %v", wantSignedHeaders, got) } }) + + t.Run("IntegrationTest", func(t *testing.T) { + // This test validates presigned URLs with extra signed headers against a real S3 service. + // MinIO doesn't fully support this feature, so the test will skip if MinIO is detected. + // Set TEST_REAL_S3=true to run this test (requires AWS S3 or Cloudflare R2). + if os.Getenv("TEST_REAL_S3") != "true" { + t.Skip("Skipping AWS S3 integration test. Set TEST_REAL_S3=true to run.") + } + + // Skip if MinIO is detected (known limitation) + endpoint := os.Getenv("AWS_S3_ENDPOINT") + if strings.Contains(strings.ToLower(endpoint), "minio") || strings.Contains(strings.ToLower(endpoint), "localhost:9000") { + t.Skip("MinIO detected - presigned URLs with custom signed headers not supported") + } + + s3 := New( + os.Getenv("AWS_S3_REGION"), + os.Getenv("AWS_S3_ACCESS_KEY"), + os.Getenv("AWS_S3_SECRET_KEY"), + ) + + // Set endpoint if provided (for Cloudflare R2, etc.) + if endpoint != "" { + s3.SetEndpoint(endpoint) + } + + testContent := "test content for presigned URL" + + headers := map[string]string{ + "X-Amz-Meta-Test": "testvalue", + "X-Amz-Meta-Author": "integration-test", + "Content-Length": fmt.Sprintf("%d", len(testContent)), + "Content-Type": "text/plain", + } + + urlWithHeaders := s3.GeneratePresignedURL(PresignedInput{ + Bucket: os.Getenv("AWS_S3_BUCKET"), + ObjectKey: "presigned-upload-test-with-headers.txt", + Method: "PUT", + Timestamp: nowTime(), + ExpirySeconds: 3600, + ExtraHeaders: headers, + }) + + req, _ := http.NewRequest("PUT", urlWithHeaders, strings.NewReader(testContent)) + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to PUT with extra headers: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 204 { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("Presigned URL with extra headers failed with status %d. Body: %s", resp.StatusCode, string(bodyBytes)) + } + + readResp, err := s3.FileDownload(DownloadInput{ + Bucket: os.Getenv("AWS_S3_BUCKET"), + ObjectKey: "presigned-upload-test-with-headers.txt", + }) + if err != nil { + t.Fatalf("Failed to download uploaded object: %v", err) + } + defer readResp.Close() + + readContent, _ := io.ReadAll(readResp) + if string(readContent) != testContent { + t.Fatalf("Content mismatch. Expected: %q, Got: %q", testContent, string(readContent)) + } + + defer s3.FileDelete(DeleteInput{ + Bucket: os.Getenv("AWS_S3_BUCKET"), + ObjectKey: "presigned-upload-test-with-headers.txt", + }) + }) + } func TestS3_GeneratePresignedURL_PUT(t *testing.T) {