From 6794f14a244e334bd3fbd3ee7c0579492f1b1718 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 14:12:01 +0000 Subject: [PATCH] Fix S3 filename header escaping Use an ASCII-only fallback for Content-Disposition filename and RFC 5987 path escaping for filename* so presigned S3 responses keep spaces and Unicode filenames interoperable. Signed-off-by: Cursor Agent Co-authored-by: Ludovic --- pkg/filemanager/s3.go | 22 +++++++++++++++++++--- pkg/filemanager/url_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/pkg/filemanager/s3.go b/pkg/filemanager/s3.go index ebebe5c5f..1a166177c 100644 --- a/pkg/filemanager/s3.go +++ b/pkg/filemanager/s3.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "net/url" + "strings" "time" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -118,11 +119,10 @@ func (s *Service) GeneratePresignedURL( ) (string, error) { presignClient := s3.NewPresignClient(s.s3Client) - encodedFilename := url.QueryEscape(file.FileName) contentDisposition := fmt.Sprintf( "attachment; filename=%q; filename*=UTF-8''%s", - encodedFilename, - encodedFilename, + asciiFilename(file.FileName), + url.PathEscape(file.FileName), ) presignedReq, err := presignClient.PresignGetObject( @@ -145,6 +145,22 @@ func (s *Service) GeneratePresignedURL( return presignedReq.URL, nil } +func asciiFilename(filename string) string { + var b strings.Builder + b.Grow(len(filename)) + + for _, r := range filename { + if r < 0x20 || r > 0x7e { + b.WriteByte('_') + continue + } + + b.WriteRune(r) + } + + return b.String() +} + // GetFileSize determines the byte size of a seekable io.Reader by seeking to // the end and back. Returns an error if content is not seekable. func GetFileSize(content io.Reader) (int64, error) { diff --git a/pkg/filemanager/url_test.go b/pkg/filemanager/url_test.go index bae2606a0..b97946b90 100644 --- a/pkg/filemanager/url_test.go +++ b/pkg/filemanager/url_test.go @@ -15,9 +15,16 @@ package filemanager_test import ( + "context" + "net/url" "testing" + "time" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/filemanager" @@ -65,3 +72,33 @@ func TestGenerateFileURL_PrivateFile(t *testing.T) { svc.GenerateFileURL(file), ) } + +func TestGeneratePresignedFileURL_EscapesContentDispositionFilename(t *testing.T) { + t.Parallel() + + s3Client := awss3.NewFromConfig( + aws.Config{ + Region: "us-east-1", + Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""), + }, + ) + svc := filemanager.NewService(nil, nil, s3Client) + file := &coredata.File{ + BucketName: "uploads", + FileKey: "tenant/file", + FileName: `report "Q2"/résumé 100%.pdf`, + MimeType: "application/pdf", + } + + rawURL, err := svc.GeneratePresignedFileURL(context.Background(), file, time.Hour) + require.NoError(t, err) + + parsedURL, err := url.Parse(rawURL) + require.NoError(t, err) + + assert.Equal( + t, + `attachment; filename="report \"Q2\"/r_sum_ 100%.pdf"; filename*=UTF-8''report%20%22Q2%22%2Fr%C3%A9sum%C3%A9%20100%25.pdf`, + parsedURL.Query().Get("response-content-disposition"), + ) +}