Fix server side copy of files with ? in - fixes #7058 (#7059)

Before this change the CopyObjectHandler and the CopyObjectPartHandler
both looked for a `versionId` parameter on the `X-Amz-Copy-Source` URL
for the version of the object to be copied on the URL unescaped version
of the header.  This meant that files that had question marks in were
truncated after the question mark so that files with `?` in their
names could not be server side copied.

After this change the URL unescaping is done during the parsing of the
`versionId` parameter which fixes the problem.

This change also introduces the same logic for the
`X-Amz-Copy-Source-Version-Id` header field which was previously
ignored, namely returning an error if it is present and not `null`
since minio does not currently support versions.

S3 Docs:
- https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectCOPY.html
- https://docs.aws.amazon.com/AmazonS3/latest/API/mpUploadUploadPartCopy.html
This commit is contained in:
Nick Craig-Wood 2019-01-10 21:10:10 +00:00 committed by kannappanr
parent f3f47d8cd3
commit 9c26fe47b0
2 changed files with 128 additions and 22 deletions

View File

@ -653,12 +653,8 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
// TODO: Reject requests where body/payload is present, for now we don't even read it. // TODO: Reject requests where body/payload is present, for now we don't even read it.
// Copy source path. // Read escaped copy source path to check for parameters.
cpSrcPath, err := url.QueryUnescape(r.Header.Get("X-Amz-Copy-Source")) cpSrcPath := r.Header.Get("X-Amz-Copy-Source")
if err != nil {
// Save unescaped string as is.
cpSrcPath = r.Header.Get("X-Amz-Copy-Source")
}
// Check https://docs.aws.amazon.com/AmazonS3/latest/dev/ObjectVersioning.html // Check https://docs.aws.amazon.com/AmazonS3/latest/dev/ObjectVersioning.html
// Regardless of whether you have enabled versioning, each object in your bucket // Regardless of whether you have enabled versioning, each object in your bucket
@ -673,8 +669,18 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
writeErrorResponse(w, ErrNoSuchVersion, r.URL, guessIsBrowserReq(r)) writeErrorResponse(w, ErrNoSuchVersion, r.URL, guessIsBrowserReq(r))
return return
} }
// Note that url.Parse does the unescaping
cpSrcPath = u.Path cpSrcPath = u.Path
} }
if vid := r.Header.Get("X-Amz-Copy-Source-Version-Id"); vid != "" {
// Check if versionId header was added, if yes then check if
// its non "null" value, we should error out since we do not support
// any versions other than "null".
if vid != "null" {
writeErrorResponse(w, ErrNoSuchVersion, r.URL, guessIsBrowserReq(r))
return
}
}
srcBucket, srcObject := path2BucketAndObject(cpSrcPath) srcBucket, srcObject := path2BucketAndObject(cpSrcPath)
// If source object is empty or bucket is empty, reply back invalid copy source. // If source object is empty or bucket is empty, reply back invalid copy source.
@ -699,7 +705,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
} }
var srcOpts, dstOpts ObjectOptions var srcOpts, dstOpts ObjectOptions
srcOpts, err = copySrcEncryptionOpts(ctx, r, srcBucket, srcObject) srcOpts, err := copySrcEncryptionOpts(ctx, r, srcBucket, srcObject)
if err != nil { if err != nil {
logger.LogIf(ctx, err) logger.LogIf(ctx, err)
writeErrorResponse(w, toAPIErrorCode(ctx, err), r.URL, guessIsBrowserReq(r)) writeErrorResponse(w, toAPIErrorCode(ctx, err), r.URL, guessIsBrowserReq(r))
@ -1466,12 +1472,8 @@ func (api objectAPIHandlers) CopyObjectPartHandler(w http.ResponseWriter, r *htt
return return
} }
// Copy source path. // Read escaped copy source path to check for parameters.
cpSrcPath, err := url.QueryUnescape(r.Header.Get("X-Amz-Copy-Source")) cpSrcPath := r.Header.Get("X-Amz-Copy-Source")
if err != nil {
// Save unescaped string as is.
cpSrcPath = r.Header.Get("X-Amz-Copy-Source")
}
// Check https://docs.aws.amazon.com/AmazonS3/latest/dev/ObjectVersioning.html // Check https://docs.aws.amazon.com/AmazonS3/latest/dev/ObjectVersioning.html
// Regardless of whether you have enabled versioning, each object in your bucket // Regardless of whether you have enabled versioning, each object in your bucket
@ -1486,8 +1488,18 @@ func (api objectAPIHandlers) CopyObjectPartHandler(w http.ResponseWriter, r *htt
writeErrorResponse(w, ErrNoSuchVersion, r.URL, guessIsBrowserReq(r)) writeErrorResponse(w, ErrNoSuchVersion, r.URL, guessIsBrowserReq(r))
return return
} }
// Note that url.Parse does the unescaping
cpSrcPath = u.Path cpSrcPath = u.Path
} }
if vid := r.Header.Get("X-Amz-Copy-Source-Version-Id"); vid != "" {
// Check if X-Amz-Copy-Source-Version-Id header was added, if yes then check if
// its non "null" value, we should error out since we do not support
// any versions other than "null".
if vid != "null" {
writeErrorResponse(w, ErrNoSuchVersion, r.URL, guessIsBrowserReq(r))
return
}
}
srcBucket, srcObject := path2BucketAndObject(cpSrcPath) srcBucket, srcObject := path2BucketAndObject(cpSrcPath)
// If source object is empty or bucket is empty, reply back invalid copy source. // If source object is empty or bucket is empty, reply back invalid copy source.

