Skip to content
158 changes: 139 additions & 19 deletions lib/api/apiUtils/integrity/validateChecksums.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ const errMPUTypeWithoutAlgo = errorInstances.InvalidRequest.customizeDescription
'The x-amz-checksum-type header can only be used ' + 'with the x-amz-checksum-algorithm header.',
);

// Methods that validate BOTH Content-MD5 and x-amz-checksum-* against the
// buffered request body. For these, x-amz-checksum-* is the body digest.
const checksumedMethods = Object.freeze({
completeMultipartUpload: true,
multiObjectDelete: true,
bucketPutACL: true,
bucketPutCors: true,
Expand All @@ -61,6 +62,15 @@ const checksumedMethods = Object.freeze({
objectRestore: true,
});

// Methods that validate Content-MD5 against the buffered request body but
// do NOT treat x-amz-checksum-* as a body digest. CompleteMPU's
// x-amz-checksum-<algo> is the asserted final-object checksum, not a
// body digest, and is validated separately by
// validateCompleteMultipartUploadChecksum.
const md5OnlyMethods = Object.freeze({
completeMultipartUpload: true,
});

const ChecksumError = Object.freeze({
MD5Mismatch: 'MD5Mismatch',
MD5Invalid: 'MD5Invalid',
Expand Down Expand Up @@ -336,30 +346,39 @@ function getChecksumDataFromHeaders(headers) {
* @param {Buffer} body - http request body
* @return {object} - error
*/
// Validate a Content-MD5 header against the buffered body. Returns null on
// success, an error object otherwise.
function validateContentMd5(headers, body) {
if (!('content-md5' in headers)) {
return { error: ChecksumError.MissingChecksum, details: null };
}
if (typeof headers['content-md5'] !== 'string') {
return { error: ChecksumError.MD5Invalid, details: { expected: headers['content-md5'] } };
}
if (headers['content-md5'].length !== 24) {
return { error: ChecksumError.MD5Invalid, details: { expected: headers['content-md5'] } };
}
if (!base64Regex.test(headers['content-md5'])) {
return { error: ChecksumError.MD5Invalid, details: { expected: headers['content-md5'] } };
}
const md5 = crypto.createHash('md5').update(body).digest('base64');
if (md5 !== headers['content-md5']) {
return { error: ChecksumError.MD5Mismatch, details: { calculated: md5, expected: headers['content-md5'] } };
}
return null;
}

async function validateChecksumsNoChunking(headers, body) {
if (!headers) {
return { error: ChecksumError.MissingChecksum, details: null };
}

let md5Present = false;
if ('content-md5' in headers) {
if (typeof headers['content-md5'] !== 'string') {
return { error: ChecksumError.MD5Invalid, details: { expected: headers['content-md5'] } };
}

if (headers['content-md5'].length !== 24) {
return { error: ChecksumError.MD5Invalid, details: { expected: headers['content-md5'] } };
}

if (!base64Regex.test(headers['content-md5'])) {
return { error: ChecksumError.MD5Invalid, details: { expected: headers['content-md5'] } };
const md5Err = validateContentMd5(headers, body);
if (md5Err) {
return md5Err;
}

const md5 = crypto.createHash('md5').update(body).digest('base64');
if (md5 !== headers['content-md5']) {
return { error: ChecksumError.MD5Mismatch, details: { calculated: md5, expected: headers['content-md5'] } };
}

md5Present = true;
}

Expand Down Expand Up @@ -426,6 +445,86 @@ function arsenalErrorFromChecksumError(err) {
}
}

/**
* Validate the optional x-amz-checksum-<algo> header on a CompleteMPU
* request against the computed final-object checksum.
*
* AWS contract: when present, x-amz-checksum-<algo> on CompleteMPU is the
* client's assertion of what the final-object checksum should be (not a
* body digest). Rules:
* - Multiple value headers → MultipleChecksumTypes.
* - Unknown algorithm (e.g. x-amz-checksum-bad) → AlgoNotSupported.
* - Value not a valid digest for the named algorithm → MalformedChecksum
* (`-N` suffix permitted for COMPOSITE final-object values).
* - Single known, well-formed header that doesn't match the computed value
* (including when the named algorithm differs from the MPU's, or when we
* couldn't compute one at all) → XAmzMismatch.
* - No header → null (no error).
*
* Scans ALL `x-amz-checksum-*` headers on the request (excluding the
* configuration headers `x-amz-checksum-type` / `x-amz-checksum-algorithm`)
* so an unsupported algorithm is rejected rather than silently ignored.
*
* Returns the same `{ error, details }` discriminated shape as
* validateXAmzChecksums; the caller must run it through
* arsenalErrorFromChecksumError to get an ArsenalError.
*
* @param {object} headers - request.headers (lowercased keys)
* @param {object|null} finalChecksum - { algorithm, type, value } or null
* @returns {{error: string, details: object}|null}
*/
function validateCompleteMultipartUploadChecksum(headers, finalChecksum) {
// `x-amz-checksum-type` and `x-amz-checksum-algorithm` are configuration
// headers (MPU completeness mode / SDK algorithm hint), not value
// headers. They must not count toward the "value header" tally.
const valueHeaders = Object.keys(headers).filter(
h => h.startsWith('x-amz-checksum-') && h !== 'x-amz-checksum-type' && h !== 'x-amz-checksum-algorithm',
);

if (valueHeaders.length === 0) {
return null;
}

if (valueHeaders.length > 1) {
return { error: ChecksumError.MultipleChecksumTypes, details: { algorithms: valueHeaders } };
}

const headerName = valueHeaders[0];
const foundAlgo = headerName.slice('x-amz-checksum-'.length);
const foundValue = headers[headerName];

if (!(foundAlgo in algorithms)) {
return { error: ChecksumError.AlgoNotSupported, details: { algorithm: foundAlgo } };
}

const dashIdx = foundValue.lastIndexOf('-');
const hasCompositeSuffix = dashIdx > 0 && /^\d+$/.test(foundValue.slice(dashIdx + 1));
if (hasCompositeSuffix && !compositeAlgorithms.has(foundAlgo)) {
return { error: ChecksumError.MalformedChecksum, details: { algorithm: foundAlgo, expected: foundValue } };
}
if (!hasCompositeSuffix && !fullObjectAlgorithms.has(foundAlgo)) {
return { error: ChecksumError.MalformedChecksum, details: { algorithm: foundAlgo, expected: foundValue } };
}
const digestPart = hasCompositeSuffix ? foundValue.slice(0, dashIdx) : foundValue;
if (!algorithms[foundAlgo].isValidDigest(digestPart)) {
return { error: ChecksumError.MalformedChecksum, details: { algorithm: foundAlgo, expected: foundValue } };
}

if (!finalChecksum || finalChecksum.algorithm !== foundAlgo || finalChecksum.value !== foundValue) {
return {
error: ChecksumError.XAmzMismatch,
details: {
algorithm: foundAlgo,
expected: foundValue,
computedAlgorithm: finalChecksum && finalChecksum.algorithm,
computed: finalChecksum && finalChecksum.value,
},
};
}

return null;
}

async function defaultValidationFunc(request, body, log) {
const err = await validateChecksumsNoChunking(request.headers, body);
if (!err) {
Expand All @@ -439,6 +538,22 @@ async function defaultValidationFunc(request, body, log) {
return arsenalErrorFromChecksumError(err);
}

// Validate ONLY Content-MD5 against the buffered body. Used for methods
// where x-amz-checksum-* has a non-body meaning (e.g. CompleteMPU's
// final-object checksum assertion) and must not be re-validated here.
function md5OnlyValidationFunc(request, body, log) {
// Content-MD5 is optional. If absent, nothing to validate.
if (!('content-md5' in request.headers)) {
return null;
}
const err = validateContentMd5(request.headers, body);
if (!err) {
return null;
}
log.debug('failed Content-MD5 validation', { method: request.apiMethod }, err);
return arsenalErrorFromChecksumError(err);
}

/**
* validateMethodChecksumsNoChunking - Validate the checksums of a request.
* @param {object} request - http request
Expand All @@ -455,6 +570,10 @@ async function validateMethodChecksumNoChunking(request, body, log) {
return await defaultValidationFunc(request, body, log);
}

if (request.apiMethod in md5OnlyMethods) {
return md5OnlyValidationFunc(request, body, log);
}

return null;
}

Expand Down Expand Up @@ -569,13 +688,13 @@ const COMPOSITE_ALGOS = new Set(['crc32', 'crc32c', 'sha1', 'sha256']);
* @returns {{ checksum: string, error: null }
* | { checksum: null, error: { code: string, details: object } }}
*/
function computeCompositeMPUChecksum(algorithm, partChecksumsBase64) {
async function computeCompositeMPUChecksum(algorithm, partChecksumsBase64) {
if (!COMPOSITE_ALGOS.has(algorithm)) {
return { checksum: null, error: { code: ChecksumError.MPUAlgoNotSupported, details: { algorithm } } };
}

const concat = Buffer.concat(partChecksumsBase64.map(c => Buffer.from(c, 'base64')));
const digest = algorithms[algorithm].digest(concat);
const digest = await algorithms[algorithm].digest(concat);
return {
checksum: `${digest}-${partChecksumsBase64.length}`,
error: null,
Expand Down Expand Up @@ -617,4 +736,5 @@ module.exports = {
getChecksumDataFromMPUHeaders,
computeCompositeMPUChecksum,
computeFullObjectMPUChecksum,
validateCompleteMultipartUploadChecksum,
};
Loading
Loading