Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 7 additions & 24 deletions presigned.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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{'&'})
}
Expand Down Expand Up @@ -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('&')
}
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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{'&'})
}
Expand Down Expand Up @@ -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('&')
}
Expand Down
98 changes: 93 additions & 5 deletions presigned_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package simples3

import (
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
Expand Down Expand Up @@ -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) {
Expand Down