Skip to content

Commit d330a90

Browse files
authored
Merge pull request #9147 from romayalon/romy-chunked-encoding-parsing-fix
S3 | Fix chunked content encoding parsing check
2 parents 03d59f6 + e7a8509 commit d330a90

File tree

4 files changed

+252
-1
lines changed

4 files changed

+252
-1
lines changed
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/* Copyright (C) 2024 NooBaa */
2+
'use strict';
3+
4+
const _ = require('lodash');
5+
const mocha = require('mocha');
6+
const crypto = require('crypto');
7+
const assert = require('assert');
8+
const config = require('../../../../../config');
9+
const http_utils = require('../../../../util/http_utils');
10+
const buffer_utils = require('../../../../util/buffer_utils');
11+
const dbg = require('../../../../util/debug_module')(__filename);
12+
const { NodeHttpHandler } = require('@smithy/node-http-handler');
13+
const { S3Error } = require('../../../../endpoint/s3/s3_errors');
14+
const { require_coretest, is_nc_coretest } = require('../../../system_tests/test_utils');
15+
const { S3Client, PutObjectCommand, ChecksumAlgorithm, CreateMultipartUploadCommand, UploadPartCommand,
16+
CompleteMultipartUploadCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
17+
18+
const coretest = require_coretest();
19+
const { rpc_client, EMAIL, POOL_LIST } = coretest;
20+
coretest.setup({ pools_to_create: is_nc_coretest ? undefined : [POOL_LIST[1]] });
21+
22+
const first_bucket = 'first.bucket';
23+
const default_checksum_algorithm = ChecksumAlgorithm.SHA256;
24+
const non_chunked_upload_key = 'non_chunked_upload.txt';
25+
26+
mocha.describe('S3 basic chunked upload tests', async function() {
27+
let s3;
28+
29+
mocha.before(async () => {
30+
const account_info = await rpc_client.account.read_account({ email: EMAIL, });
31+
const s3_client_params = {
32+
endpoint: coretest.get_http_address(),
33+
credentials: {
34+
accessKeyId: account_info.access_keys[0].access_key.unwrap(),
35+
secretAccessKey: account_info.access_keys[0].secret_key.unwrap(),
36+
},
37+
forcePathStyle: true,
38+
region: config.DEFAULT_REGION,
39+
requestHandler: new NodeHttpHandler({
40+
httpAgent: http_utils.get_unsecured_agent(coretest.get_http_address()),
41+
}),
42+
};
43+
s3 = new S3Client(s3_client_params);
44+
await validate_request_headers(s3);
45+
});
46+
47+
mocha.it('Put object - chunked upload - no additional content-encoding', async function() {
48+
const bucket = first_bucket;
49+
const key = 'chunked_upload.txt';
50+
const size = 5 * 1024 * 1024; // 5MB minimal for chunked upload
51+
await test_put_chunked_object({ s3, bucket, key, size });
52+
});
53+
54+
mocha.it('Put object - chunked upload - multiple encodings - no spaces - aws-chunked will be added to content-encoding header without spaces by the sdk', async function() {
55+
const bucket = first_bucket;
56+
const key = 'chunked_upload_multiple_encodings_no_spaces.txt';
57+
const content_encoding = 'gzip,zstd';
58+
const size = 100;
59+
await test_put_chunked_object({ s3, bucket, key, size, content_encoding });
60+
});
61+
62+
mocha.it('Put object - not a chunked upload but having aws-chunked with spaces in encoding header - should fail', async function() {
63+
const bucket = first_bucket;
64+
const key = non_chunked_upload_key;
65+
const content_encoding = 'zstd, gzip, aws-chunked';
66+
const size = 100;
67+
try {
68+
await test_put_chunked_object({ s3, bucket, key, size, content_encoding, chunked_upload: false });
69+
assert.fail('Put object - not a chunked upload but having aws-chunked in encoding header encoding - should fail');
70+
} catch (err) {
71+
dbg.error('error', err);
72+
// the upload is recognized as a chunked upload because of the added aws-chunked encoding but the content is not chunked - NooBaa throws internal error
73+
assert.equal(err.Code, S3Error.InternalError.code);
74+
}
75+
});
76+
77+
mocha.it('MPU - chunked upload - no additional content encoding', async function() {
78+
const bucket = first_bucket;
79+
const key = 'chunked_upload_mpu.txt';
80+
const parts_num = 3;
81+
const size = 5 * 1024 * 1024; // 5MB minimal for chunked upload
82+
await test_chunked_mpu({ s3, bucket, key, size, parts_num });
83+
});
84+
85+
mocha.it('MPU - chunked upload - multiple encodings - no spaces - aws-chunked will be added to content-encoding header without spaces by the sdk', async function() {
86+
const bucket = first_bucket;
87+
const key = 'chunked_upload_mpu_multiple_encodings_no_spaces.txt';
88+
const content_encoding = 'gzip,zstd';
89+
const parts_num = 3;
90+
const size = 5 * 1024 * 1024; // 5MB minimal for chunked upload
91+
await test_chunked_mpu({ s3, bucket, key, size, parts_num, content_encoding });
92+
});
93+
});
94+
95+
/**
96+
* @param {{
97+
* s3: S3Client,
98+
* bucket?: string,
99+
* key: string,
100+
* size?: number,
101+
* content_encoding?: string,
102+
* checksum_algorithm?: ChecksumAlgorithm
103+
* chunked_upload?: boolean
104+
* }} upload_config - Configuration object
105+
*/
106+
async function test_put_chunked_object(upload_config) {
107+
const { s3, bucket = first_bucket, key, size = 100, chunked_upload = true,
108+
content_encoding, checksum_algorithm = default_checksum_algorithm } = upload_config;
109+
const random_data_buffer = crypto.randomBytes(size);
110+
const random_data_stream = buffer_utils.buffer_to_read_stream(random_data_buffer);
111+
const body = chunked_upload ? random_data_stream : random_data_buffer;
112+
const input = {
113+
Bucket: bucket,
114+
Key: key,
115+
ContentLength: size,
116+
Body: body,
117+
ContentEncoding: content_encoding,
118+
ChecksumAlgorithm: chunked_upload ? checksum_algorithm : undefined,
119+
};
120+
const put_object_command = new PutObjectCommand(input);
121+
const put_object_response = await s3.send(put_object_command);
122+
dbg.log0('PutObject response:', put_object_response);
123+
assert.ok(put_object_response.ETag);
124+
125+
const get_object_res = await get_object(s3, bucket, key);
126+
assert.equal(get_object_res.body, random_data_buffer.toString());
127+
assert.equal(get_object_res.ETag, put_object_response.ETag);
128+
129+
await delete_object(s3, bucket, key);
130+
}
131+
132+
/**
133+
* @param {{
134+
* s3: S3Client,
135+
* bucket?: string,
136+
* key: string,
137+
* size?: number,
138+
* parts_num?: number,
139+
* content_encoding?: string,
140+
* checksum_algorithm?: ChecksumAlgorithm
141+
* chunked_upload?: boolean
142+
* }} mpu_config - Configuration object
143+
*/
144+
async function test_chunked_mpu(mpu_config) {
145+
const { s3, bucket = first_bucket, key, size = 100, parts_num = 1, chunked_upload = true,
146+
content_encoding = undefined, checksum_algorithm = default_checksum_algorithm } = mpu_config;
147+
148+
const create_mpu_input = {
149+
Bucket: bucket, Key: key, ContentEncoding: content_encoding,
150+
ChecksumAlgorithm: checksum_algorithm
151+
};
152+
const create_mpu_command = new CreateMultipartUploadCommand(create_mpu_input);
153+
const create_mpu_response = await s3.send(create_mpu_command);
154+
dbg.log0('MPU create_mpu_response:', create_mpu_response);
155+
assert.ok(create_mpu_response.UploadId);
156+
157+
const parts = [];
158+
let original_string = '';
159+
for (let i = 1; i <= parts_num; i++) {
160+
const random_data_buffer = crypto.randomBytes(size);
161+
const random_data_stream = buffer_utils.buffer_to_read_stream(random_data_buffer);
162+
const body = chunked_upload ? random_data_stream : random_data_buffer;
163+
const upload_part_input = {
164+
Bucket: bucket, Key: key, UploadId: create_mpu_response.UploadId,
165+
PartNumber: i, Body: body, ContentLength: size,
166+
ContentEncoding: content_encoding,
167+
ChecksumAlgorithm: chunked_upload ? checksum_algorithm : undefined
168+
};
169+
const upload_part_command = new UploadPartCommand(upload_part_input);
170+
const upload_part_response = await s3.send(upload_part_command);
171+
dbg.log0('MPU upload_part_response:', upload_part_response);
172+
assert.ok(upload_part_response.ETag);
173+
parts.push({ PartNumber: i, ETag: upload_part_response.ETag });
174+
original_string += random_data_buffer.toString();
175+
}
176+
177+
const complete_mpu_input = { Bucket: bucket, Key: key, UploadId: create_mpu_response.UploadId, MultipartUpload: { Parts: parts } };
178+
const complete_mpu_command = new CompleteMultipartUploadCommand(complete_mpu_input);
179+
const complete_mpu_response = await s3.send(complete_mpu_command);
180+
dbg.log0('MPU complete_mpu_response:', complete_mpu_response);
181+
assert.ok(complete_mpu_response.ETag);
182+
183+
const get_object_res = await get_object(s3, bucket, key);
184+
assert.equal(get_object_res.body, original_string);
185+
186+
await delete_object(s3, bucket, key);
187+
}
188+
189+
/**
190+
* validate_request_headers - Middleware to log and validate request headers
191+
* @param {S3Client} s3
192+
* @returns {Promise<Object>} - Returns the request headers after the middleware is executed
193+
*/
194+
async function validate_request_headers(s3) {
195+
let request_headers;
196+
s3.middlewareStack.add(
197+
(next, context) => async args => {
198+
const command = context.commandName;
199+
const object_key = args.input.Key;
200+
const is_chunked_upload_by_key = object_key !== non_chunked_upload_key;
201+
const is_upload_command = command === 'PutObjectCommand' || command === 'UploadPartCommand';
202+
if (is_chunked_upload_by_key && is_upload_command) {
203+
const upload_req_headers = args.request.headers;
204+
// NooBaa checks if the transfer is chunked by checking the content-encoding header
205+
// in this test file we artificially add aws-chunked to the content-encoding header
206+
// but we expect that the content is not chunked and the transfer-encoding header is not set
207+
// and the x-amz-decoded-content-length header is not set
208+
assert.ok(upload_req_headers['content-encoding'].includes('aws-chunked'));
209+
assert.ok(upload_req_headers['transfer-encoding'].includes('chunked'));
210+
assert.ok(upload_req_headers['x-amz-decoded-content-length'] !== undefined);
211+
}
212+
return next(args);
213+
},
214+
{
215+
step: 'finalizeRequest',
216+
name: 'logRequestHeaders',
217+
priority: 'high',
218+
}
219+
);
220+
return request_headers;
221+
}
222+
223+
/**
224+
* get_object - Get an object from S3 bucket
225+
* @param {S3Client} s3
226+
* @param {String} bucket
227+
* @param {String} key
228+
* @returns {Promise<{body: String, ETag: String}>} - The content of the object as a string
229+
*/
230+
async function get_object(s3, bucket, key) {
231+
const get_object_command = new GetObjectCommand({ Bucket: bucket, Key: key });
232+
const get_object_response = await s3.send(get_object_command);
233+
dbg.log0('GetObject response:', _.omit(get_object_response, ['Body']));
234+
const body = await get_object_response.Body.transformToString();
235+
return { body, ETag: get_object_response.ETag };
236+
}
237+
238+
/**
239+
* delete_object - Delete an object from S3 bucket
240+
* @param {S3Client} s3
241+
* @param {String} bucket
242+
* @param {String} key
243+
* @returns {Promise<Void>} - Deletes the object from the S3 bucket
244+
*/
245+
async function delete_object(s3, bucket, key) {
246+
const delete_object_command = new DeleteObjectCommand({ Bucket: bucket, Key: key });
247+
const delete_object_response = await s3.send(delete_object_command);
248+
dbg.log0('Delete response:', delete_object_response);
249+
}

src/test/utils/index/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ require('../../integration_tests/internal/test_tiering_ttl_worker');
9898
//require('./test_s3_worm');
9999
require('../../integration_tests/api/s3/test_bucket_logging');
100100
require('../../integration_tests/api/s3/test_notifications');
101+
require('../../integration_tests/api/s3/test_chunked_upload');
101102

102103
// UPGRADE
103104
// require('./test_postgres_upgrade'); // TODO currently working with mongo -> once changing to postgres - need to uncomment

src/test/utils/index/nc_index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ require('../../integration_tests/nc/cli/test_nc_bucket_logging');
2222
require('../../integration_tests/nc/cli/test_nc_online_upgrade_s3_integrations');
2323
require('../../integration_tests/api/s3/test_public_access_block');
2424
require('../../integration_tests/nc/lifecycle/test_nc_lifecycle_expiration');
25+
require('../../integration_tests/api/s3/test_chunked_upload');
2526

2627
// running with iam port
2728
require('../../integration_tests/api/iam/test_nc_iam_basic_integration.js'); // please notice that we use a different setup

src/util/http_utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,7 @@ function check_headers(req, options) {
630630

631631
const content_encoding = req.headers['content-encoding'] || '';
632632
req.chunked_content =
633-
content_encoding.split(',').includes('aws-chunked') ||
633+
content_encoding.split(',').map(encoding => encoding.trim()).includes('aws-chunked') ||
634634
content_sha256_hdr === STREAMING_PAYLOAD;
635635

636636
const req_time =

0 commit comments

Comments
 (0)