Skip to content
Closed
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
153 changes: 143 additions & 10 deletions lib/api/apiUtils/integrity/validateChecksums.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ const ChecksumError = Object.freeze({
MultipleChecksumTypes: 'MultipleChecksumTypes',
MissingCorresponding: 'MissingCorresponding',
MalformedChecksum: 'MalformedChecksum',
TrailerAlgoMismatch: 'TrailerAlgoMismatch',
TrailerChecksumMalformed: 'TrailerChecksumMalformed',
TrailerMissing: 'TrailerMissing',
TrailerUnexpected: 'TrailerUnexpected',
TrailerAndChecksum: 'TrailerAndChecksum',
TrailerNotSupported: 'TrailerNotSupported',
});

const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
Expand All @@ -56,35 +62,51 @@ const algorithms = Object.freeze({
const result = await crc.digest();
return Buffer.from(result).toString('base64');
},
digestFromHash: async hash => {
const result = await hash.digest();
return Buffer.from(result).toString('base64');
},
isValidDigest: expected => typeof expected === 'string' && expected.length === 12 && base64Regex.test(expected),
createHash: () => new CrtCrc64Nvme()
},
crc32: {
digest: data => {
const input = Buffer.isBuffer(data) ? data : Buffer.from(data);
return uint32ToBase64(new Crc32().update(input).digest() >>> 0); // >>> 0 coerce number to uint32
},
digestFromHash: hash => {
const result = hash.digest();
return uint32ToBase64(result >>> 0);
},
isValidDigest: expected => typeof expected === 'string' && expected.length === 8 && base64Regex.test(expected),
createHash: () => new Crc32()
},
crc32c: {
digest: data => {
const input = Buffer.isBuffer(data) ? data : Buffer.from(data);
return uint32ToBase64(new Crc32c().update(input).digest() >>> 0); // >>> 0 coerce number to uint32
},
digestFromHash: hash => uint32ToBase64(hash.digest() >>> 0),
isValidDigest: expected => typeof expected === 'string' && expected.length === 8 && base64Regex.test(expected),
createHash: () => new Crc32c()
},
sha1: {
digest: data => {
const input = Buffer.isBuffer(data) ? data : Buffer.from(data);
return crypto.createHash('sha1').update(input).digest('base64');
},
digestFromHash: hash => hash.digest('base64'),
isValidDigest: expected => typeof expected === 'string' && expected.length === 28 && base64Regex.test(expected),
createHash: () => crypto.createHash('sha1')
},
sha256: {
digest: data => {
const input = Buffer.isBuffer(data) ? data : Buffer.from(data);
return crypto.createHash('sha256').update(input).digest('base64');
},
digestFromHash: hash => hash.digest('base64'),
isValidDigest: expected => typeof expected === 'string' && expected.length === 44 && base64Regex.test(expected),
createHash: () => crypto.createHash('sha256')
}
});

Expand Down Expand Up @@ -141,6 +163,94 @@ async function validateXAmzChecksums(headers, body) {
return null;
}

