Skip to content
Merged
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
69 changes: 52 additions & 17 deletions lib/api/objectGet.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { errors, s3middleware } = require('arsenal');
const { parseRange } = require('arsenal').network.http.utils;
const async = require('async');

const { data } = require('../data/wrapper');

Expand All @@ -12,6 +13,7 @@ const setPartRanges = require('./apiUtils/object/setPartRanges');
const { standardMetadataValidateBucketAndObj } = require('../metadata/metadataUtils');
const { getPartCountFromMd5 } = require('./apiUtils/object/partInfo');
const { setExpirationHeaders } = require('./apiUtils/object/expirationHeaders');
const kms = require('../kms/wrapper');

const validateHeaders = s3middleware.validateConditionalHeaders;

Expand Down Expand Up @@ -211,24 +213,57 @@ function objectGet(authInfo, request, returnTagCount, log, callback) {
dataLocator = setPartRanges(dataLocator, byteRange);
}
}
return data.head(dataLocator, log, err => {
if (err) {
log.error('error from external backend checking for ' +
'object existence', { error: err });
return callback(err);
// Check KMS Key access and usability before checking data
// diff with AWS: for empty object (no dataLocator) KMS not checked
return async.each(dataLocator || [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit/question: async.each fires one KMS call per location entry, all in parallel. For an MPU object with thousands of parts, that is thousands of concurrent requests to the KMS on a single GET. Could we use async.eachLimit with a small cap (5 or 10) so one big object cannot flood the KMS?

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.

Right, and this has been shipped like that since more than 1 year in 9.5.0. This needs to be fixed in all versions.

I could even dedup the keys to avoid querying the same key multiple times as for MPU it's likely all parts use the same master key.

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.

(objectGetInfo, next) => {
if (!objectGetInfo.cipheredDataKey) {
return next();
}
const serverSideEncryption = {
cryptoScheme: objectGetInfo.cryptoScheme,
masterKeyId: objectGetInfo.masterKeyId,
cipheredDataKey: Buffer.from(
objectGetInfo.cipheredDataKey, 'base64'),
};
const offset = objectGetInfo.range ? objectGetInfo.range[0] : 0;
return kms.createDecipherBundle(serverSideEncryption,
offset, log, (err, decipherBundle) => {
if (err) {
log.error('cannot get decipher bundle from kms',
{ method: 'objectGet' });
return next(err);
}
// eslint-disable-next-line no-param-reassign
objectGetInfo.decipherStream = decipherBundle.decipher;
return next();
});
},
err => {
if (err) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: if one part's createDecipherBundle fails here, or data.head fails just below, what happens to the decipher streams already attached to the other parts? Same question if the client goes away before streaming starts. As far as I can tell they are never consumed or destroyed. Is that harmless, or should we destroy any objectGetInfo.decipherStream already set on these error paths? It feels like the same kind of lifecycle gap the arsenal fix covers, but I may be missing where they get cleaned up.

Also, would it make sense to add a cloudserver unit test for "KMS error is returned before any data is read", for example with a stubbed kms.createDecipherBundle that returns an error? That seems to be the main behavior this backport is for.

@BourgoisMickael BourgoisMickael Oct 6, 2026 •

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.

The current fix is related to an HTTP request keeping a socket to sproxyd.

The decipher stream has no socket to external service, it's a stream.Transform from crypto

const cipher = crypto.createDecipheriv(this._algorithm(), derivedKey, iv);

https://nodejs.org/api/crypto.html#cryptocreatedecipherivalgorithm-key-iv-options

It's in memory Transform with no socket involved. So it gets garbage collected.

JS memory is very light on Transform but that crypto does hold for OpenSSL some C++ native memory. So it would be better to clean them ASAP instead of waiting for GC or it might create native memory OOM in some conditions.


I'll likely open another PR / ticket to fix those 2 issues present in all versions of cloudserver out of this backport.


And I'll add some tests as well if they don't exist

return callback(err);
}

return data.head(dataLocator, log, err => {
if (err) {
log.error('error from external backend checking for ' +
'object existence', { error: err });
return callback(err);
}
pushMetric('getObject', log, {
authInfo,
bucket: bucketName,
keys: [objectKey],
newByteLength:
Number.parseInt(responseMetaHeaders['Content-Length'], 10),
versionId: objMD.versionId,
location: objMD.dataStoreName,
});
return callback(null, dataLocator, responseMetaHeaders,
byteRange);
});
}
pushMetric('getObject', log, {
authInfo,
bucket: bucketName,
keys: [objectKey],
newByteLength:
Number.parseInt(responseMetaHeaders['Content-Length'], 10),
versionId: objMD.versionId,
location: objMD.dataStoreName,
});
return callback(null, dataLocator, responseMetaHeaders,
byteRange);
});
);
});
}

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "s3",
"version": "7.10.60",
"version": "7.10.61",
"description": "S3 connector",
"main": "index.js",
"engines": {
Expand All @@ -20,7 +20,7 @@
"homepage": "https://github.com/scality/S3#readme",
"dependencies": {
"@hapi/joi": "^17.1.0",
"arsenal": "git+https://github.com/scality/arsenal#7.10.67",
"arsenal": "git+https://github.com/scality/arsenal#7.10.68",
"async": "~2.5.0",
"aws-sdk": "2.905.0",
"azure-storage": "^2.1.0",
Expand Down
45 changes: 45 additions & 0 deletions tests/unit/api/objectGet.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
const assert = require('assert');
const async = require('async');
const crypto = require('crypto');
const sinon = require('sinon');
const { errors } = require('arsenal');
const { parseString } = require('xml2js');

const { bucketPut } = require('../../../lib/api/bucketPut');
const { cleanup, DummyRequestLogger, makeAuthInfo } = require('../helpers');
const completeMultipartUpload
= require('../../../lib/api/completeMultipartUpload');
const { data } = require('../../../lib/data/wrapper');
const DummyRequest = require('../DummyRequest');
const initiateMultipartUpload
= require('../../../lib/api/initiateMultipartUpload');
const kms = require('../../../lib/kms/wrapper');
const objectPut = require('../../../lib/api/objectPut');
const objectGet = require('../../../lib/api/objectGet');
const objectPutPart = require('../../../lib/api/objectPutPart');
Expand Down Expand Up @@ -390,4 +394,45 @@ describe('objectGet API', () => {
});
});
});

