feat: Add support for response-content-disposition in presigned URLs (#34)

* feat: add support for response-content-disposition in presigned URLs
- minor refactors

* remove extra comments
This commit is contained in:
Abhinav Raut
2025-11-26 12:31:22 +05:30
committed by GitHub
parent 80ea209c04
commit 171b6756e1
2 changed files with 364 additions and 63 deletions
+67 -63
View File
@@ -15,16 +15,25 @@ import (
const (
defaultPresignedHost = "s3.amazonaws.com" // <bucket>
defaultProtocol = "https://" // <bucket>
HdrXAmzSignedHeaders = "X-Amz-SignedHeaders"
)
// PresignedInput is passed to GeneratePresignedURL as a parameter.
type PresignedInput struct {
Bucket string
ObjectKey string
Method string
Timestamp time.Time
ExtraHeaders map[string]string
ExpirySeconds int
Bucket string
ObjectKey string
Method string
Timestamp time.Time
ExtraHeaders map[string]string
ExpirySeconds int
ResponseContentDisposition string
}
// awsURIEncode encodes a string per AWS S3 requirements (space as %20, not +, as Go's url.QueryEscape does)
// (https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html)
func awsURIEncode(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
// GeneratePresignedURL creates a Presigned URL that can be used
@@ -75,22 +84,47 @@ func (s3 *S3) GeneratePresignedURL(in PresignedInput) string {
}
signedHeaders["host"] = []byte(hostname)
// Build signed headers string
sortedSH := make([]string, 0, len(signedHeaders))
for name := range signedHeaders {
sortedSH = append(sortedSH, name)
}
sort.Strings(sortedSH)
signedHeadersStr := strings.Join(sortedSH, ";")
// For URL: header names must be individually escaped, semicolons remain raw
// AWS format: "host;x-amz-date" not "host%3Bx-amz-date"
var signedHeadersForURL strings.Builder
for i, name := range sortedSH {
if i > 0 {
signedHeadersForURL.WriteRune(';')
}
signedHeadersForURL.WriteString(url.QueryEscape(name))
}
// Start Canonical Request Formation
h := sha256.New() // We write the canonical request directly to the SHA256 hash.
h.Write([]byte(in.Method)) // HTTP Verb
h.Write(newLine)
h.Write([]byte(path_prefix))
h.Write([]byte{'/'})
h.Write([]byte(in.ObjectKey)) // CanonicalURL
h.Write([]byte(encodePath(in.ObjectKey))) // CanonicalURL
h.Write(newLine)
// Start QueryString Params (before SignedHeaders)
queryString := map[string]string{
"X-Amz-Algorithm": algorithm,
"X-Amz-Credential": string(cred),
"X-Amz-Date": amzdate,
"X-Amz-Expires": strconv.Itoa(in.ExpirySeconds),
"X-Amz-Algorithm": algorithm,
"X-Amz-Credential": string(cred),
"X-Amz-Date": amzdate,
"X-Amz-Expires": strconv.Itoa(in.ExpirySeconds),
HdrXAmzSignedHeaders: signedHeadersForURL.String(),
}
// Include response-content-disposition if set
if in.ResponseContentDisposition != "" {
queryString["response-content-disposition"] = in.ResponseContentDisposition
}
// include the x-amz-security-token incase we are using IAM role or AWS STS
if s3.Token != "" {
queryString["X-Amz-Security-Token"] = s3.Token
@@ -102,33 +136,20 @@ func (s3 *S3) GeneratePresignedURL(in PresignedInput) string {
for name := range queryString {
sortedQS = append(sortedQS, name)
}
sort.Strings(sortedQS) //sort by key
sortedSH := make([]string, 0, len(signedHeaders))
for name := range signedHeaders {
sortedSH = append(sortedSH, name)
}
sort.Strings(sortedSH) //sort by key
sort.Strings(sortedQS)
// Proceed to write canonical query params
for _, k := range sortedQS {
// HTTP Verb
h.Write([]byte(url.QueryEscape(k)))
for i, k := range sortedQS {
h.Write([]byte(awsURIEncode(k)))
h.Write([]byte{'='})
h.Write([]byte(url.QueryEscape(string(queryString[k]))))
h.Write([]byte{'&'})
}
h.Write([]byte("X-Amz-SignedHeaders="))
// Add Signed Headers to Query String
first := true
for i := 0; i < len(sortedSH); i++ {
if first {
h.Write([]byte(url.QueryEscape(sortedSH[i])))
first = false
// X-Amz-SignedHeaders already has properly formatted semicolons, retain as is.
if k == HdrXAmzSignedHeaders {
h.Write([]byte(queryString[k]))
} else {
h.Write([]byte{';'})
h.Write([]byte(url.QueryEscape(sortedSH[i])))
h.Write([]byte(awsURIEncode(queryString[k])))
}
if i < len(sortedQS)-1 {
h.Write([]byte{'&'})
}
}
h.Write(newLine)
@@ -145,23 +166,13 @@ func (s3 *S3) GeneratePresignedURL(in PresignedInput) string {
// End Canonical Headers
// Start Signed Headers
first = true
for i := 0; i < len(sortedSH); i++ {
if first {
h.Write([]byte(url.QueryEscape(sortedSH[i])))
first = false
} else {
h.Write([]byte{';'})
h.Write([]byte(url.QueryEscape(sortedSH[i])))
}
}
h.Write([]byte(signedHeadersStr))
h.Write(newLine)
// End Canonical Headers
// End Signed Headers
// Mention Unsigned Payload
h.Write([]byte("UNSIGNED-PAYLOAD"))
// canonicalReq := h.Bytes()
// Start StringToSign
b.WriteString(algorithm)
b.WriteRune('\n')
@@ -208,27 +219,20 @@ func (s3 *S3) GeneratePresignedURL(in PresignedInput) string {
b.WriteString(hostname)
}
b.WriteRune('/')
b.WriteString(in.ObjectKey)
b.WriteString(encodePath(in.ObjectKey))
b.WriteRune('?')
// We don't need to have a sorted order here,
// but just to preserve tests.
for i := 0; i < len(sortedQS); i++ {
b.WriteString(url.QueryEscape(sortedQS[i]))
for i, k := range sortedQS {
b.WriteString(awsURIEncode(k))
b.WriteRune('=')
b.WriteString(url.QueryEscape(string(queryString[sortedQS[i]])))
b.WriteRune('&')
}
b.WriteString("X-Amz-SignedHeaders")
b.WriteRune('=')
first = true
for i := 0; i < len(sortedSH); i++ {
if first {
b.WriteString(url.QueryEscape(sortedSH[i]))
first = false
// X-Amz-SignedHeaders already has properly formatted semicolons
if k == HdrXAmzSignedHeaders {
b.WriteString(queryString[k])
} else {
b.WriteRune(';')
b.WriteString(url.QueryEscape(sortedSH[i]))
b.WriteString(awsURIEncode(queryString[k]))
}
if i < len(sortedQS)-1 {
b.WriteRune('&')
}
}
b.WriteString("&X-Amz-Signature=")
+297
View File
@@ -2,6 +2,7 @@ package simples3
import (
"os"
"strings"
"testing"
"time"
)
@@ -119,6 +120,302 @@ func TestS3_GeneratePresignedURL_PUT(t *testing.T) {
})
}
func TestS3_GeneratePresignedURL_ResponseContentDisposition(t *testing.T) {
t.Run("BasicDisposition", func(t *testing.T) {
s := New(
os.Getenv("AWS_S3_REGION"),
os.Getenv("AWS_S3_ACCESS_KEY"),
os.Getenv("AWS_S3_SECRET_KEY"),
)
s.Endpoint = os.Getenv("AWS_S3_ENDPOINT")
url := s.GeneratePresignedURL(PresignedInput{
Bucket: os.Getenv("AWS_S3_BUCKET"),
ObjectKey: "test-download.txt",
Method: "GET",
Timestamp: nowTime(),
ExpirySeconds: 3600,
ResponseContentDisposition: "attachment; filename=\"report.pdf\"",
})
// Check that the URL contains the response-content-disposition parameter
if !strings.Contains(url, "response-content-disposition=") {
t.Errorf("URL missing response-content-disposition parameter")
}
// Check that the disposition is properly encoded
expectedDisposition := "attachment%3B%20filename%3D%22report.pdf%22"
if !strings.Contains(url, expectedDisposition) {
t.Errorf("response-content-disposition not properly encoded. URL: %v", url)
}
})
t.Run("DispositionWithSpacesAndSpecialChars", func(t *testing.T) {
s := New(
os.Getenv("AWS_S3_REGION"),
os.Getenv("AWS_S3_ACCESS_KEY"),
os.Getenv("AWS_S3_SECRET_KEY"),
)
s.Endpoint = os.Getenv("AWS_S3_ENDPOINT")
// Test with spaces in filename
url := s.GeneratePresignedURL(PresignedInput{
Bucket: os.Getenv("AWS_S3_BUCKET"),
ObjectKey: "test-download.txt",
Method: "GET",
Timestamp: nowTime(),
ExpirySeconds: 3600,
ResponseContentDisposition: "attachment; filename=\"my report 2024.pdf\"",
})
// Check that spaces are encoded as %20 not +
if strings.Contains(url, "my+report") {
t.Errorf("Spaces incorrectly encoded as +, should be %%20. URL: %v", url)
}
if !strings.Contains(url, "my%20report%202024") {
t.Errorf("Spaces not properly encoded as %%20. URL: %v", url)
}
// Test with special characters
url2 := s.GeneratePresignedURL(PresignedInput{
Bucket: os.Getenv("AWS_S3_BUCKET"),
ObjectKey: "test-download2.txt",
Method: "GET",
Timestamp: nowTime(),
ExpirySeconds: 3600,
ResponseContentDisposition: "attachment; filename=\"file+with=special&chars.pdf\"",
})
// Check that special chars are properly encoded
// + should be encoded as %2B
// = should be encoded as %3D
// & should be encoded as %26
if !strings.Contains(url2, "file%2Bwith%3Dspecial%26chars") {
t.Errorf("Special characters not properly encoded. URL: %v", url2)
}
})
}
func TestS3_GeneratePresignedURL_URLEncoding(t *testing.T) {
t.Run("VerifyQueryParameterSpaceEncoding", func(t *testing.T) {
var testTime, _ = time.Parse(time.RFC1123, "Fri, 24 May 2013 00:00:00 GMT")
s := New(
"us-east-1",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
)
// The token contains spaces that should be encoded as %20 in query params
s.SetToken("test token with spaces")
url := s.GeneratePresignedURL(PresignedInput{
Bucket: "testbucket",
ObjectKey: "test.txt",
Method: "GET",
Timestamp: testTime,
ExpirySeconds: 3600,
})
// Verify that spaces in the security token are encoded as %20 not +
if strings.Contains(url, "test+token+with+spaces") {
t.Errorf("Spaces in token incorrectly encoded as +. URL: %v", url)
}
if !strings.Contains(url, "test%20token%20with%20spaces") {
t.Errorf("Spaces in token not properly encoded as %%20. URL: %v", url)
}
})
t.Run("VerifyCredentialEncoding", func(t *testing.T) {
var testTime, _ = time.Parse(time.RFC1123, "Fri, 24 May 2013 00:00:00 GMT")
// Test with special characters in the access key that need encoding
s := New(
"us-east-1",
"KEY+WITH=SPECIAL&CHARS",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
)
url := s.GeneratePresignedURL(PresignedInput{
Bucket: "testbucket",
ObjectKey: "test.txt",
Method: "GET",
Timestamp: testTime,
ExpirySeconds: 3600,
})
// Check that special characters in credentials are properly encoded
// + should be %2B, = should be %3D, & should be %26
if strings.Contains(url, "KEY+WITH=SPECIAL&CHARS") {
t.Errorf("Special chars in credential not encoded. URL: %v", url)
}
if !strings.Contains(url, "KEY%2BWITH%3DSPECIAL%26CHARS") {
t.Errorf("Special chars in credential not properly encoded. URL: %v", url)
}
})
}
func TestS3_GeneratePresignedURL_ResponseContentDisposition_PUT(t *testing.T) {
t.Run("PUTMethodWithDisposition", func(t *testing.T) {
s := New(
os.Getenv("AWS_S3_REGION"),
os.Getenv("AWS_S3_ACCESS_KEY"),
os.Getenv("AWS_S3_SECRET_KEY"),
)
s.Endpoint = os.Getenv("AWS_S3_ENDPOINT")
// Test PUT method with response-content-disposition
url := s.GeneratePresignedURL(PresignedInput{
Bucket: os.Getenv("AWS_S3_BUCKET"),
ObjectKey: "upload-test.txt",
Method: "PUT",
Timestamp: nowTime(),
ExpirySeconds: 3600,
ResponseContentDisposition: "attachment; filename=\"uploaded-file.txt\"",
})
// Check that the URL contains the response-content-disposition parameter
if !strings.Contains(url, "response-content-disposition=") {
t.Errorf("PUT URL missing response-content-disposition parameter")
}
// Check that it contains the PUT-specific parameters
if !strings.Contains(url, "X-Amz-Expires=3600") {
t.Errorf("PUT URL missing X-Amz-Expires parameter")
}
// Verify encoding is correct for PUT as well
expectedDisposition := "attachment%3B%20filename%3D%22uploaded-file.txt%22"
if !strings.Contains(url, expectedDisposition) {
t.Errorf("response-content-disposition not properly encoded in PUT URL. URL: %v", url)
}
})
t.Run("CompareGETandPUTWithSameDisposition", func(t *testing.T) {
s := New(
os.Getenv("AWS_S3_REGION"),
os.Getenv("AWS_S3_ACCESS_KEY"),
os.Getenv("AWS_S3_SECRET_KEY"),
)
s.Endpoint = os.Getenv("AWS_S3_ENDPOINT")
fixedTime := nowTime()
disposition := "inline; filename=\"document.pdf\""
// Generate GET URL
getURL := s.GeneratePresignedURL(PresignedInput{
Bucket: os.Getenv("AWS_S3_BUCKET"),
ObjectKey: "test-file.pdf",
Method: "GET",
Timestamp: fixedTime,
ExpirySeconds: 3600,
ResponseContentDisposition: disposition,
})
// Generate PUT URL with same parameters
putURL := s.GeneratePresignedURL(PresignedInput{
Bucket: os.Getenv("AWS_S3_BUCKET"),
ObjectKey: "test-file.pdf",
Method: "PUT",
Timestamp: fixedTime,
ExpirySeconds: 3600,
ResponseContentDisposition: disposition,
})
// Both should have the disposition parameter
if !strings.Contains(getURL, "response-content-disposition=") {
t.Errorf("GET URL missing response-content-disposition")
}
if !strings.Contains(putURL, "response-content-disposition=") {
t.Errorf("PUT URL missing response-content-disposition")
}
// The signatures will be different due to different HTTP methods
// but both should encode the disposition the same way
expectedEncodedDisposition := "inline%3B%20filename%3D%22document.pdf%22"
if !strings.Contains(getURL, expectedEncodedDisposition) {
t.Errorf("GET URL disposition encoding incorrect")
}
if !strings.Contains(putURL, expectedEncodedDisposition) {
t.Errorf("PUT URL disposition encoding incorrect")
}
})
}
func TestS3_GeneratePresignedURL_ObjectKeyEncoding(t *testing.T) {
t.Run("ObjectKeyWithSpaces", func(t *testing.T) {
s := New(
"us-east-1",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
)
url := s.GeneratePresignedURL(PresignedInput{
Bucket: "mybucket",
ObjectKey: "folder/file with spaces.txt",
Method: "GET",
Timestamp: nowTime(),
ExpirySeconds: 3600,
})
// This test will currently FAIL because spaces are not encoded
// The URL contains raw spaces which makes it invalid
if strings.Contains(url, "file with spaces") {
t.Errorf("CRITICAL BUG: Spaces in object key are not encoded! Invalid URL generated: %v", url)
}
// Should contain encoded spaces
if !strings.Contains(url, "file%20with%20spaces") {
t.Skipf("WARNING: Object key encoding not implemented. Raw URL: %v", url)
}
})
t.Run("ObjectKeyWithSpecialChars", func(t *testing.T) {
s := New(
"us-east-1",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
)
// Test with various special characters that MUST be encoded
testCases := []struct {
objectKey string
mustNotContain string // Raw character that should be encoded
shouldContain string // Expected encoded version
description string
}{
{
"file&name.txt",
"file&name",
"file%26name",
"Ampersand breaks URL query string parsing",
},
{
"file#anchor.txt",
"file#anchor",
"file%23anchor",
"Hash creates URL fragment",
},
{
"file?query.txt",
"file?query",
"file%3Fquery",
"Question mark starts query string prematurely",
},
}
for _, tc := range testCases {
url := s.GeneratePresignedURL(PresignedInput{
Bucket: "mybucket",
ObjectKey: tc.objectKey,
Method: "GET",
Timestamp: nowTime(),
ExpirySeconds: 3600,
})
if strings.Contains(url, tc.mustNotContain) {
t.Errorf("CRITICAL BUG - %s: Special char not encoded in object key! Invalid URL: %v",
tc.description, url)
}
}
})
}
func BenchmarkS3_GeneratePresigned(b *testing.B) {
// run the Fib function b.N times
s := New(