From 94fa6f29247faa3a954e6ab8891cb3102e17d895 Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Sat, 26 Sep 2026 15:02:18 +0200 Subject: [PATCH 1/5] keep system metadata when restoring from cold storage The cold backend writes the restored object back with a PutObject (or CompleteMultipartUpload) carrying its own headers, so the object ended up with the SDK default content-type and lost cache-control, content-disposition, content-encoding and expires. Object lock retention was dropped too, or silently recomputed from the bucket default rule. Take all of these from the archived metadata instead of the restore request. Issue: CLDSRV-1009 --- lib/api/apiUtils/object/versioning.js | 8 ++ tests/unit/api/apiUtils/versioning.js | 131 +++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/lib/api/apiUtils/object/versioning.js b/lib/api/apiUtils/object/versioning.js index a9018c0a60..a95afa0009 100644 --- a/lib/api/apiUtils/object/versioning.js +++ b/lib/api/apiUtils/object/versioning.js @@ -494,6 +494,14 @@ function restoreMetadata(objMD, metadataStoreParams) { } }); + metadataStoreParams.contentType = objMD['content-type']; + metadataStoreParams.cacheControl = objMD['cache-control']; + metadataStoreParams.contentDisposition = objMD['content-disposition']; + metadataStoreParams.contentEncoding = objMD['content-encoding']; + metadataStoreParams.expires = objMD.expires; + metadataStoreParams.retentionMode = objMD.retentionMode; + metadataStoreParams.retentionDate = objMD.retentionDate; + if (objMD['x-amz-website-redirect-location']) { if (!metadataStoreParams.headers) { metadataStoreParams.headers = {}; diff --git a/tests/unit/api/apiUtils/versioning.js b/tests/unit/api/apiUtils/versioning.js index c89eca7aba..d3e47d9dd7 100644 --- a/tests/unit/api/apiUtils/versioning.js +++ b/tests/unit/api/apiUtils/versioning.js @@ -698,6 +698,16 @@ describe('versioning helpers', () => { archiveID: '126783123678', }; const now = Date.now(); + // always taken from the archived object, so cleared when it had none + const clearedSystemMD = { + contentType: undefined, + cacheControl: undefined, + contentDisposition: undefined, + contentEncoding: undefined, + expires: undefined, + retentionMode: undefined, + retentionDate: undefined, + }; let clock; beforeEach(() => { @@ -938,6 +948,125 @@ describe('versioning helpers', () => { }, }, }, + { + description: 'Should keep the system metadata of the archived object', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'content-type': 'application/zip', + 'cache-control': 'no-cache', + 'content-disposition': 'attachment; filename="archive.zip"', + 'content-encoding': 'gzip', + expires: 'Wed, 21 Oct 2026 07:28:00 GMT', + 'x-amz-website-redirect-location': '/elsewhere', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + contentType: 'binary/octet-stream', + headers: {}, + }, + expectedRes: { + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + contentType: 'application/zip', + cacheControl: 'no-cache', + contentDisposition: 'attachment; filename="archive.zip"', + contentEncoding: 'gzip', + expires: 'Wed, 21 Oct 2026 07:28:00 GMT', + headers: { + 'x-amz-website-redirect-location': '/elsewhere', + }, + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, + { + description: 'Should drop the system metadata sent with the restore request', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + contentType: 'binary/octet-stream', + cacheControl: 'no-store', + contentDisposition: 'inline', + contentEncoding: 'identity', + expires: 'Wed, 21 Oct 2026 07:28:00 GMT', + }, + expectedRes: { + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, + { + description: 'Should keep the object lock retention', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + retentionMode: 'GOVERNANCE', + retentionDate: '2026-10-21T07:28:00.000Z', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + expectedRes: { + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + retentionMode: 'GOVERNANCE', + retentionDate: '2026-10-21T07:28:00.000Z', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, { description: 'Should keep ACLs', objMD: { @@ -1031,7 +1160,7 @@ describe('versioning helpers', () => { } const options = overwritingVersioning(testCase.objMD, metadataStoreParams); assert.deepStrictEqual(options.versionId, testCase.objMD.versionId); - assert.deepStrictEqual(metadataStoreParams, testCase.expectedRes); + assert.deepStrictEqual(metadataStoreParams, { ...clearedSystemMD, ...testCase.expectedRes }); if (testCase.objMD.isNull) { assert.deepStrictEqual(options.extraMD.nullVersionId, 'vnull'); From ff9dcf4f39caa3f0478c154d583bc620fba4f84f Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Wed, 30 Sep 2026 15:08:59 +0200 Subject: [PATCH 2/5] Don't take object lock info from the restore request The restored version must carry the object lock info of the archived object. The object lock headers of the restore request and the bucket default retention could otherwise override it, or add a retention the archived object did not have. Issue: CLDSRV-1009 --- lib/api/apiUtils/object/versioning.js | 10 +++ tests/unit/api/apiUtils/versioning.js | 74 +++++++++++++++++ tests/unit/api/objectPut.js | 110 ++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/lib/api/apiUtils/object/versioning.js b/lib/api/apiUtils/object/versioning.js index a95afa0009..13743ba8c5 100644 --- a/lib/api/apiUtils/object/versioning.js +++ b/lib/api/apiUtils/object/versioning.js @@ -502,6 +502,16 @@ function restoreMetadata(objMD, metadataStoreParams) { metadataStoreParams.retentionMode = objMD.retentionMode; metadataStoreParams.retentionDate = objMD.retentionDate; + // Object lock info comes from the archived object only, not from the + // restore request or the bucket default retention + delete metadataStoreParams.defaultRetention; + if (metadataStoreParams.headers) { + metadataStoreParams.headers = { ...metadataStoreParams.headers }; + delete metadataStoreParams.headers['x-amz-object-lock-mode']; + delete metadataStoreParams.headers['x-amz-object-lock-retain-until-date']; + delete metadataStoreParams.headers['x-amz-object-lock-legal-hold']; + } + if (objMD['x-amz-website-redirect-location']) { if (!metadataStoreParams.headers) { metadataStoreParams.headers = {}; diff --git a/tests/unit/api/apiUtils/versioning.js b/tests/unit/api/apiUtils/versioning.js index d3e47d9dd7..e800924eb4 100644 --- a/tests/unit/api/apiUtils/versioning.js +++ b/tests/unit/api/apiUtils/versioning.js @@ -1067,6 +1067,80 @@ describe('versioning helpers', () => { }, }, }, + { + description: 'Should not apply the bucket default retention', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + defaultRetention: { + rule: { mode: 'COMPLIANCE', days: 1 }, + }, + }, + expectedRes: { + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, + { + description: 'Should drop object lock headers', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + headers: { + host: 'localhost', + 'x-amz-object-lock-mode': 'GOVERNANCE', + 'x-amz-object-lock-retain-until-date': new Date(now).toISOString(), + 'x-amz-object-lock-legal-hold': 'ON', + }, + }, + expectedRes: { + headers: { host: 'localhost' }, + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, { description: 'Should keep ACLs', objMD: { diff --git a/tests/unit/api/objectPut.js b/tests/unit/api/objectPut.js index 2477ad04d6..af01d58c97 100644 --- a/tests/unit/api/objectPut.js +++ b/tests/unit/api/objectPut.js @@ -299,6 +299,116 @@ describe('objectPut API', () => { ); }); + it('should not apply the bucket default retention when restoring an object without retention', done => { + const testObjLockRequest = { + bucketName, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + post: objectLockTestUtils.generateXml('COMPLIANCE', 30, 'Days'), + }; + const archiveRestoreRequested = { + archiveInfo: { foo: 0, bar: 'stuff' }, + restoreRequestedAt: new Date().toString(), + restoreRequestedDays: 5, + }; + let versionId; + + async.series( + [ + next => bucketPut(authInfo, testPutBucketRequestLock, log, next), + next => + objectPut(authInfo, testPutObjectRequest, undefined, log, (err, headers) => { + versionId = headers?.['x-amz-version-id']; + next(err); + }), + next => bucketPutObjectLock(authInfo, testObjLockRequest, log, next), + next => fakeMetadataArchive(bucketName, objectName, versionId, archiveRestoreRequested, next), + next => { + const restoreRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + 'x-scal-s3-version-id': versionId, + }, + url: '/', + }, + postBody, + ); + objectPut(authInfo, restoreRequest, undefined, log, (err, headers) => { + assert.ifError(err); + assert.strictEqual(headers['x-amz-version-id'], versionId); + next(); + }); + }, + next => + metadata.getObjectMD(bucketName, objectName, {}, log, (err, md) => { + assert.ifError(err); + assert(md.archive.restoreCompletedAt); + assert.strictEqual(md.retentionMode, undefined); + assert.strictEqual(md.retentionDate, undefined); + next(); + }), + ], + done, + ); + }); + + it('should ignore object lock headers when restoring an object', done => { + const archiveRestoreRequested = { + archiveInfo: { foo: 0, bar: 'stuff' }, + restoreRequestedAt: new Date().toString(), + restoreRequestedDays: 5, + }; + let versionId; + + async.series( + [ + next => bucketPut(authInfo, testPutBucketRequestLock, log, next), + next => + objectPut(authInfo, testPutObjectRequest, undefined, log, (err, headers) => { + versionId = headers?.['x-amz-version-id']; + next(err); + }), + next => fakeMetadataArchive(bucketName, objectName, versionId, archiveRestoreRequested, next), + next => { + const restoreRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + 'x-scal-s3-version-id': versionId, + 'x-amz-object-lock-mode': 'GOVERNANCE', + 'x-amz-object-lock-retain-until-date': moment().add(1, 'days').toISOString(), + 'x-amz-object-lock-legal-hold': 'ON', + }, + url: '/', + }, + postBody, + ); + objectPut(authInfo, restoreRequest, undefined, log, (err, headers) => { + assert.ifError(err); + assert.strictEqual(headers['x-amz-version-id'], versionId); + next(); + }); + }, + next => + metadata.getObjectMD(bucketName, objectName, {}, log, (err, md) => { + assert.ifError(err); + assert(md.archive.restoreCompletedAt); + assert.strictEqual(md.retentionMode, undefined); + assert.strictEqual(md.retentionDate, undefined); + assert(!md.legalHold); + next(); + }), + ], + done, + ); + }); + it('should successfully put an object with legal hold ON', done => { const request = new DummyRequest( { From d4045d17c409c3f1cd769ae330b6c1b2330be66d Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Wed, 30 Sep 2026 16:34:43 +0200 Subject: [PATCH 3/5] Don't take ACL from the restore request The ACL of a restored object must be the one of the archived object: ACL headers sent by the cold backend on the restore PutObject would otherwise override it. Issue: CLDSRV-1009 --- lib/api/apiUtils/object/versioning.js | 9 +++- tests/unit/api/apiUtils/versioning.js | 41 ++++++++++++++++ tests/unit/api/objectPut.js | 68 +++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/lib/api/apiUtils/object/versioning.js b/lib/api/apiUtils/object/versioning.js index 13743ba8c5..7fb8deb6eb 100644 --- a/lib/api/apiUtils/object/versioning.js +++ b/lib/api/apiUtils/object/versioning.js @@ -502,14 +502,19 @@ function restoreMetadata(objMD, metadataStoreParams) { metadataStoreParams.retentionMode = objMD.retentionMode; metadataStoreParams.retentionDate = objMD.retentionDate; - // Object lock info comes from the archived object only, not from the - // restore request or the bucket default retention + // Object lock info and ACL come from the archived object only, not from + // the restore request or the bucket default retention delete metadataStoreParams.defaultRetention; if (metadataStoreParams.headers) { metadataStoreParams.headers = { ...metadataStoreParams.headers }; delete metadataStoreParams.headers['x-amz-object-lock-mode']; delete metadataStoreParams.headers['x-amz-object-lock-retain-until-date']; delete metadataStoreParams.headers['x-amz-object-lock-legal-hold']; + delete metadataStoreParams.headers['x-amz-acl']; + delete metadataStoreParams.headers['x-amz-grant-full-control']; + delete metadataStoreParams.headers['x-amz-grant-read']; + delete metadataStoreParams.headers['x-amz-grant-read-acp']; + delete metadataStoreParams.headers['x-amz-grant-write-acp']; } if (objMD['x-amz-website-redirect-location']) { diff --git a/tests/unit/api/apiUtils/versioning.js b/tests/unit/api/apiUtils/versioning.js index e800924eb4..cead9bafab 100644 --- a/tests/unit/api/apiUtils/versioning.js +++ b/tests/unit/api/apiUtils/versioning.js @@ -1141,6 +1141,47 @@ describe('versioning helpers', () => { }, }, }, + { + description: 'Should drop ACL headers', + objMD: { + versionId: '2345678', + 'creation-time': now, + 'last-modified': now, + originOp: 's3:PutObject', + 'x-amz-storage-class': 'cold-location', + archive: { + restoreRequestedDays: days, + restoreRequestedAt: now, + archiveInfo, + }, + }, + metadataStoreParams: { + headers: { + host: 'localhost', + 'x-amz-acl': 'public-read-write', + 'x-amz-grant-full-control': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + 'x-amz-grant-read': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + 'x-amz-grant-read-acp': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + 'x-amz-grant-write-acp': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + }, + }, + expectedRes: { + headers: { host: 'localhost' }, + creationTime: now, + lastModifiedDate: now, + updateMicroVersionId: true, + originOp: 's3:ObjectRestore:Completed', + taggingCopy: undefined, + amzStorageClass: 'cold-location', + archive: { + archiveInfo, + restoreRequestedDays: days, + restoreRequestedAt: now, + restoreCompletedAt: new Date(now), + restoreWillExpireAt: new Date(now + days * scaledMsPerDay), + }, + }, + }, { description: 'Should keep ACLs', objMD: { diff --git a/tests/unit/api/objectPut.js b/tests/unit/api/objectPut.js index af01d58c97..f92713f93a 100644 --- a/tests/unit/api/objectPut.js +++ b/tests/unit/api/objectPut.js @@ -409,6 +409,74 @@ describe('objectPut API', () => { ); }); + it('should ignore ACL headers when restoring an object', done => { + const putRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { 'x-amz-acl': 'public-read' }, + url: `/${bucketName}/${objectName}`, + }, + postBody, + ); + const archiveRestoreRequested = { + archiveInfo: { foo: 0, bar: 'stuff' }, + restoreRequestedAt: new Date().toString(), + restoreRequestedDays: 5, + }; + let versionId; + let originalAcl; + + async.series( + [ + next => bucketPut(authInfo, testPutBucketRequestLock, log, next), + next => + objectPut(authInfo, putRequest, undefined, log, (err, headers) => { + versionId = headers?.['x-amz-version-id']; + next(err); + }), + next => + metadata.getObjectMD(bucketName, objectName, {}, log, (err, md) => { + originalAcl = md?.acl; + next(err); + }), + next => fakeMetadataArchive(bucketName, objectName, versionId, archiveRestoreRequested, next), + next => { + const restoreRequest = new DummyRequest( + { + bucketName, + namespace, + objectKey: objectName, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + 'x-scal-s3-version-id': versionId, + 'x-amz-acl': 'public-read-write', + 'x-amz-grant-full-control': 'uri=http://acs.amazonaws.com/groups/global/AllUsers', + }, + url: '/', + }, + postBody, + ); + objectPut(authInfo, restoreRequest, undefined, log, (err, headers) => { + assert.ifError(err); + assert.strictEqual(headers['x-amz-version-id'], versionId); + next(); + }); + }, + next => + metadata.getObjectMD(bucketName, objectName, {}, log, (err, md) => { + assert.ifError(err); + assert(md.archive.restoreCompletedAt); + assert.strictEqual(originalAcl.Canned, 'public-read'); + assert.deepStrictEqual(md.acl, originalAcl); + next(); + }), + ], + done, + ); + }); + it('should successfully put an object with legal hold ON', done => { const request = new DummyRequest( { From b54d5fb5ca39b60f385dbddcee12805965d7a2ca Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Wed, 30 Sep 2026 21:46:09 +0200 Subject: [PATCH 4/5] Expect restored objects to keep content-type and content-encoding The cold storage restore functional tests for PutObject and CompleteMPU with x-scal-s3-version-id still asserted the old behaviour, where content-type and content-encoding were taken from the restore request. These are system metadata of the archived object and are now preserved on restore, so the tests must expect the archived values. Issue: CLDSRV-1009 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/functional/aws-node-sdk/test/object/mpuVersion.js | 4 ++-- tests/functional/aws-node-sdk/test/object/putVersion.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/functional/aws-node-sdk/test/object/mpuVersion.js b/tests/functional/aws-node-sdk/test/object/mpuVersion.js index c42b97cacd..4efecad8cb 100644 --- a/tests/functional/aws-node-sdk/test/object/mpuVersion.js +++ b/tests/functional/aws-node-sdk/test/object/mpuVersion.js @@ -1019,8 +1019,8 @@ describe('MPU with x-scal-s3-version-id header', () => { // make sure data related metadatas ar not the same before and after assert.notStrictEqual(finalObjMD['x-amz-server-side-encryption'], 'aws:kms'); assert.notStrictEqual(finalObjMD['content-length'], 99); - assert.notStrictEqual(finalObjMD['content-encoding'], 'testencoding'); - assert.notStrictEqual(finalObjMD['content-type'], 'testtype'); + assert.strictEqual(finalObjMD['content-encoding'], 'testencoding'); + assert.strictEqual(finalObjMD['content-type'], 'testtype'); // make sure we keep the same etag and add the new restored // data's etag inside x-amz-restore assert.strictEqual(finalObjMD['content-md5'], 'testmd5'); diff --git a/tests/functional/aws-node-sdk/test/object/putVersion.js b/tests/functional/aws-node-sdk/test/object/putVersion.js index d49e869d40..5c582b80df 100644 --- a/tests/functional/aws-node-sdk/test/object/putVersion.js +++ b/tests/functional/aws-node-sdk/test/object/putVersion.js @@ -1159,8 +1159,8 @@ describe('PUT object with x-scal-s3-version-id header', () => { // make sure data related metadatas ar not the same before and after assert.notStrictEqual(objMD['x-amz-server-side-encryption'], 'aws:kms'); assert.notStrictEqual(objMD['content-length'], 99); - assert.notStrictEqual(objMD['content-encoding'], 'testencoding'); - assert.notStrictEqual(objMD['content-type'], 'testtype'); + assert.strictEqual(objMD['content-encoding'], 'testencoding'); + assert.strictEqual(objMD['content-type'], 'testtype'); // make sure we keep the same etag and add the new restored // data's etag inside x-amz-restore assert.strictEqual(objMD['content-md5'], 'testmd5'); From 9ab6299c69e255150be5806642cd5a1d7b0c17f3 Mon Sep 17 00:00:00 2001 From: Francois Ferrand Date: Thu, 1 Oct 2026 19:46:34 +0200 Subject: [PATCH 5/5] Release 9.4.6 Issue: CLDSRV-1009 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 477f45a2e4..bdc263d28a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenko/cloudserver", - "version": "9.4.5", + "version": "9.4.6", "description": "Zenko CloudServer, an open-source Node.js implementation of a server handling the Amazon S3 protocol", "main": "index.js", "engines": {