describe('with an encrypted object', () => {
const testPutEncryptedObjectRequest = () => new DummyRequest({
bucketName,
namespace,
objectKey: objectName,
headers: {
'x-amz-server-side-encryption': 'AES256',
'content-length': '12',
},
parsedContentLength: 12,
url: `/${bucketName}/${objectName}`,
}, postBody);

afterEach(() => {
sinon.restore();
});

it('should return KMS error before checking data', done => {
const kmsError = errors.InternalError
.customizeDescription('KMS key is not usable');
async.waterfall([
next => bucketPut(authInfo, testPutBucketRequest, log,
err => next(err)),
next => objectPut(authInfo, testPutEncryptedObjectRequest(),
undefined, log, err => next(err)),
], err => {
assert.ifError(err);
const createDecipherBundleStub = sinon
.stub(kms, 'createDecipherBundle')
.callsFake((sse, offset, log, cb) => cb(kmsError));
const dataHeadSpy = sinon.spy(data, 'head');
objectGet(authInfo, testGetRequest, false, log, err => {
assert.strictEqual(err, kmsError);
assert(createDecipherBundleStub.calledOnce);
assert(dataHeadSpy.notCalled);
done();
});
});
});
});
});
6 changes: 3 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -488,9 +488,9 @@ arraybuffer.slice@~0.0.7:
optionalDependencies:
ioctl "^2.0.2"

"arsenal@git+https://github.com/scality/arsenal#7.10.67":
version "7.10.67"
resolved "git+https://github.com/scality/arsenal#4d12025b5dd95e67a3bb9e7f9e05058456b561d7"
"arsenal@git+https://github.com/scality/arsenal#7.10.68":
version "7.10.68"
resolved "git+https://github.com/scality/arsenal#931e3f1ed0ad5f09d2b770ee19895333a2662445"
dependencies:
"@types/async" "^3.2.12"
"@types/utf8" "^3.0.1"
Expand Down
Loading