Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions lib/api/apiUtils/authorization/prepareRequestContexts.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,23 @@ function prepareRequestContexts(apiMethod, request, sourceBucket,
if (requestedAttributes.filter(attr => attr != 'RestoreStatus').length > 0) {
requestContexts.push(generateRequestContext('listObjectsV2OptionalAttributes'));
}
} else if (apiMethodAfterVersionCheck === 'objectGetAttributes') {
if (request.headers['x-amz-version-id']) {
requestContexts.push(
generateRequestContext('objectGetVersion'),
generateRequestContext('objectGetVersionAttributes'),
);
} else {
requestContexts.push(
generateRequestContext('objectGet'),
generateRequestContext('objectGetAttributes'),
);
}

const attributes = request.headers['x-amz-object-attributes']?.split(',') ?? [];
if (attributes.some(attr => attr.startsWith('x-amz-meta-'))) {
requestContexts.push(generateRequestContext('objectGetAttributesWithUserMetadata'));
}
} else {
const requestContext =
generateRequestContext(apiMethodAfterVersionCheck);
Expand Down
28 changes: 18 additions & 10 deletions lib/api/apiUtils/object/parseAttributesHeader.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
const { errorInstances } = require('arsenal');
const { allowedObjectAttributes } = require('../../../../constants');

/**
* parseAttributesHeaders - Parse and validate the x-amz-object-attributes header
* @param {object} headers - request headers
* @returns {string[]} - array of valid attribute names
* @throws {Error} - InvalidRequest if header is missing/empty, InvalidArgument if attribute is invalid
* Parse and validate attribute headers from a request.
* @param {object} headers - Request headers object
* @param {string} headerName - Name of the header to parse (e.g., 'x-amz-object-attributes')
* @param {Set<string>} allowedAttributes - Set of valid attribute names
* @param {boolean} [isRequired=false] - If true, throws when header is missing/empty
* @returns {string[]} Array of validated attribute names
* @throws {arsenal.errors.InvalidRequest} When header is required but missing/empty
* @throws {arsenal.errors.InvalidArgument} When an invalid attribute name is specified
*/
function parseAttributesHeaders(headers) {
const attributes = headers['x-amz-object-attributes']?.split(',').map(attr => attr.trim()) ?? [];
if (attributes.length === 0) {
function parseAttributesHeaders(headers, headerName, allowedAttributes, isRequired = false) {
const attributes =
headers[headerName]
?.split(',')
.map(attr => attr.trim())
.map(attr => (allowedAttributes.has(attr) ? attr : attr.toLowerCase())) ?? [];

if (isRequired && attributes.length === 0) {
throw errorInstances.InvalidRequest.customizeDescription(
'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty',
`The ${headerName} header specifying the attributes to be retrieved is either missing or empty`,
);
}

if (attributes.some(attr => !allowedObjectAttributes.has(attr))) {
if (attributes.some(attr => !attr.startsWith('x-amz-meta-') && !allowedAttributes.has(attr))) {
throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.');
}

Expand Down
17 changes: 7 additions & 10 deletions lib/api/bucketGet.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const versionIdUtils = versioning.VersionID;
const monitoring = require('../utilities/monitoringHandler');
const { generateToken, decryptToken }
= require('../api/apiUtils/object/continueToken');
const parseAttributesHeaders = require('./apiUtils/object/parseAttributesHeader');

// do not url encode the continuation tokens
const skipUrlEncoding = new Set([
Expand Down Expand Up @@ -332,16 +333,12 @@ function bucketGet(authInfo, request, log, callback) {
const bucketName = request.bucketName;
const v2 = params['list-type'];

const optionalAttributes =
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')
);
let optionalAttributes;
try {
const headerName = 'x-amz-optional-object-attributes';
optionalAttributes = parseAttributesHeaders(request.headers, headerName, new Set(['RestoreStatus']));
} catch (err) {
return callback(err);
}

if (v2 !== undefined && Number.parseInt(v2, 10) !== 2) {
Expand Down
26 changes: 25 additions & 1 deletion lib/api/objectGetAttributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { decodeVersionId, getVersionIdResHeader } = require('./apiUtils/object/ve
const { checkExpectedBucketOwner } = require('./apiUtils/authorization/bucketOwner');
const { pushMetric } = require('../utapi/utilities');
const { getPartCountFromMd5 } = require('./apiUtils/object/partInfo');
const { allowedObjectAttributes } = require('../../constants');

const OBJECT_GET_ATTRIBUTES = 'objectGetAttributes';

Expand Down Expand Up @@ -47,10 +48,33 @@ function buildXmlResponse(objMD, attributes) {
attrResp.ObjectSize = objMD['content-length'];
}

if (attributes.some(attr => attr.startsWith('x-amz-meta-'))) {
const userMetadata = parseUserMetadata(objMD, attributes);
Object.assign(attrResp, userMetadata);
}

const builder = new xml2js.Builder();
return builder.buildObject({ GetObjectAttributesResponse: attrResp });
}

/**
* parseUserMetadata - Extract user metadata from object metadata
* @param {object} objMD - object metadata
* @param {string[]} attributes - requested attributes
* @returns {object} - object containing requested user metadata key-value pairs
*/
function parseUserMetadata(objMD, attributes) {
const sourceKeys = attributes.includes('x-amz-meta-*') ? Object.keys(objMD) : attributes;

return sourceKeys
.filter(key => key.startsWith('x-amz-meta-') && key !== 'x-amz-meta-*' && objMD[key])
.reduce((acc, key) => {
// eslint-disable-next-line no-param-reassign
acc[key] = objMD[key];
return acc;
}, {});
}

/**
* objectGetAttributes - Retrieves all metadata from an object without returning the object itself
* @param {AuthInfo} authInfo - Instance of AuthInfo class with requester's info
Expand Down Expand Up @@ -137,7 +161,7 @@ async function objectGetAttributes(authInfo, request, log, callback) {
throw err;
}

const attributes = parseAttributesHeaders(headers);
const attributes = parseAttributesHeaders(headers, 'x-amz-object-attributes', allowedObjectAttributes, true);

pushMetric(OBJECT_GET_ATTRIBUTES, log, {
authInfo,
Expand Down
194 changes: 194 additions & 0 deletions tests/functional/aws-node-sdk/test/object/objectGetAttributes.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const assert = require('assert');
const { parseStringPromise } = require('xml2js');
const { S3 } = require('aws-sdk');
const getConfig = require('../support/config');

Expand Down Expand Up @@ -262,3 +263,196 @@ describe('Test get object attributes with multipart upload', () => {
assert.strictEqual(data.StorageClass, 'STANDARD');
});
});

describe('objectGetAttributes with user metadata', () => {
let s3;

const getObjectAttributesWithUserMetadata = (s3, params, attributes) => new Promise((resolve, reject) => {
const request = s3.makeRequest('getObjectAttributes', {
Bucket: params.Bucket,
Key: params.Key,
ObjectAttributes: ['ETag'],
});

request.on('build', () => {
request.httpRequest.headers['x-amz-object-attributes'] = attributes;
});

request.on('error', err => reject(err));

request.on('success', async response => {
try {
const body = response.httpResponse.body.toString();
const parsedXml = await parseStringPromise(body);

const data = response.data;
const parsedData = parsedXml.GetObjectAttributesResponse;

if (!data || !parsedData) {
return resolve(response.data);
}

Object.keys(parsedData).forEach(key => {
if (key.startsWith('x-amz-meta-')) {
data[key] = parsedData[key][0];
}
});

return resolve(response.data);
} catch (err) {
return reject(err);
}
});

request.send();
});

before(() => {
const config = getConfig('default', { signatureVersion: 'v4' });
s3 = new S3(config);
});

beforeEach(async () => {
await s3.createBucket({ Bucket: bucket }).promise();
});

afterEach(async () => {
await s3.deleteObject({ Bucket: bucket, Key: key }).promise();
await s3.deleteBucket({ Bucket: bucket }).promise();
});

it('should return specific user metadata when requested', async () => {
await s3.putObject({
Bucket: bucket,
Key: key,
Body: body,
Metadata: {
'custom-key': 'custom-value',
'another-key': 'another-value',
},
}).promise();

const response = await getObjectAttributesWithUserMetadata(s3, {
Bucket: bucket,
Key: key,
}, 'x-amz-meta-custom-key');

assert.strictEqual(response['x-amz-meta-custom-key'], 'custom-value');
});

it('should return multiple user metadata when requested', async () => {
await s3.putObject({
Bucket: bucket,
Key: key,
Body: body,
Metadata: {
foo: 'foo-value',
bar: 'bar-value',
baz: 'baz-value',
},
}).promise();

const response = await getObjectAttributesWithUserMetadata(s3, {
Bucket: bucket,
Key: key,
}, 'x-amz-meta-foo,x-amz-meta-bar');

assert.strictEqual(response['x-amz-meta-foo'], 'foo-value');
assert.strictEqual(response['x-amz-meta-bar'], 'bar-value');
});

it('should return all user metadata when x-amz-meta-* is requested', async () => {
await s3.putObject({
Bucket: bucket,
Key: key,
Body: body,
Metadata: {
key1: 'value1',
key2: 'value2',
key3: 'value3',
},
}).promise();

const response = await getObjectAttributesWithUserMetadata(s3, {
Bucket: bucket,
Key: key,
}, 'x-amz-meta-*');

assert.strictEqual(response['x-amz-meta-key1'], 'value1');
assert.strictEqual(response['x-amz-meta-key2'], 'value2');
assert.strictEqual(response['x-amz-meta-key3'], 'value3');
});

it('should return empty response when object has no user metadata and x-amz-meta-* is requested', async () => {
await s3.putObject({
Bucket: bucket,
Key: key,
Body: body,
}).promise();

const response = await getObjectAttributesWithUserMetadata(s3, {
Bucket: bucket,
Key: key,
}, 'x-amz-meta-*');

const metadataKeys = Object.keys(response).filter(k => k.startsWith('x-amz-meta-'));
assert.strictEqual(metadataKeys.length, 0);
});

it('should return empty response when requested metadata key does not exist', async () => {
await s3.putObject({
Bucket: bucket,
Key: key,
Body: body,
Metadata: {
existing: 'value',
},
}).promise();

const response = await getObjectAttributesWithUserMetadata(s3, {
Bucket: bucket,
Key: key,
}, 'x-amz-meta-nonexistent');

assert.strictEqual(response['x-amz-meta-nonexistent'], undefined);
});

it('should return user metadata along with standard attributes', async () => {
await s3.putObject({
Bucket: bucket,
Key: key,
Body: body,
Metadata: {
custom: 'custom-value',
},
}).promise();

const response = await getObjectAttributesWithUserMetadata(s3, {
Bucket: bucket,
Key: key,
}, 'ETag,x-amz-meta-custom,ObjectSize');

assert.strictEqual(response.ETag, expectedMD5);
assert.strictEqual(response.ObjectSize, body.length);
assert.strictEqual(response['x-amz-meta-custom'], 'custom-value');
});

it('should not include x-amz-meta-* marker in response when wildcard is used', async () => {
await s3.putObject({
Bucket: bucket,
Key: key,
Body: body,
Metadata: {
test: 'test-value',
},
}).promise();

const response = await getObjectAttributesWithUserMetadata(s3, {
Bucket: bucket,
Key: key,
}, 'x-amz-meta-*');

assert.strictEqual(response['x-amz-meta-*'], undefined);
assert.strictEqual(response['x-amz-meta-test'], 'test-value');
});
});
Loading
Loading