diff --git a/backend/azure/azure.go b/backend/azure/azure.go index ea5439f7..cdcb7a45 100644 --- a/backend/azure/azure.go +++ b/backend/azure/azure.go @@ -68,6 +68,7 @@ const ( keyObjRetention key = "Objectretention" keyObjLegalHold key = "Objectlegalhold" keyExpires key = "Vgwexpires" + keyWebsiteRedirect key = "Vgwwebsiteredirect" onameAttr key = "Objname" onameAttrLower key = "objname" metaTmpMultipartPrefix key = ".sgwtmp" + "/multipart" @@ -84,18 +85,19 @@ const ( func (key) Table() map[string]struct{} { return map[string]struct{}{ - "acl": {}, - "ownership": {}, - "tags": {}, - "policy": {}, - "bucketlock": {}, - "website": {}, - "objectretention": {}, - "vgwexpires": {}, - "objectlegalhold": {}, - "objname": {}, - ".sgwtmp/multipart": {}, - "mpmetadata": {}, + "acl": {}, + "ownership": {}, + "tags": {}, + "policy": {}, + "bucketlock": {}, + "website": {}, + "objectretention": {}, + "vgwexpires": {}, + "vgwwebsiteredirect": {}, + "objectlegalhold": {}, + "objname": {}, + ".sgwtmp/multipart": {}, + "mpmetadata": {}, } } @@ -372,6 +374,15 @@ func (az *Azure) PutObject(ctx context.Context, po s3response.PutObjectInput) (s metadata[string(keyExpires)] = po.Expires } } + if getString(po.WebsiteRedirectLocation) != "" { + if metadata == nil { + metadata = map[string]*string{ + string(keyWebsiteRedirect): po.WebsiteRedirectLocation, + } + } else { + metadata[string(keyWebsiteRedirect)] = po.WebsiteRedirectLocation + } + } opts := &blockblob.UploadStreamOptions{ Metadata: metadata, @@ -571,22 +582,23 @@ func (az *Azure) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.G } return &s3.GetObjectOutput{ - AcceptRanges: backend.GetPtrFromString("bytes"), - ContentLength: blobDownloadResponse.ContentLength, - ContentEncoding: blobDownloadResponse.ContentEncoding, - ContentType: blobDownloadResponse.ContentType, - ContentDisposition: blobDownloadResponse.ContentDisposition, - ContentLanguage: blobDownloadResponse.ContentLanguage, - CacheControl: blobDownloadResponse.CacheControl, - ExpiresString: blobDownloadResponse.Metadata[string(keyExpires)], - ETag: backend.GetPtrFromString(convertAzureEtag(blobDownloadResponse.ETag)), - LastModified: blobDownloadResponse.LastModified, - Metadata: parseAndFilterAzMetadata(blobDownloadResponse.Metadata), - TagCount: &tagcount, - ContentRange: contentRange, - Body: blobDownloadResponse.Body, - StorageClass: types.StorageClassStandard, - PartsCount: partsCount, + AcceptRanges: backend.GetPtrFromString("bytes"), + ContentLength: blobDownloadResponse.ContentLength, + ContentEncoding: blobDownloadResponse.ContentEncoding, + ContentType: blobDownloadResponse.ContentType, + ContentDisposition: blobDownloadResponse.ContentDisposition, + ContentLanguage: blobDownloadResponse.ContentLanguage, + CacheControl: blobDownloadResponse.CacheControl, + ExpiresString: blobDownloadResponse.Metadata[string(keyExpires)], + WebsiteRedirectLocation: blobDownloadResponse.Metadata[string(keyWebsiteRedirect)], + ETag: backend.GetPtrFromString(convertAzureEtag(blobDownloadResponse.ETag)), + LastModified: blobDownloadResponse.LastModified, + Metadata: parseAndFilterAzMetadata(blobDownloadResponse.Metadata), + TagCount: &tagcount, + ContentRange: contentRange, + Body: blobDownloadResponse.Body, + StorageClass: types.StorageClassStandard, + PartsCount: partsCount, }, nil } @@ -670,20 +682,21 @@ func (az *Azure) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3 } result := &s3.HeadObjectOutput{ - ContentRange: contentRange, - AcceptRanges: backend.GetPtrFromString("bytes"), - ContentLength: &length, - PartsCount: partsCount, - ContentType: resp.ContentType, - ContentEncoding: resp.ContentEncoding, - ContentLanguage: resp.ContentLanguage, - ContentDisposition: resp.ContentDisposition, - CacheControl: resp.CacheControl, - ExpiresString: resp.Metadata[string(keyExpires)], - ETag: backend.GetPtrFromString(convertAzureEtag(resp.ETag)), - LastModified: resp.LastModified, - Metadata: parseAndFilterAzMetadata(resp.Metadata), - StorageClass: types.StorageClassStandard, + ContentRange: contentRange, + AcceptRanges: backend.GetPtrFromString("bytes"), + ContentLength: &length, + PartsCount: partsCount, + ContentType: resp.ContentType, + ContentEncoding: resp.ContentEncoding, + ContentLanguage: resp.ContentLanguage, + ContentDisposition: resp.ContentDisposition, + CacheControl: resp.CacheControl, + ExpiresString: resp.Metadata[string(keyExpires)], + WebsiteRedirectLocation: resp.Metadata[string(keyWebsiteRedirect)], + ETag: backend.GetPtrFromString(convertAzureEtag(resp.ETag)), + LastModified: resp.LastModified, + Metadata: parseAndFilterAzMetadata(resp.Metadata), + StorageClass: types.StorageClassStandard, } status, ok := resp.Metadata[string(keyObjLegalHold)] @@ -1193,6 +1206,9 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu if getString(input.Expires) != "" { meta[string(keyExpires)] = *input.Expires } + if getString(input.WebsiteRedirectLocation) != "" { + meta[string(keyWebsiteRedirect)] = *input.WebsiteRedirectLocation + } // Set object metadata _, err = dstClient.SetMetadata(ctx, parseMetadata(meta), nil) if err != nil { @@ -1273,6 +1289,7 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu ContentLanguage: input.ContentLanguage, CacheControl: input.CacheControl, Expires: input.Expires, + WebsiteRedirectLocation: input.WebsiteRedirectLocation, Metadata: input.Metadata, ObjectLockRetainUntilDate: input.ObjectLockRetainUntilDate, ObjectLockMode: input.ObjectLockMode, @@ -1288,6 +1305,7 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu pInput.ContentLanguage = downloadResp.ContentLanguage pInput.ContentType = downloadResp.ContentType pInput.Metadata = parseAzMetadata(downloadResp.Metadata) + delete(pInput.Metadata, string(keyWebsiteRedirect)) } if input.TaggingDirective == types.TaggingDirectiveReplace { @@ -1397,6 +1415,9 @@ func (az *Azure) CreateMultipartUpload(ctx context.Context, input s3response.Cre if getString(input.Expires) != "" { meta[string(keyExpires)] = input.Expires } + if getString(input.WebsiteRedirectLocation) != "" { + meta[string(keyWebsiteRedirect)] = input.WebsiteRedirectLocation + } // parse object tags tags, err := backend.ParseObjectTags(getString(input.Tagging)) diff --git a/backend/common.go b/backend/common.go index 536f7a4d..01dfd40a 100644 --- a/backend/common.go +++ b/backend/common.go @@ -531,7 +531,7 @@ func DecompressData(data []byte) ([]byte, error) { return nil, err } if closeErr != nil { - return nil, err + return nil, closeErr } return decompressed, nil diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 8494ec64..b8073463 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -122,6 +122,7 @@ const ( contentDispHdr = "content-disposition" cacheCtrlHdr = "cache-control" expiresHdr = "expires" + websiteRedirectHdr = "website-redirect-location" emptyMD5 = "\"d41d8cd98f00b204e9800998ecf8427e\"" aclkey = "acl" ownershipkey = "ownership" @@ -1549,13 +1550,14 @@ func (p *Posix) CreateMultipartUpload(ctx context.Context, mpu s3response.Create err = p.storeObjectMetaProperties(nil, bucket, filepath.Join(objdir, uploadID), metaProperties{ - ContentType: mpu.ContentType, - ContentEncoding: mpu.ContentEncoding, - ContentDisposition: mpu.ContentDisposition, - ContentLanguage: mpu.ContentLanguage, - CacheControl: mpu.CacheControl, - Expires: mpu.Expires, - Metadata: mpu.Metadata, + ContentType: mpu.ContentType, + ContentEncoding: mpu.ContentEncoding, + ContentDisposition: mpu.ContentDisposition, + ContentLanguage: mpu.ContentLanguage, + CacheControl: mpu.CacheControl, + Expires: mpu.Expires, + WebsiteRedirectLocation: mpu.WebsiteRedirectLocation, + Metadata: mpu.Metadata, }) if err != nil { // cleanup object if returning error @@ -2403,13 +2405,14 @@ func (p *Posix) checkUploadIDExists(bucket, object, uploadID string) ([32]byte, } type metaProperties struct { - ContentType *string - ContentEncoding *string - ContentDisposition *string - ContentLanguage *string - CacheControl *string - Expires *string - Metadata map[string]string + ContentType *string + ContentEncoding *string + ContentDisposition *string + ContentLanguage *string + CacheControl *string + Expires *string + WebsiteRedirectLocation *string + Metadata map[string]string } // loadObjectMetadata loads the given object metadata, if it fails to load @@ -2508,6 +2511,11 @@ func (p *Posix) loadObjectMetaProperties(f *os.File, bucket, object string, fi * result.Expires = backend.GetPtrFromString(string(b)) } + b, err = p.meta.RetrieveAttribute(f, bucket, object, websiteRedirectHdr) + if err == nil { + result.WebsiteRedirectLocation = backend.GetPtrFromString(string(b)) + } + result.Metadata = p.loadObjectMetadata(f, bucket, object) return result @@ -2587,6 +2595,12 @@ func (p *Posix) storeObjectMetaProperties(f *os.File, bucket, object string, m m return fmt.Errorf("set expires: %w", err) } } + if getString(m.WebsiteRedirectLocation) != "" { + err := p.meta.StoreAttribute(f, bucket, object, websiteRedirectHdr, []byte(*m.WebsiteRedirectLocation)) + if err != nil { + return fmt.Errorf("set website-redirect-location: %w", err) + } + } if m.Metadata != nil { err := p.storeObjectMetadata(f, bucket, object, m.Metadata) if err != nil { @@ -3682,6 +3696,14 @@ func (p *Posix) PutObjectWithPostFunc(ctx context.Context, po s3response.PutObje return s3response.PutObjectOutput{}, fmt.Errorf("set content-type attr: %w", err) } + if getString(po.WebsiteRedirectLocation) != "" { + err = p.meta.StoreAttribute(nil, *po.Bucket, *po.Key, websiteRedirectHdr, + []byte(*po.WebsiteRedirectLocation)) + if err != nil { + return s3response.PutObjectOutput{}, fmt.Errorf("set website-redirect-location attr: %w", err) + } + } + expectedSum := getEmptyChecksumValue(checksumAlgorithm) if checksumValue != "" && expectedSum != checksumValue { return s3response.PutObjectOutput{}, s3err.GetChecksumBadDigestErr(checksumAlgorithm) @@ -3879,13 +3901,14 @@ func (p *Posix) PutObjectWithPostFunc(ctx context.Context, po s3response.PutObje err = p.storeObjectMetaProperties(f.File(), *po.Bucket, *po.Key, metaProperties{ - ContentType: po.ContentType, - ContentEncoding: po.ContentEncoding, - ContentLanguage: po.ContentLanguage, - ContentDisposition: po.ContentDisposition, - CacheControl: po.CacheControl, - Expires: po.Expires, - Metadata: po.Metadata, + ContentType: po.ContentType, + ContentEncoding: po.ContentEncoding, + ContentLanguage: po.ContentLanguage, + ContentDisposition: po.ContentDisposition, + CacheControl: po.CacheControl, + Expires: po.Expires, + WebsiteRedirectLocation: po.WebsiteRedirectLocation, + Metadata: po.Metadata, }) if err != nil { return s3response.PutObjectOutput{}, err @@ -4583,32 +4606,33 @@ func (p *Posix) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.Ge var length int64 = 0 return &s3.GetObjectOutput{ - ChecksumCRC32: checksums.CRC32, - ChecksumCRC32C: checksums.CRC32C, - ChecksumSHA1: checksums.SHA1, - ChecksumSHA256: checksums.SHA256, - ChecksumCRC64NVME: checksums.CRC64NVME, - ChecksumSHA512: checksums.SHA512, - ChecksumMD5: checksums.MD5, - ChecksumXXHASH64: checksums.XXHASH64, - ChecksumXXHASH3: checksums.XXHASH3, - ChecksumXXHASH128: checksums.XXHASH128, - ChecksumType: checksums.Type, - AcceptRanges: backend.GetPtrFromString("bytes"), - ContentLength: &length, - ContentEncoding: objMeta.ContentEncoding, - ContentType: objMeta.ContentType, - ContentLanguage: objMeta.ContentLanguage, - ContentDisposition: objMeta.ContentDisposition, - CacheControl: objMeta.CacheControl, - ExpiresString: objMeta.Expires, - ETag: &etag, - LastModified: backend.GetTimePtr(fid.ModTime()), - Metadata: objMeta.Metadata, - TagCount: tagCount, - ContentRange: nil, - StorageClass: types.StorageClassStandard, - VersionId: &versionId, + ChecksumCRC32: checksums.CRC32, + ChecksumCRC32C: checksums.CRC32C, + ChecksumSHA1: checksums.SHA1, + ChecksumSHA256: checksums.SHA256, + ChecksumCRC64NVME: checksums.CRC64NVME, + ChecksumSHA512: checksums.SHA512, + ChecksumMD5: checksums.MD5, + ChecksumXXHASH64: checksums.XXHASH64, + ChecksumXXHASH3: checksums.XXHASH3, + ChecksumXXHASH128: checksums.XXHASH128, + ChecksumType: checksums.Type, + AcceptRanges: backend.GetPtrFromString("bytes"), + ContentLength: &length, + ContentEncoding: objMeta.ContentEncoding, + ContentType: objMeta.ContentType, + ContentLanguage: objMeta.ContentLanguage, + ContentDisposition: objMeta.ContentDisposition, + CacheControl: objMeta.CacheControl, + ExpiresString: objMeta.Expires, + WebsiteRedirectLocation: objMeta.WebsiteRedirectLocation, + ETag: &etag, + LastModified: backend.GetTimePtr(fid.ModTime()), + Metadata: objMeta.Metadata, + TagCount: tagCount, + ContentRange: nil, + StorageClass: types.StorageClassStandard, + VersionId: &versionId, }, nil } @@ -4735,34 +4759,35 @@ func (p *Posix) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.Ge } return &s3.GetObjectOutput{ - AcceptRanges: backend.GetPtrFromString("bytes"), - ContentLength: &length, - ContentEncoding: objMeta.ContentEncoding, - ContentType: objMeta.ContentType, - ContentDisposition: objMeta.ContentDisposition, - ContentLanguage: objMeta.ContentLanguage, - CacheControl: objMeta.CacheControl, - ExpiresString: objMeta.Expires, - ETag: &etag, - LastModified: backend.GetTimePtr(fi.ModTime()), - Metadata: objMeta.Metadata, - TagCount: tagCount, - ContentRange: contentRange, - StorageClass: types.StorageClassStandard, - VersionId: &versionId, - Body: body, - ChecksumCRC32: checksums.CRC32, - ChecksumCRC32C: checksums.CRC32C, - ChecksumSHA1: checksums.SHA1, - ChecksumSHA256: checksums.SHA256, - ChecksumCRC64NVME: checksums.CRC64NVME, - ChecksumSHA512: checksums.SHA512, - ChecksumMD5: checksums.MD5, - ChecksumXXHASH64: checksums.XXHASH64, - ChecksumXXHASH3: checksums.XXHASH3, - ChecksumXXHASH128: checksums.XXHASH128, - ChecksumType: checksums.Type, - PartsCount: partsCount, + AcceptRanges: backend.GetPtrFromString("bytes"), + ContentLength: &length, + ContentEncoding: objMeta.ContentEncoding, + ContentType: objMeta.ContentType, + ContentDisposition: objMeta.ContentDisposition, + ContentLanguage: objMeta.ContentLanguage, + CacheControl: objMeta.CacheControl, + ExpiresString: objMeta.Expires, + WebsiteRedirectLocation: objMeta.WebsiteRedirectLocation, + ETag: &etag, + LastModified: backend.GetTimePtr(fi.ModTime()), + Metadata: objMeta.Metadata, + TagCount: tagCount, + ContentRange: contentRange, + StorageClass: types.StorageClassStandard, + VersionId: &versionId, + Body: body, + ChecksumCRC32: checksums.CRC32, + ChecksumCRC32C: checksums.CRC32C, + ChecksumSHA1: checksums.SHA1, + ChecksumSHA256: checksums.SHA256, + ChecksumCRC64NVME: checksums.CRC64NVME, + ChecksumSHA512: checksums.SHA512, + ChecksumMD5: checksums.MD5, + ChecksumXXHASH64: checksums.XXHASH64, + ChecksumXXHASH3: checksums.XXHASH3, + ChecksumXXHASH128: checksums.XXHASH128, + ChecksumType: checksums.Type, + PartsCount: partsCount, }, nil } @@ -5012,6 +5037,7 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3. ContentLanguage: objMeta.ContentLanguage, CacheControl: objMeta.CacheControl, ExpiresString: objMeta.Expires, + WebsiteRedirectLocation: objMeta.WebsiteRedirectLocation, ETag: &etag, LastModified: backend.GetTimePtr(fi.ModTime()), Metadata: objMeta.Metadata, @@ -5326,17 +5352,26 @@ func (p *Posix) CopyObject(ctx context.Context, input s3response.CopyObjectInput // Store the provided object meta properties err = p.storeObjectMetaProperties(nil, dstBucket, dstObject, metaProperties{ - ContentType: input.ContentType, - ContentEncoding: input.ContentEncoding, - ContentLanguage: input.ContentLanguage, - ContentDisposition: input.ContentDisposition, - CacheControl: input.CacheControl, - Expires: input.Expires, - Metadata: input.Metadata, + ContentType: input.ContentType, + ContentEncoding: input.ContentEncoding, + ContentLanguage: input.ContentLanguage, + ContentDisposition: input.ContentDisposition, + CacheControl: input.CacheControl, + Expires: input.Expires, + WebsiteRedirectLocation: input.WebsiteRedirectLocation, + Metadata: input.Metadata, }) if err != nil { return s3response.CopyObjectOutput{}, err } + // explicitly delete the website redirect location, as if it's not + // provided as CopyObject input, it should not be copied + if getString(input.WebsiteRedirectLocation) == "" { + err := p.meta.DeleteAttribute(dstBucket, dstObject, websiteRedirectHdr) + if err != nil && !errors.Is(err, meta.ErrNoSuchKey) { + return s3response.CopyObjectOutput{}, fmt.Errorf("delete website-redirect-location: %w", err) + } + } if input.TaggingDirective == types.TaggingDirectiveReplace { tags, err := backend.ParseObjectTags(getString(input.Tagging)) @@ -5375,6 +5410,7 @@ func (p *Posix) CopyObject(ctx context.Context, input s3response.CopyObjectInput ContentLanguage: input.ContentLanguage, CacheControl: input.CacheControl, Expires: input.Expires, + WebsiteRedirectLocation: input.WebsiteRedirectLocation, Metadata: input.Metadata, ObjectLockRetainUntilDate: input.ObjectLockRetainUntilDate, ObjectLockMode: input.ObjectLockMode, diff --git a/s3api/controllers/bucket-post.go b/s3api/controllers/bucket-post.go index 1fd0a825..1015d8dd 100644 --- a/s3api/controllers/bucket-post.go +++ b/s3api/controllers/bucket-post.go @@ -111,6 +111,7 @@ func (c S3ApiController) POSTObject(ctx *fiber.Ctx) (*Response, error) { contentLanguage := parsed.Fields["content-language"] cacheControl := parsed.Fields["cache-control"] expires := parsed.Fields["expires"] + websiteRedirectLocation := parsed.Fields["x-amz-website-redirect-location"] key := parsed.Fields["key"] @@ -196,29 +197,39 @@ func (c S3ApiController) POSTObject(ctx *fiber.Ctx) (*Response, error) { }, err } + err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + res, err := c.be.PutObject(ctx.Context(), s3response.PutObjectInput{ - Bucket: &bucket, - Key: &key, - ContentType: &contentType, - ContentEncoding: &contentEncoding, - ContentDisposition: &contentDisposition, - ContentLanguage: &contentLanguage, - CacheControl: &cacheControl, - Expires: &expires, - Body: parsed.FileRdr, - ContentLength: &parsed.ContentLength, - Tagging: &tagging, - Metadata: metadata, - ChecksumCRC32: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32]), - ChecksumCRC32C: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32c]), - ChecksumSHA1: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha1]), - ChecksumSHA256: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha256]), - ChecksumCRC64NVME: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc64nvme]), - ChecksumSHA512: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha512]), - ChecksumMD5: utils.GetStringPtr(checksums[types.ChecksumAlgorithmMd5]), - ChecksumXXHASH64: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash64]), - ChecksumXXHASH3: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash3]), - ChecksumXXHASH128: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash128]), + Bucket: &bucket, + Key: &key, + ContentType: &contentType, + ContentEncoding: &contentEncoding, + ContentDisposition: &contentDisposition, + ContentLanguage: &contentLanguage, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &websiteRedirectLocation, + Body: parsed.FileRdr, + ContentLength: &parsed.ContentLength, + Tagging: &tagging, + Metadata: metadata, + ChecksumCRC32: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32]), + ChecksumCRC32C: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32c]), + ChecksumSHA1: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha1]), + ChecksumSHA256: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha256]), + ChecksumCRC64NVME: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc64nvme]), + ChecksumSHA512: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha512]), + ChecksumMD5: utils.GetStringPtr(checksums[types.ChecksumAlgorithmMd5]), + ChecksumXXHASH64: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash64]), + ChecksumXXHASH3: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash3]), + ChecksumXXHASH128: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash128]), }) if err != nil { return &Response{ diff --git a/s3api/controllers/object-get.go b/s3api/controllers/object-get.go index a7575807..07c312ab 100644 --- a/s3api/controllers/object-get.go +++ b/s3api/controllers/object-get.go @@ -551,6 +551,7 @@ func (c S3ApiController) GetObject(ctx *fiber.Ctx) (*Response, error) { "Content-Language": utils.ApplyOverride(res.ContentLanguage, responseOverrides["Content-Language"]), "Cache-Control": utils.ApplyOverride(res.CacheControl, responseOverrides["Cache-Control"]), "Expires": utils.ApplyOverride(res.ExpiresString, responseOverrides["Expires"]), + "x-amz-website-redirect-location": res.WebsiteRedirectLocation, "x-amz-checksum-crc32": res.ChecksumCRC32, "x-amz-checksum-crc64nvme": res.ChecksumCRC64NVME, "x-amz-checksum-crc32c": res.ChecksumCRC32C, diff --git a/s3api/controllers/object-get_test.go b/s3api/controllers/object-get_test.go index 2fdc417f..0808bbde 100644 --- a/s3api/controllers/object-get_test.go +++ b/s3api/controllers/object-get_test.go @@ -794,6 +794,7 @@ func TestS3ApiController_GetObject(t *testing.T) { "Content-Language": nil, "Cache-Control": nil, "Expires": nil, + "x-amz-website-redirect-location": nil, "x-amz-checksum-crc32": nil, "x-amz-checksum-crc64nvme": nil, "x-amz-checksum-crc32c": nil, @@ -844,6 +845,7 @@ func TestS3ApiController_GetObject(t *testing.T) { "Content-Language": nil, "Cache-Control": nil, "Expires": nil, + "x-amz-website-redirect-location": nil, "x-amz-checksum-crc32": nil, "x-amz-checksum-crc64nvme": nil, "x-amz-checksum-crc32c": nil, diff --git a/s3api/controllers/object-head.go b/s3api/controllers/object-head.go index 789c8e8f..de949adc 100644 --- a/s3api/controllers/object-head.go +++ b/s3api/controllers/object-head.go @@ -179,6 +179,7 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) { "Content-Length": utils.ConvertPtrToStringPtr(res.ContentLength), "Content-Type": utils.ApplyOverride(res.ContentType, responseOverrides["Content-Type"]), "Expires": utils.ApplyOverride(res.ExpiresString, responseOverrides["Expires"]), + "x-amz-website-redirect-location": res.WebsiteRedirectLocation, "ETag": res.ETag, "Last-Modified": utils.FormatDatePtrToString(res.LastModified, timefmt), "x-amz-restore": res.Restore, diff --git a/s3api/controllers/object-head_test.go b/s3api/controllers/object-head_test.go index ade3d4c4..067b51ec 100644 --- a/s3api/controllers/object-head_test.go +++ b/s3api/controllers/object-head_test.go @@ -184,6 +184,7 @@ func TestS3ApiController_HeadObject(t *testing.T) { "Content-Language": nil, "Cache-Control": nil, "Expires": nil, + "x-amz-website-redirect-location": nil, "x-amz-checksum-crc32": nil, "x-amz-checksum-crc64nvme": nil, "x-amz-checksum-crc32c": nil, @@ -233,6 +234,7 @@ func TestS3ApiController_HeadObject(t *testing.T) { "Content-Language": nil, "Cache-Control": nil, "Expires": nil, + "x-amz-website-redirect-location": nil, "x-amz-checksum-crc32": nil, "x-amz-checksum-crc64nvme": nil, "x-amz-checksum-crc32c": nil, diff --git a/s3api/controllers/object-post.go b/s3api/controllers/object-post.go index 9e767b7b..03180c96 100644 --- a/s3api/controllers/object-post.go +++ b/s3api/controllers/object-post.go @@ -154,6 +154,7 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error contentEncoding := ctx.Get("Content-Encoding") tagging := ctx.Get("X-Amz-Tagging") expires := ctx.Get("Expires") + websiteRedirectLocation := ctx.Get("X-Amz-Website-Redirect-Location") legalHoldHdr := ctx.Get("X-Amz-Object-Lock-Legal-Hold") lockModeHdr := ctx.Get("X-Amz-Object-Lock-Mode") objLockDate := ctx.Get("X-Amz-Object-Lock-Retain-Until-Date") @@ -203,6 +204,15 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error }, err } + err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + objLockState, err := utils.ParsObjectLockHdrs(ctx) if err != nil { return &Response{ @@ -232,6 +242,7 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error ContentLanguage: &contentLanguage, CacheControl: &cacheControl, Expires: &expires, + WebsiteRedirectLocation: &websiteRedirectLocation, ObjectLockRetainUntilDate: &objLockState.RetainUntilDate, ObjectLockMode: objLockState.ObjectLockMode, ObjectLockLegalHoldStatus: objLockState.LegalHoldStatus, diff --git a/s3api/controllers/object-put.go b/s3api/controllers/object-put.go index d2335267..a66032a3 100644 --- a/s3api/controllers/object-put.go +++ b/s3api/controllers/object-put.go @@ -494,6 +494,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { contentLanguage := ctx.Get("Content-Language") cacheControl := ctx.Get("Cache-Control") expires := ctx.Get("Expires") + websiteRedirectLocation := ctx.Get("X-Amz-Website-Redirect-Location") tagging := ctx.Get("x-amz-tagging") storageClass := ctx.Get("X-Amz-Storage-Class") legalHoldHdr := ctx.Get("X-Amz-Object-Lock-Legal-Hold") @@ -578,6 +579,15 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { }, s3err.GetInvalidArgumentErr(s3err.InvalidArgTaggingDirective, string(taggingDirective)) } + err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + checksumAlgorithm := types.ChecksumAlgorithm(ctx.Get("x-amz-checksum-algorithm")) err = utils.IsChecksumAlgorithmValid(checksumAlgorithm) if err != nil { @@ -618,6 +628,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { ContentLanguage: &contentLanguage, CacheControl: &cacheControl, Expires: &expires, + WebsiteRedirectLocation: &websiteRedirectLocation, Tagging: &tagging, TaggingDirective: taggingDirective, CopySource: ©Source, @@ -665,6 +676,7 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { contentLanguage := ctx.Get("Content-Language") cacheControl := ctx.Get("Cache-Control") expires := ctx.Get("Expires") + websiteRedirectLocation := ctx.Get("X-Amz-Website-Redirect-Location") tagging := ctx.Get("x-amz-tagging") legalHoldHdr := ctx.Get("X-Amz-Object-Lock-Legal-Hold") lockModeHdr := ctx.Get("X-Amz-Object-Lock-Mode") @@ -729,6 +741,15 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { }, err } + err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + err = auth.CheckObjectAccess(ctx.Context(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, IsBucketPublic, c.be, true) if err != nil { return &Response{ @@ -787,6 +808,7 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { ContentLanguage: &contentLanguage, CacheControl: &cacheControl, Expires: &expires, + WebsiteRedirectLocation: &websiteRedirectLocation, Metadata: metadata, Body: body, Tagging: &tagging, diff --git a/s3api/utils/utils.go b/s3api/utils/utils.go index 2516a3cb..150b12e9 100644 --- a/s3api/utils/utils.go +++ b/s3api/utils/utils.go @@ -59,6 +59,16 @@ func SetBucketNameValidationStrict(strict bool) { // object metadata combined, excluded the 'x-amz-meta-' prefix const maxMetadataSize = 2048 +func ValidateWebsiteRedirectLocation(location string) error { + if location == "" || strings.HasPrefix(location, "http://") || + strings.HasPrefix(location, "https://") || strings.HasPrefix(location, "/") { + return nil + } + + debuglogger.Logf("invalid website redirect location: %q", location) + return s3err.GetAPIError(s3err.ErrInvalidRedirectLocation) +} + // GetUserMetaData extracts user metadata from headers with the "x-amz-meta-" prefix. // Keys are normalized to lowercase and duplicate headers are merged as // comma-separated values. The total metadata size is validated against the diff --git a/s3err/s3err.go b/s3err/s3err.go index 8aa15e20..b994d23c 100644 --- a/s3err/s3err.go +++ b/s3err/s3err.go @@ -173,6 +173,7 @@ const ( ErrCORSIsNotEnabled ErrNoSuchWebsiteConfiguration ErrInvalidWebsiteRedirectProtocol + ErrInvalidRedirectLocation ErrBothReplaceKeyAndPrefix ErrMaxMessageLengthExceeded ErrNotModified @@ -660,6 +661,11 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "Invalid protocol, protocol can be http or https. If not defined the protocol will be selected automatically.", HTTPStatusCode: http.StatusBadRequest, }, + ErrInvalidRedirectLocation: { + Code: "InvalidRedirectLocation", + Description: "The website redirect location must have a prefix of 'http://' or 'https://' or '/'.", + HTTPStatusCode: http.StatusBadRequest, + }, ErrBothReplaceKeyAndPrefix: { Code: "InvalidRequest", Description: "You can only define ReplaceKeyPrefix or ReplaceKey but not both.", diff --git a/s3response/website.go b/s3response/website.go index 976a0769..153de9a7 100644 --- a/s3response/website.go +++ b/s3response/website.go @@ -54,7 +54,7 @@ type RedirectAllRequestsTo struct { // RoutingRule specifies a redirect rule with an optional condition. type RoutingRule struct { Condition *RoutingRuleCondition `xml:"Condition,omitempty"` - Redirect Redirect `xml:"Redirect"` + Redirect *Redirect `xml:"Redirect"` } // RoutingRuleCondition specifies when a routing rule applies. @@ -139,10 +139,28 @@ func (c *RoutingRuleCondition) Validate() error { return nil } + if c.HttpErrorCodeReturnedEquals == "" && c.KeyPrefixEquals == "" { + debuglogger.Logf("website routing rule condition is empty") + return s3err.GetAPIError(s3err.ErrMalformedXML) + } + return isValidHTTPCode(c.HttpErrorCodeReturnedEquals, validateErrorCode) } func (r *Redirect) Validate() error { + if r == nil { + return nil + } + + if r.HostName == "" && + r.HttpRedirectCode == "" && + r.Protocol == "" && + r.ReplaceKeyPrefixWith == "" && + r.ReplaceKeyWith == "" { + debuglogger.Logf("website routing rule redirect is empty") + return s3err.GetAPIError(s3err.ErrMalformedXML) + } + if r.ReplaceKeyWith != "" && r.ReplaceKeyPrefixWith != "" { debuglogger.Logf("website redirect has both key replacements") return s3err.GetAPIError(s3err.ErrBothReplaceKeyAndPrefix) diff --git a/s3response/website_test.go b/s3response/website_test.go index a5867e7f..1f3c7aaa 100644 --- a/s3response/website_test.go +++ b/s3response/website_test.go @@ -59,7 +59,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { Condition: &RoutingRuleCondition{ KeyPrefixEquals: "docs/", }, - Redirect: Redirect{ + Redirect: &Redirect{ ReplaceKeyPrefixWith: "documents/", }, }, @@ -122,7 +122,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { IndexDocument: &IndexDocument{Suffix: "index.html"}, RoutingRules: []RoutingRule{ { - Redirect: Redirect{ + Redirect: &Redirect{ ReplaceKeyWith: "newkey", ReplaceKeyPrefixWith: "newprefix/", }, @@ -132,13 +132,45 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { wantErr: true, errCode: "InvalidRequest", }, + { + name: "routing rule with empty condition", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Condition: &RoutingRuleCondition{}, + Redirect: &Redirect{ + HostName: "example.com", + }, + }, + }, + }, + wantErr: true, + errCode: "MalformedXML", + }, + { + name: "routing rule with empty redirect", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Condition: &RoutingRuleCondition{ + KeyPrefixEquals: "docs/", + }, + Redirect: &Redirect{}, + }, + }, + }, + wantErr: true, + errCode: "MalformedXML", + }, { name: "routing rule with invalid redirect code", config: WebsiteConfiguration{ IndexDocument: &IndexDocument{Suffix: "index.html"}, RoutingRules: []RoutingRule{ { - Redirect: Redirect{ + Redirect: &Redirect{ HttpRedirectCode: "200", }, }, @@ -153,7 +185,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { IndexDocument: &IndexDocument{Suffix: "index.html"}, RoutingRules: []RoutingRule{ { - Redirect: Redirect{ + Redirect: &Redirect{ HttpRedirectCode: "301", HostName: "example.com", }, @@ -203,7 +235,7 @@ func TestWebsiteConfiguration_MatchPrefetchRoutingRuleUsesPrefixOnlyRules(t *tes Condition: &RoutingRuleCondition{ HttpErrorCodeReturnedEquals: "404", }, - Redirect: Redirect{ + Redirect: &Redirect{ HostName: "error.example.com", }, }, @@ -212,7 +244,7 @@ func TestWebsiteConfiguration_MatchPrefetchRoutingRuleUsesPrefixOnlyRules(t *tes KeyPrefixEquals: "old/", HttpErrorCodeReturnedEquals: "404", }, - Redirect: Redirect{ + Redirect: &Redirect{ HostName: "both.example.com", }, }, @@ -220,7 +252,7 @@ func TestWebsiteConfiguration_MatchPrefetchRoutingRuleUsesPrefixOnlyRules(t *tes Condition: &RoutingRuleCondition{ KeyPrefixEquals: "old/", }, - Redirect: Redirect{ + Redirect: &Redirect{ HostName: "prefix.example.com", }, }, diff --git a/tests/integration/CopyObject.go b/tests/integration/CopyObject.go index 0907b29f..90b781a7 100644 --- a/tests/integration/CopyObject.go +++ b/tests/integration/CopyObject.go @@ -666,21 +666,23 @@ func CopyObject_should_copy_meta_props(s *S3Conf) error { cType, cEnc, cDesp, cLang, cLength := "application/json", "base64", "test-desp", "us", int64(100) cacheControl, expires := "no-cache", time.Now().Add(time.Hour*10) + redirectLocation := "/source-redirect" meta := map[string]string{ "foo": "bar", "baz": "quxx", } _, err := putObjectWithData(cLength, &s3.PutObjectInput{ - Bucket: &bucket, - Key: &srcObj, - ContentDisposition: &cDesp, - ContentEncoding: &cEnc, - ContentLanguage: &cLang, - ContentType: &cType, - CacheControl: &cacheControl, - Expires: &expires, - Metadata: meta, + Bucket: &bucket, + Key: &srcObj, + ContentDisposition: &cDesp, + ContentEncoding: &cEnc, + ContentLanguage: &cLang, + ContentType: &cType, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &redirectLocation, + Metadata: meta, }, s3client) if err != nil { return err @@ -697,7 +699,7 @@ func CopyObject_should_copy_meta_props(s *S3Conf) error { return err } - return checkObjectMetaProps(s3client, bucket, dstObj, ObjectMetaProps{ + if err := checkObjectMetaProps(s3client, bucket, dstObj, ObjectMetaProps{ ContentLength: cLength, ContentType: cType, ContentEncoding: cEnc, @@ -706,7 +708,24 @@ func CopyObject_should_copy_meta_props(s *S3Conf) error { CacheControl: cacheControl, ExpiresString: expires.UTC().Format(timefmt), Metadata: meta, + }); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + out, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &dstObj, }) + cancel() + if err != nil { + return err + } + if got := getString(out.WebsiteRedirectLocation); got != "" { + return fmt.Errorf("expected WebsiteRedirectLocation not to be copied, got %v", got) + } + + return nil }) } @@ -736,6 +755,7 @@ func CopyObject_should_replace_meta_props(s *S3Conf) error { cType, cEnc, cDesp, cLang := "application/binary", "hex", "desp", "mex" cacheControl, expires := "no-cache", time.Now().Add(time.Hour*10) + redirectLocation := "https://example.com/replaced" meta := map[string]string{ "foo": "bar", "baz": "quxx", @@ -744,17 +764,18 @@ func CopyObject_should_replace_meta_props(s *S3Conf) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.CopyObject(ctx, &s3.CopyObjectInput{ - Bucket: &bucket, - Key: &dstObj, - CopySource: getPtr(bucket + "/" + srcObj), - MetadataDirective: types.MetadataDirectiveReplace, - ContentDisposition: &cDesp, - ContentEncoding: &cEnc, - ContentLanguage: &cLang, - ContentType: &cType, - CacheControl: &cacheControl, - Expires: &expires, - Metadata: meta, + Bucket: &bucket, + Key: &dstObj, + CopySource: getPtr(bucket + "/" + srcObj), + MetadataDirective: types.MetadataDirectiveReplace, + ContentDisposition: &cDesp, + ContentEncoding: &cEnc, + ContentLanguage: &cLang, + ContentType: &cType, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &redirectLocation, + Metadata: meta, }) cancel() if err != nil { @@ -762,18 +783,44 @@ func CopyObject_should_replace_meta_props(s *S3Conf) error { } return checkObjectMetaProps(s3client, bucket, dstObj, ObjectMetaProps{ - ContentLength: contentLength, - ContentType: cType, - ContentEncoding: cEnc, - ContentDisposition: cDesp, - ContentLanguage: cLang, - CacheControl: cacheControl, - ExpiresString: expires.UTC().Format(timefmt), - Metadata: meta, + ContentLength: contentLength, + ContentType: cType, + ContentEncoding: cEnc, + ContentDisposition: cDesp, + ContentLanguage: cLang, + CacheControl: cacheControl, + ExpiresString: expires.UTC().Format(timefmt), + WebsiteRedirectLocation: redirectLocation, + Metadata: meta, }) }) } +func CopyObject_invalid_website_redirect_location(s *S3Conf) error { + testName := "CopyObject_invalid_website_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + srcObj, dstObj := "source-object", "dest-object" + + _, err := putObjectWithData(100, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &srcObj, + }, s3client) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: &bucket, + Key: &dstObj, + CopySource: getPtr(bucket + "/" + srcObj), + WebsiteRedirectLocation: getPtr("ftp://example.com"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidRedirectLocation)) + }) +} + func CopyObject_default_content_type_with_replace_metadata(s *S3Conf) error { testName := "CopyObject_default_content_type_with_replace_metadata" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/CreateMultipartUpload.go b/tests/integration/CreateMultipartUpload.go index e95e2b37..b4771dc0 100644 --- a/tests/integration/CreateMultipartUpload.go +++ b/tests/integration/CreateMultipartUpload.go @@ -67,18 +67,20 @@ func CreateMultipartUpload_with_metadata(s *S3Conf) error { } cType, cEnc, cDesp, cLang := "application/text", "testenc", "testdesp", "sp" cacheControl, expires := "no-cache", time.Now().Add(time.Hour*5) + redirectLocation := "/multipart-redirect" ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) out, err := s3client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ - Bucket: &bucket, - Key: &obj, - Metadata: meta, - ContentType: &cType, - ContentEncoding: &cEnc, - ContentDisposition: &cDesp, - ContentLanguage: &cLang, - CacheControl: &cacheControl, - Expires: &expires, + Bucket: &bucket, + Key: &obj, + Metadata: meta, + ContentType: &cType, + ContentEncoding: &cEnc, + ContentDisposition: &cDesp, + ContentLanguage: &cLang, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &redirectLocation, }) cancel() if err != nil { @@ -151,11 +153,29 @@ func CreateMultipartUpload_with_metadata(s *S3Conf) error { return fmt.Errorf("expected uploaded object content-encoding to be %v, instead got %v", expires.UTC().Format(timefmt), getString(resp.ExpiresString)) } + if getString(resp.WebsiteRedirectLocation) != redirectLocation { + return fmt.Errorf("expected uploaded object website redirect location to be %v, instead got %v", + redirectLocation, getString(resp.WebsiteRedirectLocation)) + } return nil }) } +func CreateMultipartUpload_invalid_website_redirect_location(s *S3Conf) error { + testName := "CreateMultipartUpload_invalid_website_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ + Bucket: &bucket, + Key: getPtr("my-obj"), + WebsiteRedirectLocation: getPtr("example.com"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidRedirectLocation)) + }) +} + func CreateMultipartUpload_with_object_lock(s *S3Conf) error { testName := "CreateMultipartUpload_with_object_lock" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/GetObject.go b/tests/integration/GetObject.go index eb6def24..3a3ca7c0 100644 --- a/tests/integration/GetObject.go +++ b/tests/integration/GetObject.go @@ -574,22 +574,24 @@ func GetObject_success(s *S3Conf) error { dataLength, obj := int64(1234567), "my-obj" ctype, cDisp, cEnc, cLang := defaultContentType, "cont-desp", "json", "eng" cacheControl, expires := "cache-ctrl", time.Now().Add(time.Hour*2) + redirectLocation := "/get-object-redirect" meta := map[string]string{ "foo": "bar", "baz": "quxx", } r, err := putObjectWithData(dataLength, &s3.PutObjectInput{ - Bucket: &bucket, - Key: &obj, - ContentType: &ctype, - ContentDisposition: &cDisp, - ContentEncoding: &cEnc, - ContentLanguage: &cLang, - Expires: &expires, - CacheControl: &cacheControl, - Metadata: meta, - Tagging: getPtr("key=value&key1=val1"), + Bucket: &bucket, + Key: &obj, + ContentType: &ctype, + ContentDisposition: &cDisp, + ContentEncoding: &cEnc, + ContentLanguage: &cLang, + Expires: &expires, + CacheControl: &cacheControl, + WebsiteRedirectLocation: &redirectLocation, + Metadata: meta, + Tagging: getPtr("key=value&key1=val1"), }, s3client) if err != nil { return err @@ -632,6 +634,10 @@ func GetObject_success(s *S3Conf) error { return fmt.Errorf("expected Cache-Control %v, instead got %v", cacheControl, getString(out.CacheControl)) } + if getString(out.WebsiteRedirectLocation) != redirectLocation { + return fmt.Errorf("expected WebsiteRedirectLocation %v, instead got %v", + redirectLocation, getString(out.WebsiteRedirectLocation)) + } if out.StorageClass != types.StorageClassStandard { return fmt.Errorf("expected the storage class to be %v, instead got %v", types.StorageClassStandard, out.StorageClass) diff --git a/tests/integration/HeadObject.go b/tests/integration/HeadObject.go index df30d5a4..6fa68af4 100644 --- a/tests/integration/HeadObject.go +++ b/tests/integration/HeadObject.go @@ -569,18 +569,20 @@ func HeadObject_success(s *S3Conf) error { } ctype, cDisp, cEnc, cLang := defaultContentType, "cont-desp", "json", "eng" cacheControl, expires := "cache-ctrl", time.Now().Add(time.Hour*2) + redirectLocation := "/head-object-redirect" _, err := putObjectWithData(dataLen, &s3.PutObjectInput{ - Bucket: &bucket, - Key: &obj, - Metadata: meta, - ContentType: &ctype, - ContentDisposition: &cDisp, - ContentEncoding: &cEnc, - ContentLanguage: &cLang, - CacheControl: &cacheControl, - Expires: &expires, - Tagging: getPtr("key=value"), + Bucket: &bucket, + Key: &obj, + Metadata: meta, + ContentType: &ctype, + ContentDisposition: &cDisp, + ContentEncoding: &cEnc, + ContentLanguage: &cLang, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &redirectLocation, + Tagging: getPtr("key=value"), }, s3client) if err != nil { return err @@ -631,6 +633,10 @@ func HeadObject_success(s *S3Conf) error { return fmt.Errorf("expected Cache-Control %v, instead got %v", cacheControl, getString(out.CacheControl)) } + if getString(out.WebsiteRedirectLocation) != redirectLocation { + return fmt.Errorf("expected WebsiteRedirectLocation %v, instead got %v", + redirectLocation, getString(out.WebsiteRedirectLocation)) + } if out.StorageClass != types.StorageClassStandard { return fmt.Errorf("expected the storage class to be %v, instead got %v", types.StorageClassStandard, out.StorageClass) diff --git a/tests/integration/PostObject.go b/tests/integration/PostObject.go index d020ed6b..fcef5c0b 100644 --- a/tests/integration/PostObject.go +++ b/tests/integration/PostObject.go @@ -887,6 +887,7 @@ func PostObject_success_with_meta_properties(s *S3Conf) error { cLanguage := "en-US" cDisposition := "inline" cEncoding := "gzip" + redirectLocation := "/post-object-redirect" resp, err := sendPostObject(PostRequestConfig{ bucket: bucket, @@ -900,18 +901,20 @@ func PostObject_success_with_meta_properties(s *S3Conf) error { []any{"eq", "$Content-Encoding", cEncoding}, []any{"eq", "$Cache-Control", cacheControl}, []any{"eq", "$Expires", expires}, + []any{"eq", "$x-amz-website-redirect-location", redirectLocation}, []any{"eq", "$x-amz-meta-foo", "bar"}, []any{"eq", "$x-amz-meta-baz", "quxx"}, }, extraFields: map[string]string{ - "Content-Type": cType, - "Cache-Control": cacheControl, - "Expires": expires, - "Content-Language": cLanguage, - "Content-Disposition": cDisposition, - "Content-Encoding": cEncoding, - "x-amz-meta-foo": "bar", - "x-amz-meta-baz": "quxx", + "Content-Type": cType, + "Cache-Control": cacheControl, + "Expires": expires, + "Content-Language": cLanguage, + "Content-Disposition": cDisposition, + "Content-Encoding": cEncoding, + "x-amz-website-redirect-location": redirectLocation, + "x-amz-meta-foo": "bar", + "x-amz-meta-baz": "quxx", }, }) if err != nil { @@ -955,6 +958,10 @@ func PostObject_success_with_meta_properties(s *S3Conf) error { return fmt.Errorf("expected Cache-Control %s, instead got %s", cacheControl, getString(out.CacheControl)) } + if getString(out.WebsiteRedirectLocation) != redirectLocation { + return fmt.Errorf("expected WebsiteRedirectLocation %s, instead got %s", + redirectLocation, getString(out.WebsiteRedirectLocation)) + } expectedMeta := map[string]string{ "foo": "bar", @@ -969,6 +976,30 @@ func PostObject_success_with_meta_properties(s *S3Conf) error { }) } +func PostObject_invalid_website_redirect_location(s *S3Conf) error { + testName := "PostObject_invalid_website_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + redirectLocation := "ftp://example.com" + resp, err := sendPostObject(PostRequestConfig{ + bucket: bucket, + key: "test-object", + s3Conf: s, + fileContent: []byte("data"), + policyConditions: []any{ + []any{"eq", "$x-amz-website-redirect-location", redirectLocation}, + }, + extraFields: map[string]string{ + "x-amz-website-redirect-location": redirectLocation, + }, + }) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrInvalidRedirectLocation)) + }) +} + func PostObject_invalid_tagging(s *S3Conf) error { testName := "PostObject_invalid_tagging" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/PutBucketWebsite.go b/tests/integration/PutBucketWebsite.go index ed0a09d4..1612866e 100644 --- a/tests/integration/PutBucketWebsite.go +++ b/tests/integration/PutBucketWebsite.go @@ -180,6 +180,56 @@ func PutBucketWebsite_invalid_routing_rule_protocol(s *S3Conf) error { }) } +func PutBucketWebsite_empty_routing_rule_condition(s *S3Conf) error { + testName := "PutBucketWebsite_empty_routing_rule_condition" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{}, + Redirect: &types.Redirect{ + HostName: getPtr("example.com"), + }, + }, + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)) + }) +} + +func PutBucketWebsite_empty_routing_rule_redirect(s *S3Conf) error { + testName := "PutBucketWebsite_empty_routing_rule_redirect" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("docs/"), + }, + Redirect: &types.Redirect{}, + }, + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)) + }) +} + func PutBucketWebsite_empty_error_document_key(s *S3Conf) error { testName := "PutBucketWebsite_empty_error_document_key" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/PutObject.go b/tests/integration/PutObject.go index c0eb48da..f27db548 100644 --- a/tests/integration/PutObject.go +++ b/tests/integration/PutObject.go @@ -629,6 +629,19 @@ func PutObject_with_metadata(s *S3Conf) error { }) } +func PutObject_invalid_website_redirect_location(s *S3Conf) error { + testName := "PutObject_invalid_website_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "my-obj" + _, err := putObjectWithData(10, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &obj, + WebsiteRedirectLocation: getPtr("ftp://example.com"), + }, s3client) + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidRedirectLocation)) + }) +} + func PutObject_checksum_algorithm_and_header_mismatch(s *S3Conf) error { testName := "PutObject_checksum_algorithm_and_header_mismatch" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/WebsiteHosting.go b/tests/integration/WebsiteHosting.go index 9d970024..3fde8464 100644 --- a/tests/integration/WebsiteHosting.go +++ b/tests/integration/WebsiteHosting.go @@ -122,6 +122,44 @@ func WebsiteHosting_no_error_document(s *S3Conf) error { }) } +// WebsiteHosting_no_bucket_in_request_location tests that website endpoint +// requests that cannot resolve a bucket still include a useful Location header. +func WebsiteHosting_no_bucket_in_request_location(s *S3Conf) error { + testName := "WebsiteHosting_no_bucket_in_request_location" + return actionHandlerNoSetup(s, testName, func(_ *s3.Client, _ string) error { + _, domain, port := websiteEndpointParts(s) + badHost := "nested.bucket." + domain + baseHost := domain + if port != "" { + badHost = fmt.Sprintf("%s:%s", badHost, port) + baseHost = fmt.Sprintf("%s:%s", baseHost, port) + } + + reqURL, err := websiteAbsoluteURL(s, badHost, "/") + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return fmt.Errorf("failed to create website request: %w", err) + } + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + wantLocation, err := websiteAbsoluteURL(s, baseHost, "/") + if err != nil { + return err + } + if got := resp.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("expected Location %q, got %q", wantLocation, got) + } + + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrNoBucketInRequest)) + }) +} + // WebsiteHosting_private_object_and_error_document tests that website hosting // does not serve either the requested object or the configured error document // unless public object access has been granted. @@ -203,7 +241,7 @@ func WebsiteHosting_routing_rule_post_request_redirect(s *S3Conf) error { if got := resp.Header.Get("Location"); got != wantLocation { return fmt.Errorf("expected Location %q, got %q", wantLocation, got) } - return checkWebsiteResponse(resp, http.StatusFound, []byte(http.StatusText(http.StatusFound))) + return checkWebsiteResponse(resp, http.StatusFound, nil) }) } @@ -245,7 +283,7 @@ func WebsiteHosting_routing_rule_pre_request_redirect(s *S3Conf) error { if got := resp.Header.Get("Location"); got != wantLocation { return fmt.Errorf("expected Location %q, got %q", wantLocation, got) } - return checkWebsiteResponse(resp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently))) + return checkWebsiteResponse(resp, http.StatusMovedPermanently, nil) }) } @@ -294,7 +332,7 @@ func WebsiteHosting_routing_rule_prefix_and_error_redirect(s *S3Conf) error { if got := resp.Header.Get("Location"); got != wantLocation { return fmt.Errorf("expected Location %q, got %q", wantLocation, got) } - return checkWebsiteResponse(resp, http.StatusTemporaryRedirect, []byte(http.StatusText(http.StatusTemporaryRedirect))) + return checkWebsiteResponse(resp, http.StatusTemporaryRedirect, nil) }) } @@ -373,7 +411,49 @@ func WebsiteHosting_redirect_all_requests(s *S3Conf) error { if got, want := resp.Header.Get("Location"), "https://www.example.com/any/path/here?tracking=1"; got != want { return fmt.Errorf("expected Location %q, got %q", want, got) } - return checkWebsiteResponse(resp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently))) + return checkWebsiteResponse(resp, http.StatusMovedPermanently, nil) + }) +} + +// WebsiteHosting_object_redirect_location tests that an object-level website +// redirect emits a 301 with the stored Location after object fetch succeeds. +func WebsiteHosting_object_redirect_location(s *S3Conf) error { + testName := "WebsiteHosting_object_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + redirectLocation := "/new-page.html" + objectBody := "old" + _, err = putObjectWithData(int64(len(objectBody)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("old-page.html"), + Body: strings.NewReader(objectBody), + ContentType: getPtr("text/html"), + WebsiteRedirectLocation: &redirectLocation, + }, s3client) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "old-page.html", nil) + if err != nil { + return err + } + + if got := resp.Header.Get("Location"); got != redirectLocation { + return fmt.Errorf("expected Location %q, got %q", redirectLocation, got) + } + return checkWebsiteResponse(resp, http.StatusMovedPermanently, nil) }) } @@ -521,7 +601,7 @@ func WebsiteHosting_index_error_document_and_routing_rules(s *S3Conf) error { preResp.Body.Close() return fmt.Errorf("expected pre-rule Location %q, got %q", wantPreLocation, got) } - if err := checkWebsiteResponse(preResp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently))); err != nil { + if err := checkWebsiteResponse(preResp, http.StatusMovedPermanently, nil); err != nil { return err } @@ -538,7 +618,7 @@ func WebsiteHosting_index_error_document_and_routing_rules(s *S3Conf) error { postResp.Body.Close() return fmt.Errorf("expected post-rule Location %q, got %q", wantPostLocation, got) } - if err := checkWebsiteResponse(postResp, http.StatusFound, []byte(http.StatusText(http.StatusFound))); err != nil { + if err := checkWebsiteResponse(postResp, http.StatusFound, nil); err != nil { return err } @@ -584,7 +664,7 @@ func WebsiteHosting_options_preflight_access_granted(s *S3Conf) error { Origin: "https://client.example", Methods: "GET, HEAD", AllowHeaders: "content-type, x-amz-date", - ExposeHeaders: "Content-Length, ETag", + ExposeHeaders: "Content-Length", MaxAge: "42", AllowCredentials: "true", Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index b93e2e94..9dc93c73 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -203,6 +203,7 @@ func TestPutObject(ts *TestState) { ts.Run(PutObject_invalid_object_names) ts.Run(PutObject_object_acl_not_supported) ts.Run(PutObject_long_metadata) + ts.Run(PutObject_invalid_website_redirect_location) } func TestHeadObject(ts *TestState) { @@ -370,6 +371,7 @@ func TestCopyObject(ts *TestState) { ts.Run(CopyObject_non_existing_dir_object) ts.Run(CopyObject_should_copy_meta_props) ts.Run(CopyObject_should_replace_meta_props) + ts.Run(CopyObject_invalid_website_redirect_location) ts.Run(CopyObject_default_content_type_with_replace_metadata) ts.Run(CopyObject_missing_bucket_lock) ts.Run(CopyObject_invalid_legal_hold) @@ -420,6 +422,7 @@ func TestCreateMultipartUpload(ts *TestState) { ts.Run(CreateMultipartUpload_non_existing_bucket) ts.Run(CreateMultipartUpload_long_metadata) ts.Run(CreateMultipartUpload_with_metadata) + ts.Run(CreateMultipartUpload_invalid_website_redirect_location) ts.Run(CreateMultipartUpload_with_tagging) ts.Run(CreateMultipartUpload_with_object_lock) ts.Run(CreateMultipartUpload_with_object_lock_not_enabled) @@ -670,6 +673,8 @@ func TestPutBucketWebsite(ts *TestState) { ts.Run(PutBucketWebsite_invalid_redirect_protocol) ts.Run(PutBucketWebsite_redirectAll_index_error_routingRules) ts.Run(PutBucketWebsite_invalid_routing_rule_protocol) + ts.Run(PutBucketWebsite_empty_routing_rule_condition) + ts.Run(PutBucketWebsite_empty_routing_rule_redirect) ts.Run(PutBucketWebsite_empty_error_document_key) ts.Run(PutBucketWebsite_too_many_routing_rules) ts.Run(PutBucketWebsite_routing_rule_replace_key_and_prefix) @@ -696,12 +701,14 @@ func TestWebsiteHosting(ts *TestState) { ts.Run(WebsiteHosting_error_document_served) ts.Run(WebsiteHosting_error_document_not_found) ts.Run(WebsiteHosting_no_error_document) + ts.Run(WebsiteHosting_no_bucket_in_request_location) ts.Run(WebsiteHosting_private_object_and_error_document) ts.Run(WebsiteHosting_routing_rule_post_request_redirect) ts.Run(WebsiteHosting_routing_rule_pre_request_redirect) ts.Run(WebsiteHosting_routing_rule_prefix_and_error_redirect) ts.Run(WebsiteHosting_routing_rule_no_match_serves_error_document) ts.Run(WebsiteHosting_redirect_all_requests) + ts.Run(WebsiteHosting_object_redirect_location) ts.Run(WebsiteHosting_index_document) ts.Run(WebsiteHosting_index_error_document_and_routing_rules) ts.Run(WebsiteHosting_get_cors_headers) @@ -1265,6 +1272,7 @@ func TestPostObject(ts *TestState) { ts.Run(PostObject_success_status_201) ts.Run(PostObject_should_ignore_anything_after_file) ts.Run(PostObject_success_with_meta_properties) + ts.Run(PostObject_invalid_website_redirect_location) ts.Run(PostObject_invalid_tagging) ts.Run(PostObject_success_with_tagging) ts.Run(PostObject_success_double_dash_boundary) @@ -1396,6 +1404,7 @@ func GetIntTests() IntTests { "PutObject_should_combine_metadata": PutObject_should_combine_metadata, "PutObject_long_metadata": PutObject_long_metadata, "PutObject_with_metadata": PutObject_with_metadata, + "PutObject_invalid_website_redirect_location": PutObject_invalid_website_redirect_location, "PutObject_invalid_credentials": PutObject_invalid_credentials, "PutObject_checksum_algorithm_and_header_mismatch": PutObject_checksum_algorithm_and_header_mismatch, "PutObject_multiple_checksum_headers": PutObject_multiple_checksum_headers, @@ -1600,6 +1609,7 @@ func GetIntTests() IntTests { "CopyObject_non_existing_dir_object": CopyObject_non_existing_dir_object, "CopyObject_should_copy_meta_props": CopyObject_should_copy_meta_props, "CopyObject_should_replace_meta_props": CopyObject_should_replace_meta_props, + "CopyObject_invalid_website_redirect_location": CopyObject_invalid_website_redirect_location, "CopyObject_default_content_type_with_replace_metadata": CopyObject_default_content_type_with_replace_metadata, "CopyObject_missing_bucket_lock": CopyObject_missing_bucket_lock, "CopyObject_invalid_legal_hold": CopyObject_invalid_legal_hold, @@ -1634,6 +1644,7 @@ func GetIntTests() IntTests { "CreateMultipartUpload_non_existing_bucket": CreateMultipartUpload_non_existing_bucket, "CreateMultipartUpload_long_metadata": CreateMultipartUpload_long_metadata, "CreateMultipartUpload_with_metadata": CreateMultipartUpload_with_metadata, + "CreateMultipartUpload_invalid_website_redirect_location": CreateMultipartUpload_invalid_website_redirect_location, "CreateMultipartUpload_with_tagging": CreateMultipartUpload_with_tagging, "CreateMultipartUpload_with_object_lock": CreateMultipartUpload_with_object_lock, "CreateMultipartUpload_with_object_lock_not_enabled": CreateMultipartUpload_with_object_lock_not_enabled, @@ -1813,6 +1824,8 @@ func GetIntTests() IntTests { "PutBucketWebsite_invalid_redirect_protocol": PutBucketWebsite_invalid_redirect_protocol, "PutBucketWebsite_redirectAll_index_error_routingRules": PutBucketWebsite_redirectAll_index_error_routingRules, "PutBucketWebsite_invalid_routing_rule_protocol": PutBucketWebsite_invalid_routing_rule_protocol, + "PutBucketWebsite_empty_routing_rule_condition": PutBucketWebsite_empty_routing_rule_condition, + "PutBucketWebsite_empty_routing_rule_redirect": PutBucketWebsite_empty_routing_rule_redirect, "PutBucketWebsite_empty_error_document_key": PutBucketWebsite_empty_error_document_key, "PutBucketWebsite_too_many_routing_rules": PutBucketWebsite_too_many_routing_rules, "PutBucketWebsite_routing_rule_replace_key_and_prefix": PutBucketWebsite_routing_rule_replace_key_and_prefix, @@ -1830,12 +1843,14 @@ func GetIntTests() IntTests { "WebsiteHosting_error_document_served": WebsiteHosting_error_document_served, "WebsiteHosting_error_document_not_found": WebsiteHosting_error_document_not_found, "WebsiteHosting_no_error_document": WebsiteHosting_no_error_document, + "WebsiteHosting_no_bucket_in_request_location": WebsiteHosting_no_bucket_in_request_location, "WebsiteHosting_private_object_and_error_document": WebsiteHosting_private_object_and_error_document, "WebsiteHosting_routing_rule_post_request_redirect": WebsiteHosting_routing_rule_post_request_redirect, "WebsiteHosting_routing_rule_pre_request_redirect": WebsiteHosting_routing_rule_pre_request_redirect, "WebsiteHosting_routing_rule_prefix_and_error_redirect": WebsiteHosting_routing_rule_prefix_and_error_redirect, "WebsiteHosting_routing_rule_no_match_serves_error_document": WebsiteHosting_routing_rule_no_match_serves_error_document, "WebsiteHosting_redirect_all_requests": WebsiteHosting_redirect_all_requests, + "WebsiteHosting_object_redirect_location": WebsiteHosting_object_redirect_location, "WebsiteHosting_index_document": WebsiteHosting_index_document, "WebsiteHosting_index_error_document_and_routing_rules": WebsiteHosting_index_error_document_and_routing_rules, "WebsiteHosting_get_cors_headers": WebsiteHosting_get_cors_headers, @@ -2147,6 +2162,7 @@ func GetIntTests() IntTests { "PostObject_success_status_201": PostObject_success_status_201, "PostObject_should_ignore_anything_after_file": PostObject_should_ignore_anything_after_file, "PostObject_success_with_meta_properties": PostObject_success_with_meta_properties, + "PostObject_invalid_website_redirect_location": PostObject_invalid_website_redirect_location, "PostObject_invalid_tagging": PostObject_invalid_tagging, "PostObject_success_with_tagging": PostObject_success_with_tagging, "PostObject_invalid_checksum_value": PostObject_invalid_checksum_value, diff --git a/tests/integration/utils.go b/tests/integration/utils.go index 83b34c29..2a224342 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -2136,14 +2136,15 @@ func compareDelMarkers(d1, d2 []types.DeleteMarkerEntry) bool { } type ObjectMetaProps struct { - ContentLength int64 - ContentType string - ContentEncoding string - ContentDisposition string - ContentLanguage string - CacheControl string - ExpiresString string - Metadata map[string]string + ContentLength int64 + ContentType string + ContentEncoding string + ContentDisposition string + ContentLanguage string + CacheControl string + ExpiresString string + WebsiteRedirectLocation string + Metadata map[string]string } func checkObjectMetaProps(client *s3.Client, bucket, object string, o ObjectMetaProps) error { @@ -2186,6 +2187,9 @@ func checkObjectMetaProps(client *s3.Client, bucket, object string, o ObjectMeta if o.ExpiresString != "" && getString(out.ExpiresString) != o.ExpiresString { return fmt.Errorf("expected Expires %v, instead got %v", o.ExpiresString, getString(out.ExpiresString)) } + if o.WebsiteRedirectLocation != "" && getString(out.WebsiteRedirectLocation) != o.WebsiteRedirectLocation { + return fmt.Errorf("expected WebsiteRedirectLocation %v, instead got %v", o.WebsiteRedirectLocation, getString(out.WebsiteRedirectLocation)) + } if out.StorageClass != types.StorageClassStandard { return fmt.Errorf("expected the storage class to be %v, instead got %v", types.StorageClassStandard, out.StorageClass) } diff --git a/website/handler.go b/website/handler.go index 506a6f2f..98c69141 100644 --- a/website/handler.go +++ b/website/handler.go @@ -134,7 +134,7 @@ func registerWebsiteRoutes(app *fiber.App, be backend.Backend, domain string) { func setCORSPreflightHeaders(ctx *fiber.Ctx, allowConfig *auth.CORSAllowanceConfig) { ctx.Set("Access-Control-Allow-Origin", allowConfig.Origin) ctx.Set("Access-Control-Allow-Methods", allowConfig.Methods) - ctx.Set("Access-Control-Expose-Headers", corsExposeHeaders(allowConfig.ExposedHeaders)) + ctx.Set("Access-Control-Expose-Headers", allowConfig.ExposedHeaders) ctx.Set("Access-Control-Allow-Credentials", allowConfig.AllowCredentials) ctx.Set("Access-Control-Allow-Headers", allowConfig.AllowHeaders) ctx.Set("Vary", middlewares.VaryHdr) @@ -143,24 +143,6 @@ func setCORSPreflightHeaders(ctx *fiber.Ctx, allowConfig *auth.CORSAllowanceConf } } -func corsExposeHeaders(exposed string) string { - exposed = strings.TrimSpace(exposed) - if exposed == "" { - return "ETag" - } - if exposed == "*" { - return exposed - } - - for part := range strings.SplitSeq(exposed, ",") { - if strings.EqualFold(strings.TrimSpace(part), "ETag") { - return exposed - } - } - - return exposed + ", ETag" -} - func (c *websiteController) MethodNotAllowed(ctx *fiber.Ctx) error { return sendError(ctx, s3err.GetMethodNotAllowedErr(ctx.Method(), s3err.ResourceTypeObject, websiteAllowedMethods)) } @@ -188,7 +170,7 @@ func (c *websiteController) serve(ctx *fiber.Ctx, readObject websiteObjectReader } if rule := req.config.MatchPrefetchRoutingRule(req.key); rule != nil { - return applyRedirect(ctx, &rule.Redirect, rule.Condition, req.key) + return applyRedirect(ctx, rule.Redirect, rule.Condition, req.key) } resolvedKey := resolveIndexKey(req.key, req.config) @@ -201,7 +183,7 @@ func (c *websiteController) serve(ctx *fiber.Ctx, readObject websiteObjectReader } if rule := req.config.MatchPostErrorRoutingRule(req.key, result.StatusCode); rule != nil { - return applyRedirect(ctx, &rule.Redirect, rule.Condition, req.key) + return applyRedirect(ctx, rule.Redirect, rule.Condition, req.key) } return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject) @@ -213,6 +195,8 @@ func (c *websiteController) resolveRequest(ctx *fiber.Ctx) (*websiteRequestInfo, return nil, err } + fmt.Println(bucket) + key := strings.TrimPrefix(ctx.Path(), "/") if err := validateWebsiteNames(bucket, key); err != nil { return nil, err @@ -260,14 +244,13 @@ func validateWebsiteNames(bucket, key string) error { func (c *websiteController) resolveBucket(ctx *fiber.Ctx) (string, error) { host := ctx.Hostname() if host == "" { + ctx.Set("Location", c.noBucketLocation(ctx, host)) return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest) } // Strip port from host if present. Be careful with IPv6: only strip if the // last colon is not inside brackets. - if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") { - host = host[:idx] - } + host = stripHostPort(host) if c.domain == "" { return host, nil @@ -286,9 +269,43 @@ func (c *websiteController) resolveBucket(ctx *fiber.Ctx) (string, error) { } } + ctx.Set("Location", c.noBucketLocation(ctx, ctx.Hostname())) return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest) } +func (c *websiteController) noBucketLocation(ctx *fiber.Ctx, host string) string { + locationHost := c.domain + if locationHost == "" { + locationHost = stripHostPort(host) + } + if locationHost == "" { + return "/" + } + if c.domain != "" { + if port := hostPort(host); port != "" { + locationHost += ":" + port + } + } + + return fmt.Sprintf("%s://%s/", ctx.Protocol(), locationHost) +} + +func stripHostPort(host string) string { + if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") { + return host[:idx] + } + + return host +} + +func hostPort(host string) string { + if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") { + return host[idx+1:] + } + + return "" +} + type websiteResult struct { Key string StatusCode int @@ -297,9 +314,10 @@ type websiteResult struct { } type websiteObject struct { - Body io.ReadCloser - Headers map[string]*string - Metadata map[string]string + Body io.ReadCloser + Headers map[string]*string + Metadata map[string]string + WebsiteRedirectLocation *string } func resolveIndexKey(key string, config *s3response.WebsiteConfiguration) string { @@ -337,15 +355,16 @@ func (c *websiteController) getObject(ctx *fiber.Ctx, bucket, key string) websit Key: key, StatusCode: http.StatusOK, Object: websiteObject{ - Body: result.Body, - Headers: getObjectHeaders(result), - Metadata: result.Metadata, + Body: result.Body, + Headers: getObjectHeaders(result), + Metadata: result.Metadata, + WebsiteRedirectLocation: result.WebsiteRedirectLocation, }, } } func (c *websiteController) headObject(ctx *fiber.Ctx, bucket, key string) websiteResult { - if err := auth.VerifyPublicAccess(ctx.Context(), c.be, auth.ListBucketAction, auth.PermissionRead, bucket, key); err != nil { + if err := auth.VerifyPublicAccess(ctx.Context(), c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil { return websiteResult{ Key: key, StatusCode: statusCodeFromError(err), @@ -369,8 +388,9 @@ func (c *websiteController) headObject(ctx *fiber.Ctx, bucket, key string) websi Key: key, StatusCode: http.StatusOK, Object: websiteObject{ - Headers: headObjectHeaders(result), - Metadata: result.Metadata, + Headers: headObjectHeaders(result), + Metadata: result.Metadata, + WebsiteRedirectLocation: result.WebsiteRedirectLocation, }, } } @@ -388,16 +408,14 @@ func statusCodeFromError(err error) int { func handleRedirectAll(ctx *fiber.Ctx, redirect *s3response.RedirectAllRequestsTo, key string) error { protocol := redirect.Protocol if protocol == "" { - protocol = "http" + protocol = ctx.Protocol() } location := fmt.Sprintf("%s://%s/%s", protocol, redirect.HostName, key) if query := string(ctx.Request().URI().QueryString()); query != "" { location += "?" + query } - ctx.Set("Location", location) - _, _ = utils.EnsureRequestIDs(ctx) - return ctx.SendStatus(http.StatusMovedPermanently) + return sendRedirect(ctx, http.StatusMovedPermanently, location) } // applyRedirect constructs and sends a redirect response from a routing rule. @@ -430,8 +448,14 @@ func applyRedirect(ctx *fiber.Ctx, redirect *s3response.Redirect, condition *s3r if query := string(ctx.Request().URI().QueryString()); query != "" { location += "?" + query } + return sendRedirect(ctx, httpCode, location) +} + +func sendRedirect(ctx *fiber.Ctx, statusCode int, location string) error { ctx.Set("Location", location) - return ctx.SendStatus(httpCode) + _, _ = utils.EnsureRequestIDs(ctx) + ctx.Status(statusCode) + return nil } func getObjectHeaders(result *s3.GetObjectOutput) map[string]*string { @@ -472,6 +496,14 @@ func headObjectHeaders(result *s3.HeadObjectOutput) map[string]*string { func serveWebsiteResult(ctx *fiber.Ctx, bucket string, config *s3response.WebsiteConfiguration, result websiteResult, readObject websiteObjectReader) error { if result.Err == nil { + // Precedence: RedirectAllRequestsTo, pre-fetch routing rules, object + // redirect metadata, then post-error routing/error documents. + if location := backend.GetStringFromPtr(result.Object.WebsiteRedirectLocation); location != "" { + if result.Object.Body != nil { + _ = result.Object.Body.Close() + } + return sendRedirect(ctx, http.StatusMovedPermanently, location) + } return serveObject(ctx, result.Object, http.StatusOK) } diff --git a/website/handler_test.go b/website/handler_test.go index 486de5cc..85a519b0 100644 --- a/website/handler_test.go +++ b/website/handler_test.go @@ -36,13 +36,14 @@ import ( type websiteTestBackend struct { backend.BackendUnsupported - websiteConfig []byte - corsConfig []byte - corsErr error - objects map[string]string - objectErrors map[string]error - public bool - calls []string + websiteConfig []byte + corsConfig []byte + corsErr error + objects map[string]string + objectRedirects map[string]string + objectErrors map[string]error + public bool + calls []string } func (b *websiteTestBackend) record(call string) { @@ -105,9 +106,11 @@ func (b *websiteTestBackend) HeadObject(_ context.Context, input *s3.HeadObjectI length := int64(len(body)) contentType := "text/html" + redirectLocation := redirectPtr(b.objectRedirects[*input.Key]) return &s3.HeadObjectOutput{ - ContentLength: &length, - ContentType: &contentType, + ContentLength: &length, + ContentType: &contentType, + WebsiteRedirectLocation: redirectLocation, }, nil } @@ -126,13 +129,22 @@ func (b *websiteTestBackend) GetObject(_ context.Context, input *s3.GetObjectInp length := int64(len(body)) contentType := "text/html" + redirectLocation := redirectPtr(b.objectRedirects[*input.Key]) return &s3.GetObjectOutput{ - Body: io.NopCloser(strings.NewReader(body)), - ContentLength: &length, - ContentType: &contentType, + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: &length, + ContentType: &contentType, + WebsiteRedirectLocation: redirectLocation, }, nil } +func redirectPtr(location string) *string { + if location == "" { + return nil + } + return &location +} + func TestWebsiteHandlerRoutingRuleOrder(t *testing.T) { tests := []struct { name string @@ -147,7 +159,7 @@ func TestWebsiteHandlerRoutingRuleOrder(t *testing.T) { Condition: &s3response.RoutingRuleCondition{ KeyPrefixEquals: "old/", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyPrefixWith: "new/", HttpRedirectCode: "301", }, @@ -156,7 +168,7 @@ func TestWebsiteHandlerRoutingRuleOrder(t *testing.T) { Condition: &s3response.RoutingRuleCondition{ HttpErrorCodeReturnedEquals: "404", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyWith: "error.html", HttpRedirectCode: "302", }, @@ -172,7 +184,7 @@ func TestWebsiteHandlerRoutingRuleOrder(t *testing.T) { Condition: &s3response.RoutingRuleCondition{ HttpErrorCodeReturnedEquals: "404", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyWith: "error.html", HttpRedirectCode: "302", }, @@ -181,7 +193,7 @@ func TestWebsiteHandlerRoutingRuleOrder(t *testing.T) { Condition: &s3response.RoutingRuleCondition{ KeyPrefixEquals: "old/", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyPrefixWith: "new/", HttpRedirectCode: "301", }, @@ -224,7 +236,7 @@ func TestWebsiteHandlerRoutingRuleBothConditions(t *testing.T) { KeyPrefixEquals: "old/", HttpErrorCodeReturnedEquals: "404", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyPrefixWith: "new/", HttpRedirectCode: "302", }, @@ -277,6 +289,61 @@ func TestWebsiteHandlerRoutingRuleBothConditions(t *testing.T) { }) } +func TestWebsiteHandlerObjectRedirectLocation(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, map[string]string{ + "old.html": "old", + }, true) + be.objectRedirects["old.html"] = "/new.html" + + resp := websiteRequest(t, be, "/old.html") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMovedPermanently { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently) + } + if got := resp.Header.Get("Location"); got != "/new.html" { + t.Fatalf("Location = %q, want %q", got, "/new.html") + } + if got := readBody(t, resp); got != "" { + t.Fatalf("body = %q, want empty body", got) + } +} + +func TestWebsiteHandlerPrefetchRoutingPrecedesObjectRedirect(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + RoutingRules: []s3response.RoutingRule{ + { + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "old/", + }, + Redirect: &s3response.Redirect{ + ReplaceKeyPrefixWith: "new/", + HttpRedirectCode: "302", + }, + }, + }, + }, map[string]string{ + "old/page.html": "old", + }, true) + be.objectRedirects["old/page.html"] = "/object-redirect.html" + + resp := websiteRequest(t, be, "/old/page.html") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound) + } + if got := resp.Header.Get("Location"); got != "http://site.test/new/page.html" { + t.Fatalf("Location = %q", got) + } + if containsCall(be.calls, "GetObject") { + t.Fatal("GetObject was called before pre-fetch routing completed") + } +} + func TestWebsiteHandlerRedirectConstruction(t *testing.T) { tests := []struct { name string @@ -290,7 +357,7 @@ func TestWebsiteHandlerRedirectConstruction(t *testing.T) { Condition: &s3response.RoutingRuleCondition{ HttpErrorCodeReturnedEquals: "404", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyWith: "error.html", }, }, @@ -303,7 +370,7 @@ func TestWebsiteHandlerRedirectConstruction(t *testing.T) { Condition: &s3response.RoutingRuleCondition{ KeyPrefixEquals: "old/", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyPrefixWith: "new/", }, }, @@ -316,7 +383,7 @@ func TestWebsiteHandlerRedirectConstruction(t *testing.T) { Condition: &s3response.RoutingRuleCondition{ KeyPrefixEquals: "old/", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ HostName: "example.com", Protocol: "https", ReplaceKeyPrefixWith: "new/", @@ -331,7 +398,7 @@ func TestWebsiteHandlerRedirectConstruction(t *testing.T) { Condition: &s3response.RoutingRuleCondition{ KeyPrefixEquals: "old/", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyPrefixWith: "new/", }, }, @@ -369,7 +436,7 @@ func TestWebsiteHandlerPostErrorRoutingUsesOriginalKeyBeforeIndexExpansion(t *te KeyPrefixEquals: "blog/", HttpErrorCodeReturnedEquals: "404", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyPrefixWith: "archive/", HttpRedirectCode: "302", }, @@ -400,7 +467,7 @@ func TestWebsiteHandlerObjectStore5xxBypassesRoutingAndErrorDocument(t *testing. Condition: &s3response.RoutingRuleCondition{ HttpErrorCodeReturnedEquals: "500", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyWith: "elsewhere.html", HttpRedirectCode: "302", }, @@ -438,7 +505,7 @@ func TestWebsiteHandlerPublicAccessDeniedPreventsObjectReadAndCanRoute(t *testin Condition: &s3response.RoutingRuleCondition{ HttpErrorCodeReturnedEquals: "403", }, - Redirect: s3response.Redirect{ + Redirect: &s3response.Redirect{ ReplaceKeyWith: "denied.html", HttpRedirectCode: "302", }, @@ -544,6 +611,28 @@ func TestWebsiteHandlerGetValidatesBucketName(t *testing.T) { } } +func TestWebsiteHandlerNoBucketInRequestSetsLocation(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithDomainHostAndHeaders(t, be, "site.test", http.MethodGet, "wrong.test:8080", "/", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMovedPermanently { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently) + } + if got := resp.Header.Get("Location"); got != "http://site.test:8080/" { + t.Fatalf("Location = %q, want %q", got, "http://site.test:8080/") + } + if got := resp.Header.Get("x-amz-error-code"); got != "WebsiteRedirect" { + t.Fatalf("x-amz-error-code = %q, want %q", got, "WebsiteRedirect") + } + if len(be.calls) != 0 { + t.Fatalf("request without bucket should not call backend, got calls: %v", be.calls) + } +} + func TestWebsiteHandlerHeadValidatesObjectName(t *testing.T) { be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, @@ -707,7 +796,7 @@ func TestWebsiteHandlerOptionsAccessGranted(t *testing.T) { if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "content-type, x-amz-date" { t.Fatalf("Access-Control-Allow-Headers = %q", got) } - if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag" { + if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length" { t.Fatalf("Access-Control-Expose-Headers = %q", got) } if got := resp.Header.Get("Access-Control-Max-Age"); got != "42" { @@ -893,9 +982,10 @@ func newWebsiteTestBackend(t *testing.T, config s3response.WebsiteConfiguration, } return &websiteTestBackend{ - websiteConfig: data, - objects: objects, - public: public, + websiteConfig: data, + objects: objects, + objectRedirects: map[string]string{}, + public: public, } } @@ -920,10 +1010,16 @@ func websiteRequestWithHeaders(t *testing.T, be backend.Backend, method, path st func websiteRequestWithHostAndHeaders(t *testing.T, be backend.Backend, method, host, path string, headers map[string]string) *http.Response { t.Helper() + return websiteRequestWithDomainHostAndHeaders(t, be, "", method, host, path, headers) +} + +func websiteRequestWithDomainHostAndHeaders(t *testing.T, be backend.Backend, domain, method, host, path string, headers map[string]string) *http.Response { + t.Helper() + app := fiber.New(fiber.Config{ ServerHeader: "VERSITYGW", }) - registerWebsiteRoutes(app, be, "") + registerWebsiteRoutes(app, be, domain) req := httptest.NewRequest(method, path, nil) req.Host = host