From 1e5fe9fb04f8e03ffdf6e0a5ed97e0a39ddee2e9 Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Thu, 8 Jan 2026 11:31:56 +0100 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20update=20the=20XML=20response?= =?UTF-8?q?=20to=20support=20Optional=20attributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- lib/api/bucketGet.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/lib/api/bucketGet.js b/lib/api/bucketGet.js index 64b5322fdd..3fd9d9e820 100644 --- a/lib/api/bucketGet.js +++ b/lib/api/bucketGet.js @@ -138,6 +138,7 @@ function processVersions(bucketName, listParams, list) { const objectKey = escapeXmlFn(item.key); const isLatest = lastKey !== objectKey; lastKey = objectKey; + xml.push( v.IsDeleteMarker ? '' : '', `${objectKey}`, @@ -153,14 +154,17 @@ function processVersions(bucketName, listParams, list) { `${v.Owner.ID}`, `${v.Owner.DisplayName}`, '', + ...processOptionalAttributes(v, listParams.optionalAttributes), `${v.StorageClass}`, v.IsDeleteMarker ? '' : '' ); }); + list.CommonPrefixes.forEach(item => { const val = escapeXmlFn(item); xml.push(`${val}`); }); + xml.push(''); return xml.join(''); } @@ -224,6 +228,7 @@ function processMasterVersions(bucketName, listParams, list) { if (v.isDeleteMarker) { return null; } + const objectKey = escapeXmlFn(item.key); xml.push( '', @@ -232,6 +237,7 @@ function processMasterVersions(bucketName, listParams, list) { `"${v.ETag}"`, `${v.Size}` ); + if (!listParams.v2 || listParams.fetchOwner) { xml.push( '', @@ -240,6 +246,9 @@ function processMasterVersions(bucketName, listParams, list) { '' ); } + + xml.push(...processOptionalAttributes(v, listParams.optionalAttributes)); + return xml.push( `${v.StorageClass}`, '' @@ -253,20 +262,48 @@ function processMasterVersions(bucketName, listParams, list) { return xml.join(''); } +function processOptionalAttributes(item, optionalAttributes) { + const xml = []; + + for (const key of Object.keys(item)) { + if (key.startsWith('x-amz-meta-')) { + if (optionalAttributes.includes('x-amz-meta-*') || optionalAttributes.includes(key)) { + xml.push(`<${key}>${item[key]}`); + } + } + } + + if (optionalAttributes.includes('RestoreStatus')) { + xml.push(''); + xml.push(`${!!item.restoreStatus?.inProgress}`); + + if (item.restoreStatus?.expiryDate) { + xml.push(`${item.restoreStatus?.expiryDate}`); + } + + xml.push(''); + } + + return xml; +} + function handleResult(listParams, requestMaxKeys, encoding, authInfo, bucketName, list, corsHeaders, log, callback) { // eslint-disable-next-line no-param-reassign listParams.maxKeys = requestMaxKeys; // eslint-disable-next-line no-param-reassign listParams.encoding = encoding; + let res; if (listParams.listingType === 'DelimiterVersions') { res = processVersions(bucketName, listParams, list); } else { res = processMasterVersions(bucketName, listParams, list); } + pushMetric('listBucket', log, { authInfo, bucket: bucketName }); monitoring.promMetrics('GET', bucketName, '200', 'listBucket'); + return callback(null, res, corsHeaders); } @@ -336,6 +373,7 @@ function bucketGet(authInfo, request, log, callback) { listingType: 'DelimiterMaster', maxKeys: actualMaxKeys, prefix: params.prefix, + optionalAttributes, }; if (params.delimiter) { From e38e9c33c4904e0107e3409ecb5a14cd4810ba34 Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Thu, 8 Jan 2026 17:00:37 +0100 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=85=20add=20new=20tests=20for=20the?= =?UTF-8?q?=20new=20response=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- tests/unit/api/bucketGet.js | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/unit/api/bucketGet.js b/tests/unit/api/bucketGet.js index 48befeafef..7ece7773ea 100644 --- a/tests/unit/api/bucketGet.js +++ b/tests/unit/api/bucketGet.js @@ -439,5 +439,99 @@ describe('bucketGet API V2', () => { done(); }); }); + + it('should return user metadata if requested and present', done => { + const objectNameMeta = 'objectWithMeta'; + const putRequest = new DummyRequest({ + bucketName, + headers: { 'x-amz-meta-color': 'red' }, + url: `/${bucketName}/${objectNameMeta}`, + namespace, + objectKey: objectNameMeta, + }, postBody); + + const testGetRequest = Object.assign({ + query: {}, + url: baseUrl, + }, baseGetRequest); + testGetRequest.headers['x-amz-optional-object-attributes'] = 'x-amz-meta-color'; + + async.waterfall([ + next => bucketPut(authInfo, testPutBucketRequest, log, next), + (_, next) => objectPut(authInfo, putRequest, undefined, log, next), + (_, next) => bucketGet(authInfo, testGetRequest, log, next), + (result, _, next) => parseString(result, next), + ], + (err, result) => { + assert.strictEqual(err, null); + const content = result.ListBucketResult.Contents[0]; + assert.strictEqual(content.Key[0], objectNameMeta); + assert.strictEqual(content['x-amz-meta-color'][0], 'red'); + done(); + }); + }); + + it('should return all user metadata if wildcard requested', done => { + const objectNameMeta = 'objectWithMetaWildcard'; + const putRequest = new DummyRequest({ + bucketName, + headers: { 'x-amz-meta-color': 'red', 'x-amz-meta-size': 'large' }, + url: `/${bucketName}/${objectNameMeta}`, + namespace, + objectKey: objectNameMeta, + }, postBody); + + const testGetRequest = Object.assign({ + query: {}, + url: baseUrl, + }, baseGetRequest); + testGetRequest.headers['x-amz-optional-object-attributes'] = 'x-amz-meta-*'; + + async.waterfall([ + next => bucketPut(authInfo, testPutBucketRequest, log, next), + (_, next) => objectPut(authInfo, putRequest, undefined, log, next), + (_, next) => bucketGet(authInfo, testGetRequest, log, next), + (result, _, next) => parseString(result, next), + ], + (err, result) => { + assert.strictEqual(err, null); + const content = result.ListBucketResult.Contents[0]; + assert.strictEqual(content.Key[0], objectNameMeta); + assert.strictEqual(content['x-amz-meta-color'][0], 'red'); + assert.strictEqual(content['x-amz-meta-size'][0], 'large'); + done(); + }); + }); + + it('should return user metadata in version listing if requested', done => { + const objectNameMeta = 'objectWithMetaVersion'; + const putRequest = new DummyRequest({ + bucketName, + headers: { 'x-amz-meta-ver': '1' }, + url: `/${bucketName}/${objectNameMeta}`, + namespace, + objectKey: objectNameMeta, + }, postBody); + + const testGetRequest = Object.assign({ + query: { versions: '' }, + url: `${baseUrl}?versions`, + }, baseGetRequest); + testGetRequest.headers['x-amz-optional-object-attributes'] = 'x-amz-meta-ver'; + + async.waterfall([ + next => bucketPut(authInfo, testPutBucketRequest, log, next), + (_, next) => objectPut(authInfo, putRequest, undefined, log, next), + (_, next) => bucketGet(authInfo, testGetRequest, log, next), + (result, _, next) => parseString(result, next), + ], + (err, result) => { + assert.strictEqual(err, null); + const version = result.ListVersionsResult.Version[0]; + assert.strictEqual(version.Key[0], objectNameMeta); + assert.strictEqual(version['x-amz-meta-ver'][0], '1'); + done(); + }); + }); }); }); From 1eff557a316c2cd4b4710a9c2c93be6ea86148cd Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Thu, 8 Jan 2026 17:26:43 +0100 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=20update=20Veeam=20route=20to?= =?UTF-8?q?=20pass=20the=20new=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- lib/routes/veeam/list.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/routes/veeam/list.js b/lib/routes/veeam/list.js index 1fcb2e0158..5fd8585bfb 100644 --- a/lib/routes/veeam/list.js +++ b/lib/routes/veeam/list.js @@ -25,6 +25,7 @@ function buildXMLResponse(request, arrayOfFiles, versioned = false) { prefix: validPath, maxKeys: parsedQs['max-keys'] || 1000, delimiter: '/', + optionalAttributes: [], }; const list = { IsTruncated: false, From f4c8c5c5e7c3908dcd31256df8c5d1b36e19feb5 Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Fri, 9 Jan 2026 10:03:53 +0100 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=A8=20update=20metadataSearch=20to?= =?UTF-8?q?=20pass=20the=20right=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- lib/api/metadataSearch.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/api/metadataSearch.js b/lib/api/metadataSearch.js index 959068eee0..900b0cceef 100644 --- a/lib/api/metadataSearch.js +++ b/lib/api/metadataSearch.js @@ -19,6 +19,8 @@ function handleResult(listParams, requestMaxKeys, encoding, authInfo, listParams.maxKeys = requestMaxKeys; // eslint-disable-next-line no-param-reassign listParams.encoding = encoding; + // eslint-disable-next-line no-param-reassign + listParams.optionalAttributes = []; let res; if (listParams.listingType === 'DelimiterVersions') { res = processVersions(bucketName, listParams, list); From 2fbe06e274504caa16290b5061126ff72d10ffd8 Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Tue, 13 Jan 2026 11:31:55 +0100 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=A8=20process=20attributes=20as=20l?= =?UTF-8?q?ower=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- lib/api/bucketGet.js | 6 +++++- tests/unit/api/bucketGet.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/api/bucketGet.js b/lib/api/bucketGet.js index 3fd9d9e820..2cc17cce96 100644 --- a/lib/api/bucketGet.js +++ b/lib/api/bucketGet.js @@ -323,7 +323,11 @@ function bucketGet(authInfo, request, log, callback) { const v2 = params['list-type']; const optionalAttributes = - request.headers['x-amz-optional-object-attributes']?.split(',').map(attr => attr.trim()) ?? []; + request.headers['x-amz-optional-object-attributes'] + ?.split(',') + .map(attr => attr.trim()) + .map(attr => attr !== 'RestoreStatus' ? attr.toLowerCase() : attr) + ?? []; if (optionalAttributes.some(attr => !attr.startsWith('x-amz-meta-') && attr != 'RestoreStatus')) { return callback( errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified') diff --git a/tests/unit/api/bucketGet.js b/tests/unit/api/bucketGet.js index 7ece7773ea..2ed7026c5f 100644 --- a/tests/unit/api/bucketGet.js +++ b/tests/unit/api/bucketGet.js @@ -533,5 +533,36 @@ describe('bucketGet API V2', () => { done(); }); }); + + it('should return user metadata as case insentive (lowercase header)', done => { + const objectNameMeta = 'objectWithMeta'; + const putRequest = new DummyRequest({ + bucketName, + headers: { 'x-amz-meta-color': 'yellow' }, + url: `/${bucketName}/${objectNameMeta}`, + namespace, + objectKey: objectNameMeta, + }, postBody); + + const testGetRequest = Object.assign({ + query: {}, + url: baseUrl, + }, baseGetRequest); + testGetRequest.headers['x-amz-optional-object-attributes'] = 'x-amz-meta-coLor'; + + async.waterfall([ + next => bucketPut(authInfo, testPutBucketRequest, log, next), + (_, next) => objectPut(authInfo, putRequest, undefined, log, next), + (_, next) => bucketGet(authInfo, testGetRequest, log, next), + (result, _, next) => parseString(result, next), + ], + (err, result) => { + assert.strictEqual(err, null); + const content = result.ListBucketResult.Contents[0]; + assert.strictEqual(content.Key[0], objectNameMeta); + assert.strictEqual(content['x-amz-meta-color'][0], 'yellow'); + done(); + }); + }); }); }); From 20699f531acc01209d7428dd6891bc08cdc6be3a Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Tue, 13 Jan 2026 11:32:52 +0100 Subject: [PATCH 06/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20parse=20optional=20a?= =?UTF-8?q?ttributes=20instead=20of=20all=20key=20to=20improve=20performan?= =?UTF-8?q?ce=20and=20switch=20to=20a=20subobject=20usermetadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- lib/api/bucketGet.js | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/lib/api/bucketGet.js b/lib/api/bucketGet.js index 2cc17cce96..70f43a416b 100644 --- a/lib/api/bucketGet.js +++ b/lib/api/bucketGet.js @@ -264,26 +264,37 @@ function processMasterVersions(bucketName, listParams, list) { function processOptionalAttributes(item, optionalAttributes) { const xml = []; - - for (const key of Object.keys(item)) { - if (key.startsWith('x-amz-meta-')) { - if (optionalAttributes.includes('x-amz-meta-*') || optionalAttributes.includes(key)) { - xml.push(`<${key}>${item[key]}`); - } + const userMetadata = new Set(); + + for (const attribute of optionalAttributes) { + switch (attribute) { + case 'RestoreStatus': + xml.push(''); + xml.push(`${!!item.restoreStatus?.inProgress}`); + + if (item.restoreStatus?.expiryDate) { + xml.push(`${item.restoreStatus?.expiryDate}`); + } + + xml.push(''); + break; + case 'x-amz-meta-*': + for (const key of Object.keys(item.userMetadata)) { + userMetadata.add(key); + } + break; + default: + if (item.userMetadata?.[attribute]) { + userMetadata.add(attribute); + } } } - if (optionalAttributes.includes('RestoreStatus')) { - xml.push(''); - xml.push(`${!!item.restoreStatus?.inProgress}`); - - if (item.restoreStatus?.expiryDate) { - xml.push(`${item.restoreStatus?.expiryDate}`); - } - - xml.push(''); + for (const key of userMetadata) { + xml.push(`<${key}>${item.userMetadata[key]}`); } + return xml; } From 49205381d756c107b12bb4678ba7bab879bb03da Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Tue, 13 Jan 2026 16:01:45 +0100 Subject: [PATCH 07/11] =?UTF-8?q?=E2=9C=85=20create=20a=20new=20functional?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- lib/api/bucketGet.js | 1 - .../aws-node-sdk/test/bucket/get.js | 59 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/lib/api/bucketGet.js b/lib/api/bucketGet.js index 70f43a416b..3f5e352b75 100644 --- a/lib/api/bucketGet.js +++ b/lib/api/bucketGet.js @@ -294,7 +294,6 @@ function processOptionalAttributes(item, optionalAttributes) { xml.push(`<${key}>${item.userMetadata[key]}`); } - return xml; } diff --git a/tests/functional/aws-node-sdk/test/bucket/get.js b/tests/functional/aws-node-sdk/test/bucket/get.js index 0267ad7a65..1846d54ccb 100644 --- a/tests/functional/aws-node-sdk/test/bucket/get.js +++ b/tests/functional/aws-node-sdk/test/bucket/get.js @@ -1,5 +1,6 @@ const assert = require('assert'); const tv4 = require('tv4'); +const { parseString } = require('xml2js'); const withV4 = require('../support/withV4'); const BucketUtility = require('../../lib/utility/bucket-util'); @@ -373,6 +374,64 @@ describe('GET Bucket - AWS.S3.listObjects', () => { }); }); + it('should manage the x-amz-optional-attributes header', async () => { + const s3 = bucketUtil.s3; + const Bucket = bucketName; + + await s3.putObject({ + Bucket, + Key: 'super-power-object', + Metadata: { + Department: 'sales', + HR: 'true', + }, + }).promise(); + + const result = await new Promise((resolve, reject) => { + let rawXml = ''; + const req = s3.listObjectsV2({ Bucket }); + + req.on('build', () => { + req.httpRequest.headers['x-amz-optional-object-attributes'] = 'x-amz-meta-*'; + req.httpRequest.headers['x-amz-optional-object-attributes'] += ',RestoreStatus'; + req.httpRequest.headers['x-amz-optional-object-attributes'] += ',x-amz-meta-department'; + }); + req.on('httpData', chunk => { rawXml += chunk; }); + req.on('error', err => reject(err)); + req.on('success', response => { + parseString(rawXml, (err, parsedXml) => { + if (err) { + return reject(err); + } + + const contents = response.data.Contents; + const parsedContents = parsedXml.ListBucketResult.Contents; + + if (!contents || !parsedContents) { + return resolve(response.data); + } + + if (parsedContents[0]?.['x-amz-meta-department']) { + contents[0]['x-amz-meta-department'] = parsedContents[0]['x-amz-meta-department'][0]; + } + + if (parsedContents[0]?.['x-amz-meta-hr']) { + contents[0]['x-amz-meta-hr'] = parsedContents[0]['x-amz-meta-hr'][0]; + } + + return resolve(response.data); + }); + }); + + req.send(); + }); + + assert.strictEqual(result.Contents.length, 1); + assert.strictEqual(result.Contents[0].Key, 'super-power-object'); + assert.strictEqual(result.Contents[0]['x-amz-meta-department'], 'sales'); + assert.strictEqual(result.Contents[0]['x-amz-meta-hr'], 'true'); + }); + ['&', '"quot', '\'apos', 'gt'].forEach(k => { it(`should list objects with key ${k} as Prefix`, async () => { const s3 = bucketUtil.s3; From 6263b2db0086f47c34b090782a106dec8418e3b8 Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Wed, 14 Jan 2026 15:14:54 +0100 Subject: [PATCH 08/11] =?UTF-8?q?=E2=9C=85=20add=20new=20functional=20test?= =?UTF-8?q?s=20to=20cover=20more=20use=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- .../aws-node-sdk/test/bucket/get.js | 237 +++++++++++++----- 1 file changed, 177 insertions(+), 60 deletions(-) diff --git a/tests/functional/aws-node-sdk/test/bucket/get.js b/tests/functional/aws-node-sdk/test/bucket/get.js index 1846d54ccb..92912548d1 100644 --- a/tests/functional/aws-node-sdk/test/bucket/get.js +++ b/tests/functional/aws-node-sdk/test/bucket/get.js @@ -6,8 +6,13 @@ const withV4 = require('../support/withV4'); const BucketUtility = require('../../lib/utility/bucket-util'); const bucketSchema = require('../../schema/bucket'); const bucketSchemaV2 = require('../../schema/bucketV2'); -const { generateToken, decryptToken } = - require('../../../../../lib/api/apiUtils/object/continueToken'); +const { generateToken, decryptToken } = require('../../../../../lib/api/apiUtils/object/continueToken'); +const AWS = require('aws-sdk'); +const { IAM } = AWS; +const getConfig = require('../support/config'); +const { config } = require('../../../../../lib/Config'); + +const vaultHost = config.vaultd?.host || 'localhost'; const tests = [ { @@ -374,64 +379,6 @@ describe('GET Bucket - AWS.S3.listObjects', () => { }); }); - it('should manage the x-amz-optional-attributes header', async () => { - const s3 = bucketUtil.s3; - const Bucket = bucketName; - - await s3.putObject({ - Bucket, - Key: 'super-power-object', - Metadata: { - Department: 'sales', - HR: 'true', - }, - }).promise(); - - const result = await new Promise((resolve, reject) => { - let rawXml = ''; - const req = s3.listObjectsV2({ Bucket }); - - req.on('build', () => { - req.httpRequest.headers['x-amz-optional-object-attributes'] = 'x-amz-meta-*'; - req.httpRequest.headers['x-amz-optional-object-attributes'] += ',RestoreStatus'; - req.httpRequest.headers['x-amz-optional-object-attributes'] += ',x-amz-meta-department'; - }); - req.on('httpData', chunk => { rawXml += chunk; }); - req.on('error', err => reject(err)); - req.on('success', response => { - parseString(rawXml, (err, parsedXml) => { - if (err) { - return reject(err); - } - - const contents = response.data.Contents; - const parsedContents = parsedXml.ListBucketResult.Contents; - - if (!contents || !parsedContents) { - return resolve(response.data); - } - - if (parsedContents[0]?.['x-amz-meta-department']) { - contents[0]['x-amz-meta-department'] = parsedContents[0]['x-amz-meta-department'][0]; - } - - if (parsedContents[0]?.['x-amz-meta-hr']) { - contents[0]['x-amz-meta-hr'] = parsedContents[0]['x-amz-meta-hr'][0]; - } - - return resolve(response.data); - }); - }); - - req.send(); - }); - - assert.strictEqual(result.Contents.length, 1); - assert.strictEqual(result.Contents[0].Key, 'super-power-object'); - assert.strictEqual(result.Contents[0]['x-amz-meta-department'], 'sales'); - assert.strictEqual(result.Contents[0]['x-amz-meta-hr'], 'true'); - }); - ['&', '"quot', '\'apos', 'gt'].forEach(k => { it(`should list objects with key ${k} as Prefix`, async () => { const s3 = bucketUtil.s3; @@ -549,5 +496,175 @@ describe('GET Bucket - AWS.S3.listObjects', () => { decryptToken(data.NextContinuationToken), k); }); }); + + describe('x-amz-optional-attributes header', () => { + let policyWithoutPermission; + let userWithoutPermission; + let s3ClientWithoutPermission; + + const iamConfig = getConfig('default', { region: 'us-east-1' }); + iamConfig.endpoint = `http://${vaultHost}:8600`; + const iamClient = new IAM(iamConfig); + + before(async () => { + const policyRes = await iamClient + .createPolicy({ + PolicyName: 'bp-bypass-policy', + PolicyDocument: JSON.stringify({ + Version: '2012-10-17', + Statement: [{ + Sid: 'AllowS3ListBucket', + Effect: 'Allow', + Action: [ + 's3:ListBucket', + ], + Resource: ['*'], + }], + }), + }) + .promise(); + policyWithoutPermission = policyRes.Policy; + const userRes = await iamClient.createUser({ UserName: 'user-without-permission' }).promise(); + userWithoutPermission = userRes.User; + await iamClient + .attachUserPolicy({ + UserName: userWithoutPermission.UserName, + PolicyArn: policyWithoutPermission.Arn, + }) + .promise(); + + const accessKeyRes = await iamClient.createAccessKey({ + UserName: userWithoutPermission.UserName, + }).promise(); + const accessKey = accessKeyRes.AccessKey; + const s3Config = getConfig('default', { + credentials: new AWS.Credentials(accessKey.AccessKeyId, accessKey.SecretAccessKey), + }); + s3ClientWithoutPermission = new AWS.S3(s3Config); + }); + + after(async () => { + await iamClient + .detachUserPolicy({ + UserName: userWithoutPermission.UserName, + PolicyArn: policyWithoutPermission.Arn, + }) + .promise(); + await iamClient.deletePolicy({ PolicyArn: policyWithoutPermission.Arn }).promise(); + await iamClient.deleteUser({ UserName: userWithoutPermission.UserName }).promise(); + }); + + // eslint-disable-next-line max-len + const listObjectsV2WithOptionalAttributes = async (s3, bucket, headerValue) => await new Promise((resolve, reject) => { + let rawXml = ''; + const req = s3.listObjectsV2({ Bucket: bucket }); + + req.on('build', () => { + req.httpRequest.headers['x-amz-optional-object-attributes'] = headerValue; + }); + req.on('httpData', chunk => { rawXml += chunk; }); + req.on('error', err => reject(err)); + req.on('success', response => { + parseString(rawXml, (err, parsedXml) => { + if (err) { + return reject(err); + } + + const contents = response.data.Contents; + const parsedContents = parsedXml.ListBucketResult.Contents; + + if (!contents || !parsedContents) { + return resolve(response.data); + } + + if (parsedContents[0]?.['x-amz-meta-department']) { + contents[0]['x-amz-meta-department'] = parsedContents[0]['x-amz-meta-department'][0]; + } + + if (parsedContents[0]?.['x-amz-meta-hr']) { + contents[0]['x-amz-meta-hr'] = parsedContents[0]['x-amz-meta-hr'][0]; + } + + return resolve(response.data); + }); + }); + + req.send(); + }); + + it('should return an XML if the header is set', async () => { + const s3 = bucketUtil.s3; + const Bucket = bucketName; + + await s3.putObject({ + Bucket, + Key: 'super-power-object', + Metadata: { + Department: 'sales', + HR: 'true', + }, + }).promise(); + const result = await listObjectsV2WithOptionalAttributes( + s3, + Bucket, + 'x-amz-meta-*,RestoreStatus,x-amz-meta-department', + ); + + assert.strictEqual(result.Contents.length, 1); + assert.strictEqual(result.Contents[0].Key, 'super-power-object'); + assert.strictEqual(result.Contents[0]['x-amz-meta-department'], 'sales'); + assert.strictEqual(result.Contents[0]['x-amz-meta-hr'], 'true'); + }); + + it('should reject the request if the user does not have the permission', async () => { + const s3 = bucketUtil.s3; + const Bucket = bucketName; + + await s3.putObject({ + Bucket, + Key: 'super-power-object', + Metadata: { + Department: 'sales', + HR: 'true', + }, + }).promise(); + + try { + const result = await listObjectsV2WithOptionalAttributes( + s3ClientWithoutPermission, + Bucket, + 'x-amz-meta-*,RestoreStatus,x-amz-meta-department', + ); + throw new Error('Request should have been rejected'); + } catch (err) { + assert.strictEqual(err.statusCode, 403); + assert.strictEqual(err.code, 'AccessDenied'); + } + }); + + it('should return an XML if the header is only RestoreStatus even without permission', async () => { + const s3 = bucketUtil.s3; + const Bucket = bucketName; + + await s3.putObject({ + Bucket, + Key: 'super-power-object', + Metadata: { + Department: 'sales', + HR: 'true', + }, + }).promise(); + const result = await listObjectsV2WithOptionalAttributes( + s3ClientWithoutPermission, + Bucket, + 'RestoreStatus', + ); + + assert.strictEqual(result.Contents.length, 1); + assert.strictEqual(result.Contents[0].Key, 'super-power-object'); + assert.strictEqual(result.Contents[0]['x-amz-meta-department'], undefined); + assert.strictEqual(result.Contents[0]['x-amz-meta-hr'], undefined); + }); + }); }); }); From 66a826431f5aa8c7dcfabab3ed7b40584cbcba1c Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Wed, 14 Jan 2026 15:33:30 +0100 Subject: [PATCH 09/11] =?UTF-8?q?=E2=9C=85=20run=20test=20only=20if=20vaul?= =?UTF-8?q?t=20is=20available?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- tests/functional/aws-node-sdk/test/bucket/get.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/functional/aws-node-sdk/test/bucket/get.js b/tests/functional/aws-node-sdk/test/bucket/get.js index 92912548d1..144048750d 100644 --- a/tests/functional/aws-node-sdk/test/bucket/get.js +++ b/tests/functional/aws-node-sdk/test/bucket/get.js @@ -12,6 +12,8 @@ const { IAM } = AWS; const getConfig = require('../support/config'); const { config } = require('../../../../../lib/Config'); +const isVaultScality = config.backends.auth !== 'mem'; +const internalPortBypassBP = config.internalPort; const vaultHost = config.vaultd?.host || 'localhost'; const tests = [ @@ -497,7 +499,8 @@ describe('GET Bucket - AWS.S3.listObjects', () => { }); }); - describe('x-amz-optional-attributes header', () => { + const describeBypass = isVaultScality && internalPortBypassBP ? describe : describe.skip; + describeBypass('x-amz-optional-attributes header', () => { let policyWithoutPermission; let userWithoutPermission; let s3ClientWithoutPermission; @@ -630,7 +633,7 @@ describe('GET Bucket - AWS.S3.listObjects', () => { }).promise(); try { - const result = await listObjectsV2WithOptionalAttributes( + await listObjectsV2WithOptionalAttributes( s3ClientWithoutPermission, Bucket, 'x-amz-meta-*,RestoreStatus,x-amz-meta-department', From 870204dc7cca74076de85ed48fe6e332d9fdaa4a Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Wed, 4 Feb 2026 16:24:56 +0100 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=94=96=20release=20CloudServer=209.?= =?UTF-8?q?1.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index df84ef014b..5eeedc211d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenko/cloudserver", - "version": "9.1.10", + "version": "9.1.11", "description": "Zenko CloudServer, an open-source Node.js implementation of a server handling the Amazon S3 protocol", "main": "index.js", "engines": { From 09c36cee5b33514acb4b97a783ef113588a851fc Mon Sep 17 00:00:00 2001 From: DarkIsDude Date: Wed, 4 Feb 2026 17:42:44 +0100 Subject: [PATCH 11/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20test=20to?= =?UTF-8?q?=20be=20more=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: CLDSRV-813 --- tests/functional/aws-node-sdk/test/bucket/get.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/aws-node-sdk/test/bucket/get.js b/tests/functional/aws-node-sdk/test/bucket/get.js index 144048750d..3f17a7ede4 100644 --- a/tests/functional/aws-node-sdk/test/bucket/get.js +++ b/tests/functional/aws-node-sdk/test/bucket/get.js @@ -645,7 +645,7 @@ describe('GET Bucket - AWS.S3.listObjects', () => { } }); - it('should return an XML if the header is only RestoreStatus even without permission', async () => { + it('should always (ignore permission) return an XML when the header is RestoreStatus', async () => { const s3 = bucketUtil.s3; const Bucket = bucketName;