View File

@ -25,6 +25,7 @@ import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io" "io"
"runtime"
"strings" "strings"
"io/ioutil" "io/ioutil"
@ -1523,14 +1524,15 @@ func testAPICopyObjectPartHandler(obj ObjectLayer, instanceType, bucketName stri
// test cases with inputs and expected result for Copy Object. // test cases with inputs and expected result for Copy Object.
testCases := []struct { testCases := []struct {
bucketName string bucketName string
copySourceHeader string // data for "X-Amz-Copy-Source" header. Contains the object to be copied in the URL. copySourceHeader string // data for "X-Amz-Copy-Source" header. Contains the object to be copied in the URL.
copySourceRange string // data for "X-Amz-Copy-Source-Range" header, contains the byte range offsets of data to be copied. copySourceVersionId string // data for "X-Amz-Copy-Source-Version-Id" header.
uploadID string // uploadID of the transaction. copySourceRange string // data for "X-Amz-Copy-Source-Range" header, contains the byte range offsets of data to be copied.
invalidPartNumber bool // Sets an invalid multipart. uploadID string // uploadID of the transaction.
maximumPartNumber bool // Sets a maximum parts. invalidPartNumber bool // Sets an invalid multipart.
accessKey string maximumPartNumber bool // Sets a maximum parts.
secretKey string accessKey string
secretKey string
// expected output. // expected output.
expectedRespStatus int expectedRespStatus int
}{ }{
@ -1694,6 +1696,44 @@ func testAPICopyObjectPartHandler(obj ObjectLayer, instanceType, bucketName stri
secretKey: credentials.SecretKey, secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusOK, expectedRespStatus: http.StatusOK,
}, },
// Test case - 14, copy part 1 from from newObject1 with null versionId
{
bucketName: bucketName,
uploadID: uploadID,
copySourceHeader: url.QueryEscape("/"+bucketName+"/"+objectName) + "?versionId=null",
accessKey: credentials.AccessKey,
secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusOK,
},
// Test case - 15, copy part 1 from from newObject1 with non null versionId
{
bucketName: bucketName,
uploadID: uploadID,
copySourceHeader: url.QueryEscape("/"+bucketName+"/"+objectName) + "?versionId=17",
accessKey: credentials.AccessKey,
secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusNotFound,
},
// Test case - 16, copy part 1 from from newObject1 with null X-Amz-Copy-Source-Version-Id
{
bucketName: bucketName,
uploadID: uploadID,
copySourceHeader: url.QueryEscape("/" + bucketName + "/" + objectName),
copySourceVersionId: "null",
accessKey: credentials.AccessKey,
secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusOK,
},
// Test case - 16, copy part 1 from from newObject1 with non null X-Amz-Copy-Source-Version-Id
{
bucketName: bucketName,
uploadID: uploadID,
copySourceHeader: url.QueryEscape("/" + bucketName + "/" + objectName),
copySourceVersionId: "17",
accessKey: credentials.AccessKey,
secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusNotFound,
},
} }
for i, testCase := range testCases { for i, testCase := range testCases {
@ -1717,6 +1757,9 @@ func testAPICopyObjectPartHandler(obj ObjectLayer, instanceType, bucketName stri
if testCase.copySourceHeader != "" { if testCase.copySourceHeader != "" {
req.Header.Set("X-Amz-Copy-Source", testCase.copySourceHeader) req.Header.Set("X-Amz-Copy-Source", testCase.copySourceHeader)
} }
if testCase.copySourceVersionId != "" {
req.Header.Set("X-Amz-Copy-Source-Version-Id", testCase.copySourceVersionId)
}
if testCase.copySourceRange != "" { if testCase.copySourceRange != "" {
req.Header.Set("X-Amz-Copy-Source-Range", testCase.copySourceRange) req.Header.Set("X-Amz-Copy-Source-Range", testCase.copySourceRange)
} }
@ -1752,6 +1795,9 @@ func testAPICopyObjectPartHandler(obj ObjectLayer, instanceType, bucketName stri
if testCase.copySourceHeader != "" { if testCase.copySourceHeader != "" {
reqV2.Header.Set("X-Amz-Copy-Source", testCase.copySourceHeader) reqV2.Header.Set("X-Amz-Copy-Source", testCase.copySourceHeader)
} }
if testCase.copySourceVersionId != "" {
reqV2.Header.Set("X-Amz-Copy-Source-Version-Id", testCase.copySourceVersionId)
}
if testCase.copySourceRange != "" { if testCase.copySourceRange != "" {
reqV2.Header.Set("X-Amz-Copy-Source-Range", testCase.copySourceRange) reqV2.Header.Set("X-Amz-Copy-Source-Range", testCase.copySourceRange)
} }
@ -1802,7 +1848,10 @@ func TestAPICopyObjectHandler(t *testing.T) {
func testAPICopyObjectHandler(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler, func testAPICopyObjectHandler(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler,
credentials auth.Credentials, t *testing.T) { credentials auth.Credentials, t *testing.T) {
objectName := "test-object" objectName := "test?object" // use file with ? to test URL parsing...
if runtime.GOOS == "windows" {
objectName = "test-object" // ...except on Windows
}
// object used for anonymous HTTP request test. // object used for anonymous HTTP request test.
anonObject := "anon-object" anonObject := "anon-object"
var err error var err error
@ -1861,6 +1910,7 @@ func testAPICopyObjectHandler(obj ObjectLayer, instanceType, bucketName string,
bucketName string bucketName string
newObjectName string // name of the newly copied object. newObjectName string // name of the newly copied object.
copySourceHeader string // data for "X-Amz-Copy-Source" header. Contains the object to be copied in the URL. copySourceHeader string // data for "X-Amz-Copy-Source" header. Contains the object to be copied in the URL.
copySourceVersionId string // data for "X-Amz-Copy-Source-Version-Id" header.
copyModifiedHeader string // data for "X-Amz-Copy-Source-If-Modified-Since" header copyModifiedHeader string // data for "X-Amz-Copy-Source-If-Modified-Since" header
copyUnmodifiedHeader string // data for "X-Amz-Copy-Source-If-Unmodified-Since" header copyUnmodifiedHeader string // data for "X-Amz-Copy-Source-If-Unmodified-Since" header
metadataGarbage bool metadataGarbage bool
@ -2071,6 +2121,44 @@ func testAPICopyObjectHandler(obj ObjectLayer, instanceType, bucketName string,
secretKey: credentials.SecretKey, secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusOK, expectedRespStatus: http.StatusOK,
}, },
// Test case - 17, copy metadata from newObject1 with null versionId
{
bucketName: bucketName,
newObjectName: "newObject1",
copySourceHeader: url.QueryEscape("/"+bucketName+"/"+objectName) + "?versionId=null",
accessKey: credentials.AccessKey,
secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusOK,
},
// Test case - 18, copy metadata from newObject1 with non null versionId
{
bucketName: bucketName,
newObjectName: "newObject1",
copySourceHeader: url.QueryEscape("/"+bucketName+"/"+objectName) + "?versionId=17",
accessKey: credentials.AccessKey,
secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusNotFound,
},
// Test case - 19, copy metadata from newObject1 with null X-Amz-Copy-Source-Version-Id
{
bucketName: bucketName,
newObjectName: "newObject1",
copySourceHeader: url.QueryEscape("/" + bucketName + "/" + objectName),
copySourceVersionId: "null",
accessKey: credentials.AccessKey,
secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusOK,
},
// Test case - 20, copy metadata from newObject1 with non null X-Amz-Copy-Source-Version-Id
{
bucketName: bucketName,
newObjectName: "newObject1",
copySourceHeader: url.QueryEscape("/" + bucketName + "/" + objectName),
copySourceVersionId: "17",
accessKey: credentials.AccessKey,
secretKey: credentials.SecretKey,
expectedRespStatus: http.StatusNotFound,
},
} }
for i, testCase := range testCases { for i, testCase := range testCases {
@ -2089,6 +2177,9 @@ func testAPICopyObjectHandler(obj ObjectLayer, instanceType, bucketName string,
if testCase.copySourceHeader != "" { if testCase.copySourceHeader != "" {
req.Header.Set("X-Amz-Copy-Source", testCase.copySourceHeader) req.Header.Set("X-Amz-Copy-Source", testCase.copySourceHeader)
} }
if testCase.copySourceVersionId != "" {
req.Header.Set("X-Amz-Copy-Source-Version-Id", testCase.copySourceVersionId)
}
if testCase.copyModifiedHeader != "" { if testCase.copyModifiedHeader != "" {
req.Header.Set("X-Amz-Copy-Source-If-Modified-Since", testCase.copyModifiedHeader) req.Header.Set("X-Amz-Copy-Source-If-Modified-Since", testCase.copyModifiedHeader)
} }
@ -2150,6 +2241,9 @@ func testAPICopyObjectHandler(obj ObjectLayer, instanceType, bucketName string,
if testCase.copySourceHeader != "" { if testCase.copySourceHeader != "" {
reqV2.Header.Set("X-Amz-Copy-Source", testCase.copySourceHeader) reqV2.Header.Set("X-Amz-Copy-Source", testCase.copySourceHeader)
} }
if testCase.copySourceVersionId != "" {
reqV2.Header.Set("X-Amz-Copy-Source-Version-Id", testCase.copySourceVersionId)
}
if testCase.copyModifiedHeader != "" { if testCase.copyModifiedHeader != "" {
reqV2.Header.Set("X-Amz-Copy-Source-If-Modified-Since", testCase.copyModifiedHeader) reqV2.Header.Set("X-Amz-Copy-Source-If-Modified-Since", testCase.copyModifiedHeader)
} }