Skip to content
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.trim().toLowerCase().startsWith('x-amz-meta-'))) {
requestContexts.push(generateRequestContext('objectGetAttributesCustom'));
}
} else {
const requestContext =
generateRequestContext(apiMethodAfterVersionCheck);
Expand Down
23 changes: 23 additions & 0 deletions lib/api/apiUtils/object/extractUserMetadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* extractUserMetadata - Extract requested user metadata from object metadata
* @param {object} metadata - source metadata object with x-amz-meta-* keys
* @param {Set<string>} attributes - requested attributes (with x-amz-meta- prefix)
* @returns {object} - object containing requested user metadata key-value pairs
*/
function extractUserMetadata(metadata, attributes) {
const result = {};

const isWildcard = attributes.has('x-amz-meta-*');
const sourceKeys = isWildcard ? Object.keys(metadata) : attributes;

for (const key of sourceKeys) {
const isValidKey = isWildcard ? key.startsWith('x-amz-meta-') : true;
if (isValidKey && metadata[key] != null) {
result[key] = metadata[key];
}
}

return result;
}

module.exports = extractUserMetadata;
46 changes: 32 additions & 14 deletions lib/api/apiUtils/object/parseAttributesHeader.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
const { errorInstances } = require('arsenal');
const { supportedGetObjectAttributes } = require('../../../../constants');

/**
* parseAttributesHeaders - Parse and validate the x-amz-object-attributes header
* @param {object} headers - request headers
* @returns {Set<string>} - set of requested 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>} supportedAttributes - Set of valid attribute names
* @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
* @example
* // Input headers:
* { 'headerName': 'ETag, ObjectSize, x-amz-meta-custom' }
*
* // Parsed result:
* ['ETag', 'ObjectSize', 'x-amz-meta-custom']
*/
function parseAttributesHeaders(headers) {
const attributes = headers['x-amz-object-attributes']?.split(',').map(attr => attr.trim()) ?? [];
if (attributes.length === 0) {
throw errorInstances.InvalidRequest.customizeDescription(
'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty',
);
function parseAttributesHeaders(headers, headerName, supportedAttributes) {
const rawValue = headers[headerName];
if (rawValue === null || rawValue === undefined) {
return new Set();
}

if (attributes.some(attr => !supportedGetObjectAttributes.has(attr))) {
throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.');
const result = new Set();

for (const rawAttr of rawValue.split(',')) {
let attr = rawAttr.trim();

if (!supportedAttributes.has(attr)) {
attr = attr.toLowerCase();
}

if (!attr.startsWith('x-amz-meta-') && !supportedAttributes.has(attr)) {
throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified.');
}

result.add(attr);
}

return new Set(attributes);
return result;
}

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

const OPTIONAL_ATTRIBUTES = new Set(['RestoreStatus']);

const xmlParamsToSkipUrlEncoding = new Set(['ContinuationToken', 'NextContinuationToken']);

Expand Down Expand Up @@ -248,34 +252,21 @@ function processMasterVersions(bucketName, listParams, list) {

function processOptionalAttributes(item, optionalAttributes) {
const xml = [];
const userMetadata = new Set();

for (const attribute of optionalAttributes) {
switch (attribute) {
case 'RestoreStatus':
xml.push('<RestoreStatus>');
xml.push(`<IsRestoreInProgress>${!!item.restoreStatus?.inProgress}</IsRestoreInProgress>`);

if (item.restoreStatus?.expiryDate) {
xml.push(`<RestoreExpiryDate>${item.restoreStatus?.expiryDate}</RestoreExpiryDate>`);
}

xml.push('</RestoreStatus>');
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.has('RestoreStatus')) {
xml.push('<RestoreStatus>');
xml.push(`<IsRestoreInProgress>${!!item.restoreStatus?.inProgress}</IsRestoreInProgress>`);

if (item.restoreStatus?.expiryDate) {
xml.push(`<RestoreExpiryDate>${item.restoreStatus?.expiryDate}</RestoreExpiryDate>`);
}

xml.push('</RestoreStatus>');
}

for (const key of userMetadata) {
xml.push(`<${key}>${item.userMetadata[key]}</${key}>`);
const userMetadata = extractUserMetadata(item.userMetadata || {}, optionalAttributes);
for (const [key, value] of Object.entries(userMetadata)) {
xml.push(`<${key}>${value}</${key}>`);
}

return xml;
Expand Down Expand Up @@ -321,15 +312,11 @@ async 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')) {
throw errorInstances.InvalidArgument.customizeDescription('Invalid attribute name specified');
}
const optionalAttributes = parseAttributesHeaders(
request.headers,
'x-amz-optional-object-attributes',
OPTIONAL_ATTRIBUTES,
);

if (v2 !== undefined && Number.parseInt(v2, 10) !== 2) {
throw errorInstances.InvalidArgument.customizeDescription('Invalid List Type specified in Request');
Expand Down
2 changes: 1 addition & 1 deletion lib/api/metadataSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function handleResult(listParams, requestMaxKeys, encoding, authInfo,
// eslint-disable-next-line no-param-reassign
listParams.encoding = encoding;
// eslint-disable-next-line no-param-reassign
listParams.optionalAttributes = [];
listParams.optionalAttributes = new Set();
let res;
if (listParams.listingType === 'DelimiterVersions') {
res = processVersions(bucketName, listParams, list);
Expand Down
16 changes: 14 additions & 2 deletions lib/api/objectGetAttributes.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
const { promisify } = require('util');
const xml2js = require('xml2js');
const { errors } = require('arsenal');
const { errors, errorInstances } = require('arsenal');
const { standardMetadataValidateBucketAndObj } = require('../metadata/metadataUtils');
const collectCorsHeaders = require('../utilities/collectCorsHeaders');
const parseAttributesHeaders = require('./apiUtils/object/parseAttributesHeader');
const { decodeVersionId, getVersionIdResHeader } = require('./apiUtils/object/versioning');
const { checkExpectedBucketOwner } = require('./apiUtils/authorization/bucketOwner');
const { pushMetric } = require('../utapi/utilities');
const { getPartCountFromMd5 } = require('./apiUtils/object/partInfo');
const { supportedGetObjectAttributes } = require('../../constants');
const extractUserMetadata = require('./apiUtils/object/extractUserMetadata');

const checkExpectedBucketOwnerPromise = promisify(checkExpectedBucketOwner);
const validateBucketAndObj = promisify(standardMetadataValidateBucketAndObj);
Expand Down Expand Up @@ -41,6 +43,9 @@ function buildXmlResponse(objMD, requestedAttrs) {
}
}

const userMetadata = extractUserMetadata(objMD, requestedAttrs);
Object.assign(attrResp, userMetadata);

const builder = new xml2js.Builder();
return builder.buildObject({ GetObjectAttributesResponse: attrResp });
}
Expand Down Expand Up @@ -131,7 +136,14 @@ async function objectGetAttributes(authInfo, request, log, callback) {
throw err;
}

const requestedAttrs = parseAttributesHeaders(headers);
const attrHeader = headers['x-amz-object-attributes'];
if (attrHeader === undefined) {
throw errorInstances.InvalidRequest.customizeDescription(
'The x-amz-object-attributes header specifying the attributes to be retrieved is either missing or empty',
);
}

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

if (requestedAttrs.has('Checksum')) {
log.debug('Checksum attribute requested but not implemented', {
Expand Down
2 changes: 1 addition & 1 deletion lib/routes/veeam/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function buildXMLResponse(request, arrayOfFiles, versioned = false) {
prefix: validPath,
maxKeys: parsedQs['max-keys'] || 1000,
delimiter: '/',
optionalAttributes: [],
optionalAttributes: new Set(),
};
const list = {
IsTruncated: false,
Expand Down
1 change: 1 addition & 0 deletions lib/utilities/serverAccessLogger.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ const methodToResType = Object.freeze({
'objectDelete': 'OBJECT',
'objectDeleteTagging': 'TAGGING',
'objectGet': 'OBJECT',
'objectGetAttributes': 'OBJECT',
'objectGetACL': 'ACL',
'objectGetLegalHold': 'LEGALHOLD',
'objectGetRetention': 'OBJECT_LOCK_RETENTION',
Expand Down
Loading
Loading