function getChecksumDataFromHeaders(headers) {
const checkSdk = algo => {
if (!('x-amz-sdk-checksum-algorithm' in headers)) {
return null;
}

const sdkAlgo = headers['x-amz-sdk-checksum-algorithm'];
if (typeof sdkAlgo !== 'string') {
return { error: ChecksumError.AlgoNotSupportedSDK, details: { algorithm: sdkAlgo } };
}

const sdkLowerAlgo = sdkAlgo.toLowerCase();
if (!(sdkLowerAlgo in algorithms)) {
return { error: ChecksumError.AlgoNotSupportedSDK, details: { algorithm: sdkAlgo } };
}

// If AWS there is a mismatch, AWS returns the same error as if the algo was invalid.
if (sdkLowerAlgo !== algo) {
return { error: ChecksumError.AlgoNotSupportedSDK, details: { algorithm: sdkAlgo } };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: "If AWS there is a mismatch" should be "If there is a mismatch".

— Claude Code


return null;
};

const checksumHeaders = Object.keys(headers).filter(header => header.startsWith('x-amz-checksum-'));
const xAmzChecksumCnt = checksumHeaders.length;
if (xAmzChecksumCnt > 1) {
return { error: ChecksumError.MultipleChecksumTypes, details: { algorithms: checksumHeaders } };
}

if (xAmzChecksumCnt === 0 && !('x-amz-trailer' in headers) && 'x-amz-sdk-checksum-algorithm' in headers) {
return {
error: ChecksumError.MissingCorresponding,
details: { expected: headers['x-amz-sdk-checksum-algorithm'] }
};
}

if ('x-amz-trailer' in headers) {
if (xAmzChecksumCnt !== 0) {
return {
error: ChecksumError.TrailerAndChecksum,
details: { trailer: headers['x-amz-trailer'], checksum: checksumHeaders },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in comment: "If AWS there is a mismatch" should be "If there is a mismatch".

— Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no checksum headers are present, this returns crc64nvme as default. This causes ChecksumTransform to compute a crc64nvme hash over the entire request body for every PutObject/UploadPart, even when no checksum was requested. The digest is computed but never validated (since expected is undefined). If this is intentional (e.g. to store the computed checksum in metadata later), a comment would help clarify the intent.

— Claude Code

};
}

const trailer = headers['x-amz-trailer'];
if (!trailer.startsWith('x-amz-checksum-')) {
return { error: ChecksumError.TrailerNotSupported, details: { value: trailer } };
}

const trailerAlgo = trailer.slice('x-amz-checksum-'.length);
if (!(trailerAlgo in algorithms)) {
return { error: ChecksumError.TrailerNotSupported, details: { value: trailer } };
}

const err = checkSdk(trailerAlgo);
if (err) {
return err;
}

return { algorithm: trailerAlgo, isTrailer: true, expected: undefined };
}

if (xAmzChecksumCnt === 0) {
// There was no x-amz-checksum- or x-amz-trailer return crc64nvme.
// The calculated crc64nvme will be stored in the object metadata.
return { algorithm: 'crc64nvme', isTrailer: false, expected: undefined };
}

// No x-amz-sdk-checksum-algorithm we expect one x-amz-checksum-[crc64nvme, crc32, crc32C, sha1, sha256].
const algo = checksumHeaders[0].slice('x-amz-checksum-'.length);
if (!(algo in algorithms)) {
return { error: ChecksumError.AlgoNotSupported, details: { algorithm: algo } };
}

const expected = headers[`x-amz-checksum-${algo}`];
if (!algorithms[algo].isValidDigest(expected)) {
return { error: ChecksumError.MalformedChecksum, details: { algorithm: algo, expected } };
}

const err = checkSdk(algo);
if (err) {
return err;
}

return { algorithm: algo, isTrailer: false, expected };
}

/**
* validateChecksumsNoChunking - Validate the checksums of a request.
* @param {object} headers - http headers
Expand Down Expand Up @@ -183,16 +293,7 @@ async function validateChecksumsNoChunking(headers, body) {
return err;
}

async function defaultValidationFunc(request, body, log) {
const err = await validateChecksumsNoChunking(request.headers, body);
if (!err) {
return null;
}

if (err.error !== ChecksumError.MissingChecksum) {
log.debug('failed checksum validation', { method: request.apiMethod }, err);
}

function arsenalErrorFromChecksumError(err) {
switch (err.error) {
case ChecksumError.MissingChecksum:
return null;
Expand Down Expand Up @@ -225,11 +326,40 @@ async function defaultValidationFunc(request, body, log) {
);
case ChecksumError.MD5Invalid:
return ArsenalErrors.InvalidDigest;
case ChecksumError.TrailerAlgoMismatch:
return ArsenalErrors.MalformedTrailerError;
case ChecksumError.TrailerMissing:
return ArsenalErrors.MalformedTrailerError;
case ChecksumError.TrailerUnexpected:
return ArsenalErrors.MalformedTrailerError;
case ChecksumError.TrailerChecksumMalformed:
return ArsenalErrors.InvalidRequest.customizeDescription(
`Value for x-amz-checksum-${err.details.algorithm} trailing header is invalid.`
);
case ChecksumError.TrailerAndChecksum:
return ArsenalErrors.InvalidRequest.customizeDescription('Expecting a single x-amz-checksum- header');
case ChecksumError.TrailerNotSupported:
return ArsenalErrors.InvalidRequest.customizeDescription(
'The value specified in the x-amz-trailer header is not supported'
);
default:
return ArsenalErrors.BadDigest;
}
}

async function defaultValidationFunc(request, body, log) {
const err = await validateChecksumsNoChunking(request.headers, body);
if (!err) {
return null;
}

if (err.error !== ChecksumError.MissingChecksum) {
log.debug('failed checksum validation', { method: request.apiMethod }, err);
}

return arsenalErrorFromChecksumError(err);
}

/**
* validateMethodChecksumsNoChunking - Validate the checksums of a request.
* @param {object} request - http request
Expand All @@ -253,5 +383,8 @@ module.exports = {
ChecksumError,
validateChecksumsNoChunking,
validateMethodChecksumNoChunking,
getChecksumDataFromHeaders,
arsenalErrorFromChecksumError,
algorithms,
checksumedMethods,
};
144 changes: 104 additions & 40 deletions lib/api/apiUtils/object/prepareStream.js
Original file line number Diff line number Diff line change
@@ -1,50 +1,114 @@
const V4Transform = require('../../../auth/streamingV4/V4Transform');
const TrailingChecksumTransform = require('../../../auth/streamingV4/trailingChecksumTransform');
const ChecksumTransform = require('../../../auth/streamingV4/ChecksumTransform');
const {
getChecksumDataFromHeaders,
arsenalErrorFromChecksumError,
} = require('../../apiUtils/integrity/validateChecksums');
const { errors, jsutil } = require('arsenal');
const { unsupportedSignatureChecksums } = require('../../../../constants');

/**
* Prepares the stream if the chunks are sent in a v4 Auth request
* @param {object} stream - stream containing the data
* @param {object | null } streamingV4Params - if v4 auth, object containing
* accessKey, signatureFromRequest, region, scopeDate, timestamp, and
* credentialScope (to be used for streaming v4 auth if applicable)
* @param {RequestLogger} log - the current request logger
* @param {function} errCb - callback called if an error occurs
* @return {object|null} - V4Transform object if v4 Auth request, or
* the original stream, or null if the request has no V4 params but
* the type of request requires them
* Prepares the request stream for data storage by wrapping it in the
* appropriate transform pipeline based on the x-amz-content-sha256 header.
* Always returns a ChecksumTransform as the final stream.
* If no checksum was sent by the client a CRC64NVME ChecksumTransform is returned.
*
* @param {object} request - incoming HTTP request with headers and body stream
* @param {object|null} streamingV4Params - v4 streaming auth params (accessKey,
* signatureFromRequest, region, scopeDate, timestamp, credentialScope), or
* null/undefined for non-v4 requests
* @param {RequestLogger} log - request logger
* @param {function} errCb - error callback invoked if a stream error occurs
* @return {{ error: Arsenal.Error|null, stream: ChecksumTransform|null }}
* error is set and stream is null if the request headers are invalid;
* otherwise error is null and stream is the ChecksumTransform to read from
*/
function prepareStream(stream, streamingV4Params, log, errCb) {
if (stream.headers['x-amz-content-sha256'] ===
'STREAMING-AWS4-HMAC-SHA256-PAYLOAD') {
if (typeof streamingV4Params !== 'object') {
// this might happen if the user provided a valid V2
// Authentication header, while the chunked upload method
// requires V4: in such case we don't get any V4 params
// and we should return an error to the client.
return null;
}
const v4Transform = new V4Transform(streamingV4Params, log, errCb);
stream.pipe(v4Transform);
v4Transform.headers = stream.headers;
return v4Transform;
}
return stream;
}
function prepareStream(request, streamingV4Params, log, errCb) {
const xAmzContentSHA256 = request.headers['x-amz-content-sha256'];

function stripTrailingChecksumStream(stream, log, errCb) {
// don't do anything if we are not in the correct integrity check mode
if (stream.headers['x-amz-content-sha256'] !== 'STREAMING-UNSIGNED-PAYLOAD-TRAILER') {
return stream;
const checksumAlgo = getChecksumDataFromHeaders(request.headers);
if (checksumAlgo.error) {
log.debug('prepareStream invalid checksum headers', checksumAlgo);
return { error: arsenalErrorFromChecksumError(checksumAlgo), stream: null };
}

const trailingChecksumTransform = new TrailingChecksumTransform(log);
trailingChecksumTransform.on('error', errCb);
stream.pipe(trailingChecksumTransform);
trailingChecksumTransform.headers = stream.headers;
return trailingChecksumTransform;
let stream = request;
switch (xAmzContentSHA256) {
case 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD': {
if (streamingV4Params === null || typeof streamingV4Params !== 'object') {
// this might happen if the user provided a valid V2
// Authentication header, while the chunked upload method
// requires V4: in such case we don't get any V4 params
// and we should return an error to the client.
log.error('missing v4 streaming params for chunked upload', {
method: 'prepareStream',
streamingV4ParamsType: typeof streamingV4Params,
streamingV4Params,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging streamingV4Params at error level leaks sensitive credentials (accessKey, signatureFromRequest) into production logs. Log only safe fields like the type, not the full object.

— Claude Code

return { error: errors.InvalidArgument, stream: null };
}
// Use a once-guard so that if both V4Transform and ChecksumTransform
// independently error, errCb is only called once.
const onStreamError = jsutil.once(errCb);
const v4Transform = new V4Transform(streamingV4Params, log, onStreamError);
request.pipe(v4Transform);
v4Transform.headers = request.headers;
stream = v4Transform;

const checksumedStream = new ChecksumTransform(
checksumAlgo.algorithm,
checksumAlgo.expected,
checksumAlgo.isTrailer,
log,
);
checksumedStream.on('error', onStreamError);
stream.pipe(checksumedStream);
return { error: null, stream: checksumedStream };
}
case 'STREAMING-UNSIGNED-PAYLOAD-TRAILER': {
// Use a once-guard so that auto-destroying both piped streams
// when one errors does not result in errCb being called twice.
const onStreamError = jsutil.once(errCb);
const trailingChecksumTransform = new TrailingChecksumTransform(log);
trailingChecksumTransform.on('error', onStreamError);
request.pipe(trailingChecksumTransform);
trailingChecksumTransform.headers = request.headers;
stream = trailingChecksumTransform;

const checksumedStream = new ChecksumTransform(
checksumAlgo.algorithm,
checksumAlgo.expected,
checksumAlgo.isTrailer,
log,
);
checksumedStream.on('error', onStreamError);
trailingChecksumTransform.on('trailer', (name, value) => {
checksumedStream.setExpectedChecksum(name, value);
});
stream.pipe(checksumedStream);
return { error: null, stream: checksumedStream };
}
case 'UNSIGNED-PAYLOAD': // Fallthrough
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default case now also handles STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER (V4 signed + trailing checksum). With the new checksum logic, getChecksumDataFromHeaders would set isTrailer: true (from the x-amz-trailer header), but the chunked+signed body is piped directly into ChecksumTransform without V4 decoding or chunk-size stripping. The checksum would be computed over the raw wire format and validation would fail with TrailerMissing.

This is a pre-existing gap (old code also did not handle this content-sha256 value), but now it is more relevant since AWS SDK v3 can use this mode when both signing and checksums are enabled. Consider adding a dedicated case that chains V4Transform -> TrailingChecksumTransform -> ChecksumTransform, or returning an explicit NotImplemented error.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateChecksumHeaders rejects STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER

default: {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER (signed payload with trailer) is not handled. If a client sends this content-sha256 value, it falls through to the default case, which does not set up V4Transform for signature verification. Should this be handled explicitly, or rejected with an error?

— Claude Code

if (unsupportedSignatureChecksums.has(xAmzContentSHA256)) {
return {
error: errors.BadRequest.customizeDescription(`${xAmzContentSHA256} is not supported`),
stream: null,
};
}

const checksumedStream = new ChecksumTransform(
checksumAlgo.algorithm,
checksumAlgo.expected,
checksumAlgo.isTrailer,
log,
);
checksumedStream.on('error', errCb);
stream.pipe(checksumedStream);
return { error: null, stream: checksumedStream };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UNSIGNED-PAYLOAD and default cases have identical code. Consider combining them by removing the UNSIGNED-PAYLOAD case and letting it fall through to default.

— Claude Code

}
}

module.exports = {
prepareStream,
stripTrailingChecksumStream,
};
module.exports = { prepareStream };
Loading
Loading