From 1e7711fe3c4dcfdbbeada8cd8fbb6ed9590e39f7 Mon Sep 17 00:00:00 2001 From: Will Toozs Date: Mon, 22 Jul 2024 12:27:42 +0200 Subject: [PATCH] fixup: update for content-type checking --- lib/api/apiUtils/apiCallers/callPostObject.js | 55 +- .../aws-node-sdk/test/object/post.js | 1253 +++++++++++++---- 2 files changed, 993 insertions(+), 315 deletions(-) diff --git a/lib/api/apiUtils/apiCallers/callPostObject.js b/lib/api/apiUtils/apiCallers/callPostObject.js index a4509f593f..61ecda332c 100644 --- a/lib/api/apiUtils/apiCallers/callPostObject.js +++ b/lib/api/apiUtils/apiCallers/callPostObject.js @@ -9,10 +9,34 @@ const os = require('os'); const MAX_FIELD_SIZE = 20 * 1024; // 20KB /** @see doc: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html */ const MAX_KEY_SIZE = 1024; +const POST_OBJECT_OPTIONAL_FIELDS = [ + 'acl', + 'awsaccesskeyid', + 'bucket', + 'cache-control', + 'content-disposition', + 'content-encoding', + 'content-type', + 'expires', + 'policy', + 'redirect', + 'tagging', + 'success_action_redirect', + 'success_action_status', + 'x-amz-meta-', + 'x-amz-storage-class', + 'x-amz-security-token', + 'x-amz-signgnature', + 'x-amz-website-redirect-location', +]; async function authenticateRequest(request, requestContexts, log) { return new Promise(resolve => { // TODO RING-45960 remove ignore for POST object here + // if (err) { + // log.trace('authentication error', { error: err }); + // return reject(err); + // } auth.server.doAuth(request, log, (err, userInfo, authorizationResults, streamingV4Params) => resolve({ userInfo, authorizationResults, streamingV4Params }), 's3', requestContexts); }); @@ -20,7 +44,21 @@ async function authenticateRequest(request, requestContexts, log) { async function parseFormData(request, response, requestContexts, log) { /* eslint-disable no-param-reassign */ - const formDataParser = busboy({ headers: request.headers }); + let formDataParser; + if (!request.headers + || !request.headers['content-type'] + || !request.headers['content-type'].startsWith('multipart/form-data')) { + return Promise.reject(errors.PreconditionFailed + .customizeDescription('Bucket POST must be of the enclosure-type multipart/form-data')); + } + try { + formDataParser = busboy({ headers: request.headers }); + } catch (err) { + log.trace('Error creating form data parser', { error: err.toString() }); + return Promise.reject(errors.PreconditionFailed); + } + + // formDataParser = busboy({ headers: request.headers }); writeContinue(request, response); return new Promise((resolve, reject) => { @@ -37,11 +75,15 @@ async function parseFormData(request, response, requestContexts, log) { const formParserFinishedPromise = new Promise((res) => { formParserFinishedPromiseResolve = res; }); formDataParser.on('field', (fieldname, val) => { + // Check if we have exceeded the max size allowed for all fields totalFieldSize += Buffer.byteLength(val, 'utf8'); if (totalFieldSize > MAX_FIELD_SIZE) { return reject(errors.MaxPostPreDataLengthExceeded); } + + // validate the fieldname const lowerFieldname = fieldname.toLowerCase(); + // special handling for key field if (lowerFieldname === 'key') { if (val.length > MAX_KEY_SIZE) { return reject(errors.KeyTooLong); @@ -49,8 +91,12 @@ async function parseFormData(request, response, requestContexts, log) { return reject(errors.InvalidArgument .customizeDescription('User key must have a length greater than 0.')); } + request.formData[lowerFieldname] = val; + } + // add only the recognized fields to the formData object + if (POST_OBJECT_OPTIONAL_FIELDS.some(field => lowerFieldname.startsWith(field))) { + request.formData[lowerFieldname] = val; } - request.formData[lowerFieldname] = val; return undefined; }); @@ -140,11 +186,6 @@ function getFileStat(filePath, log) { } async function processPostForm(request, response, requestContexts, log, callback) { - if (!request.headers || !request.headers['content-type'].includes('multipart/form-data')) { - const contentTypeError = errors.PreconditionFailed - .customizeDescription('Bucket POST must be of the enclosure-type multipart/form-data'); - return process.nextTick(callback, contentTypeError); - } try { const { userInfo, authorizationResults, streamingV4Params } = await parseFormData(request, response, requestContexts, log); diff --git a/tests/functional/aws-node-sdk/test/object/post.js b/tests/functional/aws-node-sdk/test/object/post.js index b37b7afda6..63b03b5165 100644 --- a/tests/functional/aws-node-sdk/test/object/post.js +++ b/tests/functional/aws-node-sdk/test/object/post.js @@ -4,7 +4,8 @@ const axios = require('axios'); const crypto = require('crypto'); const FormData = require('form-data'); const assert = require('assert'); - +const http = require('http'); +const { URL } = require('url'); const BucketUtility = require('../../lib/utility/bucket-util'); const getConfig = require('../support/config'); @@ -117,7 +118,7 @@ describe('POST object', () => { bucketName = generateBucketName(); const url = `${config.endpoint}/${bucketName}`; testContext.bucketName = bucketName; - testContext.url = url; + testContext.url = new URL(url); const fileContent = 'This is a test file'; fileBuffer = Buffer.from(fileContent); @@ -151,7 +152,6 @@ describe('POST object', () => { }); }); - it('should successfully upload an object using a POST form', done => { const { url } = testContext; const fields = calculateFields(ak, sk); @@ -168,20 +168,35 @@ describe('POST object', () => { return done(err); } - return axios.post(url, formData, { + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers: { ...formData.getHeaders(), 'Content-Length': length, }, - }) - .then(response => { - assert.equal(response.status, 204); - assert.equal(response.headers.location, `/${bucketName}/${filename}`); + }; + + const req = http.request(options); + + req.on('response', res => { + try { + assert.equal(res.statusCode, 204); + assert.equal(res.headers.location, `/${bucketName}/${filename}`); done(); - }) - .catch(err => { + } catch (err) { done(err); - }); + } + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); @@ -202,20 +217,35 @@ describe('POST object', () => { return done(err); } - return axios.post(url, formData, { + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers: { ...formData.getHeaders(), 'Content-Length': length, }, - }) - .then(response => { - assert.equal(response.status, 204); - assert.equal(response.headers.location, `/${bucketName}/${encodedKey}`); + }; + + const req = http.request(options); + + req.on('response', res => { + try { + assert.equal(res.statusCode, 204); + assert.equal(res.headers.location, `/${bucketName}/${encodedKey}`); done(); - }) - .catch(err => { + } catch (err) { done(err); - }); + } + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); @@ -236,23 +266,36 @@ describe('POST object', () => { return done(err); } - return axios.post(tempUrl, formData, { + const parsedUrl = new URL(tempUrl); + + const options = { + method: 'POST', + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + parsedUrl.search, headers: { ...formData.getHeaders(), 'Content-Length': length, }, - }) - .then(() => { - done(new Error('Expected error but got success response')); - }) - .catch(err => { - assert.equal(err.response.status, 404); + }; + + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode === 404) { done(); - }); + } else { + done(new Error('Expected error but got success response')); + } + }); + + req.on('error', done); + + formData.pipe(req); + return undefined; }); }); - it('should successfully upload a larger file to S3 using a POST form', done => { const { url } = testContext; const largeFileName = 'large-test-file.txt'; @@ -273,29 +316,46 @@ describe('POST object', () => { return done(err); } - return axios.post(url, formData, { + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers: { ...formData.getHeaders(), 'Content-Length': length, }, - }) - .then(response => { - assert.equal(response.status, 204); + }; + + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode === 204) { s3.listObjectsV2({ Bucket: bucketName }, (err, data) => { if (err) { return done(err); } const uploadedFile = data.Contents.find(item => item.Key === largeFileName); - assert(uploadedFile, 'Uploaded file should exist in the bucket'); - assert.equal(uploadedFile.Size, Buffer.byteLength(largeFileContent), 'File size should match'); - - return done(); + try { + assert(uploadedFile, 'Uploaded file should exist in the bucket'); + assert.equal(uploadedFile.Size, + Buffer.byteLength(largeFileContent), 'File size should match'); + done(); + } catch (err) { + done(err); + } + return undefined; }); - }) - .catch(err => { - done(err); - }); + } else { + done(new Error(`Expected status 204 but got ${res.statusCode}`)); + } + }); + + req.on('error', done); + + formData.pipe(req); + return undefined; }); }); @@ -308,24 +368,30 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - const emptyFileBuffer = Buffer.from(''); // Create a buffer for an empty file + const emptyFileBuffer = Buffer.from(''); - formData.append('file', emptyFileBuffer, filename); + formData.append('file', emptyFileBuffer, { filename }); formData.getLength((err, length) => { if (err) { return done(err); } - return axios.post(url, formData, { + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers: { ...formData.getHeaders(), 'Content-Length': length, }, - }) - .then(response => { - assert.equal(response.status, 204); + }; + + const req = http.request(options); + req.on('response', res => { + if (res.statusCode === 204) { // Check if the object exists using listObjects s3.listObjectsV2({ Bucket: bucketName, Prefix: filename }, (err, data) => { if (err) { @@ -335,22 +401,34 @@ describe('POST object', () => { const fileExists = data.Contents.some(item => item.Key === filename); const file = data.Contents.find(item => item.Key === filename); - assert(fileExists, 'File should exist in S3'); - assert.equal(file.Size, 0, 'File size should be 0'); + try { + assert(fileExists, 'File should exist in S3'); + assert.equal(file.Size, 0, 'File size should be 0'); - // Clean up: delete the empty file from S3 - return s3.deleteObject({ Bucket: bucketName, Key: filename }, err => { - if (err) { - return done(err); - } + // Clean up: delete the empty file from S3 + s3.deleteObject({ Bucket: bucketName, Key: filename }, err => { + if (err) { + return done(err); + } - return done(); - }); + return done(); + }); + } catch (err) { + return done(err); + } + return undefined; }); - }) - .catch(err => { - done(err); - }); + } else { + done(new Error(`Expected status 204 but got ${res.statusCode}`)); + } + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); @@ -368,28 +446,55 @@ describe('POST object', () => { return done(err); } - return axios.post(url, formData, { + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers: { ...formData.getHeaders(), 'Content-Length': length, }, - }) - .then(() => { + }; + + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode !== 400) { done(new Error('Expected error but got success response')); - }) - .catch(err => { - assert.equal(err.response.status, 400); - xml2js.parseString(err.response.data, (parseErr, result) => { + return; + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { if (parseErr) { return done(parseErr); } - const error = result.Error; - assert.equal(error.Code[0], 'InvalidArgument'); - assert.equal(error.Message[0], 'POST requires exactly one file upload per request.'); - return done(); + try { + const error = result.Error; + assert.equal(error.Code[0], 'InvalidArgument'); + assert.equal(error.Message[0], 'POST requires exactly one file upload per request.'); + done(); + } catch (err) { + done(err); + } + return undefined; }); }); + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); @@ -411,32 +516,58 @@ describe('POST object', () => { return done(err); } - return axios.post(url, formData, { + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers: { ...formData.getHeaders(), 'Content-Length': length, }, - }) - .then(() => { + }; + + const req = http.request(options); + + // Handle the response + req.on('response', res => { + if (res.statusCode !== 400) { done(new Error('Expected error but got success response')); - }) - .catch(err => { - assert.equal(err.response.status, 400); - xml2js.parseString(err.response.data, (parseErr, result) => { + return; + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { if (parseErr) { return done(parseErr); } - const error = result.Error; - assert.equal(error.Code[0], 'InvalidArgument'); - assert.equal(error.Message[0], 'POST requires exactly one file upload per request.'); - return done(); + try { + const error = result.Error; + assert.equal(error.Code[0], 'InvalidArgument'); + assert.equal(error.Message[0], 'POST requires exactly one file upload per request.'); + return done(); + } catch (err) { + return done(err); + } }); }); + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); - it('should handle error when key is missing', done => { const { url } = testContext; // Prep fields then remove the key field @@ -459,39 +590,63 @@ describe('POST object', () => { return done(err); } - return axios.post(url, formData, { + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers: { ...formData.getHeaders(), 'Content-Length': length, }, - }) - .then(() => { + }; + + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode !== 400) { done(new Error('Request should not succeed without key field')); - }) - .catch(err => { - assert.ok(err.response, 'Error should be returned by axios'); + return; + } - xml2js.parseString(err.response.data, (parseErr, result) => { + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { if (parseErr) { return done(parseErr); } - const error = result.Error; - assert.equal(error.Code[0], 'InvalidArgument'); - assert.equal(error.Message[0], - "Bucket POST must contain a field named 'key'. " - + 'If it is specified, please check the order of the fields.'); - return done(); + try { + const error = result.Error; + assert.equal(error.Code[0], 'InvalidArgument'); + assert.equal(error.Message[0], + "Bucket POST must contain a field named 'key'. " + + 'If it is specified, please check the order of the fields.'); + return done(); + } catch (err) { + return done(err); + } }); }); + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); it('should handle error when content-type is incorrect', done => { const { url } = testContext; // Prep fields then remove the key field - let fields = calculateFields(ak, sk); - fields = fields.filter(e => e.name !== 'key'); + const fields = calculateFields(ak, sk); const formData = new FormData(); @@ -499,7 +654,7 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); formData.getLength((err, length) => { if (err) { @@ -509,37 +664,62 @@ describe('POST object', () => { const headers = { ...formData.getHeaders(), 'Content-Length': length, + 'Content-Type': 'application/json', // Incorrect content type }; - headers['content-type'] = 'application/json'; - return axios.post(url, formData, { + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers, - }) - .then(() => { - done(new Error('Request should not succeed wrong content-type')); - }) - .catch(err => { - assert.ok(err.response, 'Error should be returned by axios'); + }; - xml2js.parseString(err.response.data, (err, result) => { - if (err) { - return done(err); + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode !== 412) { // 412 Precondition Failed + done(new Error('Request should not succeed with wrong content-type')); + return; + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); } - const error = result.Error; - assert.equal(error.Code[0], 'PreconditionFailed'); - assert.equal(error.Message[0], - 'Bucket POST must be of the enclosure-type multipart/form-data'); - return done(); + try { + const error = result.Error; + assert.equal(error.Code[0], 'PreconditionFailed'); + assert.equal(error.Message[0], + 'Bucket POST must be of the enclosure-type multipart/form-data'); + return done(); + } catch (err) { + return done(err); + } }); }); + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); - it('should handle error when content-type is missing', done => { + it('should handle error when content-type is "abc multipart/form-data"', done => { const { url } = testContext; // Prep fields then remove the key field - let fields = calculateFields(ak, sk); - fields = fields.filter(e => e.name !== 'key'); + const fields = calculateFields(ak, sk); const formData = new FormData(); @@ -547,7 +727,7 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); formData.getLength((err, length) => { if (err) { @@ -557,36 +737,62 @@ describe('POST object', () => { const headers = { ...formData.getHeaders(), 'Content-Length': length, + 'Content-Type': 'abc multipart/form-data', // Incorrect content type }; - delete headers['content-type']; - return axios.post(url, formData, { + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, headers, - }) - .then(() => { - done(new Error('Request should not succeed without correct content-type')); - }) - .catch(err => { - assert.ok(err.response, 'Error should be returned by axios'); + }; - xml2js.parseString(err.response.data, (err, result) => { - if (err) { - return done(err); + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode !== 412) { // 412 Precondition Failed + done(new Error('Request should not succeed with wrong content-type')); + return; + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); } - const error = result.Error; - assert.equal(error.Code[0], 'PreconditionFailed'); - assert.equal(error.Message[0], - 'Bucket POST must be of the enclosure-type multipart/form-data'); - return done(); + try { + const error = result.Error; + assert.equal(error.Code[0], 'PreconditionFailed'); + assert.equal(error.Message[0], + 'Bucket POST must be of the enclosure-type multipart/form-data'); + return done(); + } catch (err) { + return done(err); + } }); }); + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); - it('should upload an object with key slash', done => { + it('should handle error when content-type is "multipart/form-data abc"', done => { const { url } = testContext; - const slashKey = '/'; - const fields = calculateFields(ak, sk, [{ key: slashKey }]); + // Prep fields then remove the key field + const fields = calculateFields(ak, sk); const formData = new FormData(); @@ -594,80 +800,71 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); formData.getLength((err, length) => { if (err) { return done(err); } - return axios.post(url, formData, { - headers: { - ...formData.getHeaders(), - 'Content-Length': length, - }, - }) - .then(response => { - assert.equal(response.status, 204); - done(); - }) - .catch(err => { - done(err); - }); - }); - }); - - it('should fail to upload an object with key length of 0', done => { - const { url } = testContext; - const fields = calculateFields(ak, sk, [ - { key: '' }, - ]); + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + 'Content-Type': 'multipart/form-data abc', // Incorrect content type + }; - const formData = new FormData(); + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; - fields.forEach(field => { - formData.append(field.name, field.value); - }); + const req = http.request(options); - formData.append('file', fileBuffer, filename); + req.on('response', res => { + if (res.statusCode !== 412) { // 412 Precondition Failed + done(new Error('Request should not succeed with wrong content-type')); + return; + } - formData.getLength((err, length) => { - if (err) { - return done(err); - } + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); - // Use an incorrect content length (e.g., actual length - 20) + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); + } - return axios.post(url, formData, { - headers: { - ...formData.getHeaders(), - 'Content-Length': length, - }, - }) - .then(() => done(new Error('Request should have failed but succeeded'))) - .catch(err => { - // Expecting an error response from the API - assert.equal(err.response.status, 400); - xml2js.parseString(err.response.data, (err, result) => { - if (err) { + try { + const error = result.Error; + assert.equal(error.Code[0], 'PreconditionFailed'); + assert.equal(error.Message[0], + 'Bucket POST must be of the enclosure-type multipart/form-data'); + return done(); + } catch (err) { return done(err); } - - const error = result.Error; - assert.equal(error.Code[0], 'InvalidArgument'); - assert.equal(error.Message[0], - 'User key must have a length greater than 0.'); - return done(); }); }); + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; }); }); - it('should fail to upload an object with key longer than 1024 bytes', done => { + it('should handle error when content-type is missing', done => { const { url } = testContext; - const fields = calculateFields(ak, sk, [ - { key: 'a'.repeat(1025) }, - ]); + const fields = calculateFields(ak, sk); const formData = new FormData(); @@ -675,40 +872,257 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); formData.getLength((err, length) => { if (err) { return done(err); } - // Use an incorrect content length (e.g., actual length - 20) + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; + delete headers['content-type']; // Ensure content-type is missing - return axios.post(url, formData, { - headers: { - ...formData.getHeaders(), - 'Content-Length': length, - }, - }) - .then(() => { - // The request should fail, so we shouldn't get here + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode !== 412) { + done(new Error('Request should not succeed without correct content-type')); + return; + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); + } + + try { + const error = result.Error; + assert.equal(error.Code[0], 'PreconditionFailed'); + assert.equal(error.Message[0], + 'Bucket POST must be of the enclosure-type multipart/form-data'); + return done(); + } catch (err) { + return done(err); + } + }); + }); + }); + + req.on('error', done); + + formData.pipe(req); + return undefined; + }); + }); + + it('should upload an object with key slash', done => { + const { url } = testContext; + const slashKey = '/'; + const fields = calculateFields(ak, sk, [{ key: slashKey }]); + + const formData = new FormData(); + + fields.forEach(field => { + formData.append(field.name, field.value); + }); + + formData.append('file', fileBuffer, { filename }); + + formData.getLength((err, length) => { + if (err) { + return done(err); + } + + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode === 204) { + done(); + } else { + done(new Error(`Expected status 204 but got ${res.statusCode}`)); + } + }); + + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; + }); + }); + + it('should fail to upload an object with key length of 0', done => { + const { url } = testContext; + const fields = calculateFields(ak, sk, [{ key: '' }]); + + const formData = new FormData(); + + fields.forEach(field => { + formData.append(field.name, field.value); + }); + + formData.append('file', fileBuffer, { filename }); + + formData.getLength((err, length) => { + if (err) { + return done(err); + } + + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + // Handle the response + req.on('response', res => { + if (res.statusCode !== 400) { done(new Error('Request should have failed but succeeded')); - }) - .catch(err => { - // Expecting an error response from the API - assert.equal(err.response.status, 400); - xml2js.parseString(err.response.data, (err, result) => { - if (err) { + return; + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); + } + + try { + const error = result.Error; + assert.equal(error.Code[0], 'InvalidArgument'); + assert.equal(error.Message[0], 'User key must have a length greater than 0.'); + return done(); + } catch (err) { return done(err); } + }); + }); + }); - const error = result.Error; - assert.equal(error.Code[0], 'KeyTooLong'); - assert.equal(error.Message[0], - 'Your key is too long.'); - return done(); + req.on('error', err => { + done(err); + }); + + formData.pipe(req); + return undefined; + }); + }); + + it('should fail to upload an object with key longer than 1024 bytes', done => { + const { url } = testContext; + const fields = calculateFields(ak, sk, [{ key: 'a'.repeat(1025) }]); + + const formData = new FormData(); + + fields.forEach(field => { + formData.append(field.name, field.value); + }); + + formData.append('file', fileBuffer, { filename }); + + formData.getLength((err, length) => { + if (err) { + return done(err); + } + + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + // Handle the response + req.on('response', res => { + if (res.statusCode !== 400) { + done(new Error('Request should have failed but succeeded')); + return; + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); + } + + try { + const error = result.Error; + assert.equal(error.Code[0], 'KeyTooLong'); + assert.equal(error.Message[0], 'Your key is too long.'); + return done(); + } catch (err) { + return done(err); + } }); }); + }); + + // Handle any errors during the request + req.on('error', err => { + done(err); + }); + + // Stream the form data into the request + formData.pipe(req); + return undefined; }); }); @@ -724,30 +1138,48 @@ describe('POST object', () => { formData.append(field.name, value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); formData.getLength((err, length) => { if (err) return done(err); - return axios.post(url, formData, { - headers: { - ...formData.getHeaders(), - 'Content-Length': length, - }, - }) - .then(response => { - assert.equal(response.status, 204); - const expectedKey = keyTemplate.replace('${filename}', filename); - - const listParams = { Bucket: bucketName, Prefix: expectedKey }; - return s3.listObjects(listParams, (err, data) => { - if (err) return done(err); - const objectExists = data.Contents.some(item => item.Key === expectedKey); - assert(objectExists, 'Object was not uploaded with the expected key'); - return done(); - }); - }) - .catch(done); + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + // Handle the response + req.on('response', res => { + if (res.statusCode !== 204) { + done(new Error(`Expected status 204 but got ${res.statusCode}`)); + return; + } + + const expectedKey = keyTemplate.replace('${filename}', filename); + + const listParams = { Bucket: bucketName, Prefix: expectedKey }; + s3.listObjects(listParams, (err, data) => { + if (err) return done(err); + const objectExists = data.Contents.some(item => item.Key === expectedKey); + assert(objectExists, 'Object was not uploaded with the expected key'); + return done(); + }); + }); + + req.on('error', done); + + formData.pipe(req); + return undefined; }); }); @@ -761,7 +1193,7 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); // Generate the form data with a valid boundary const validBoundary = formData.getBoundary(); @@ -778,25 +1210,40 @@ describe('POST object', () => { Buffer.from(`\r\n--${invalidBoundary}--\r\n`), ]); - // Create an axios instance with invalid headers - axios.post(url, payload, { - headers: { - 'Content-Type': `multipart/form-data; boundary=${validBoundary}`, - 'Content-Length': payload.length, - }, - }) - .then(() => { - // The request should fail, so we shouldn't get here + const headers = { + 'Content-Type': `multipart/form-data; boundary=${validBoundary}`, + 'Content-Length': payload.length, + }; + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode !== 400) { done(new Error('Request should have failed but succeeded')); - }) - .catch(err => { - // Expecting an error response from the API - assert.equal(err.response.status, 400); - done(); - }); + return; + } + + assert.equal(res.statusCode, 400); + done(); + }); + + req.on('error', err => { + done(err); + }); + + req.write(payload); + req.end(); }); - it('should fail to upload an object with an too small content length header', done => { + it('should fail to upload an object with a too small content length header', done => { const { url } = testContext; const fields = calculateFields(ak, sk); @@ -806,7 +1253,7 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); formData.getLength((err, length) => { if (err) { @@ -816,21 +1263,40 @@ describe('POST object', () => { // Use an incorrect content length (e.g., actual length - 20) const incorrectLength = length - 20; - return axios.post(url, formData, { - headers: { - ...formData.getHeaders(), - 'Content-Length': incorrectLength, - }, - }) - .then(() => done(new Error('Request should have failed but succeeded'))) - .catch(err => { - // Expecting an error response from the API - assert.equal(err.response.status, 400); - done(); - }); + const headers = { + ...formData.getHeaders(), + 'Content-Length': incorrectLength, + }; + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + // Handle the response + req.on('response', res => { + if (res.statusCode !== 400) { + return done(new Error('Request should have failed but succeeded')); + } + + return done(); + }); + + // Handle any errors during the request + req.on('error', done); + + // Stream the form data into the request + formData.pipe(req); + return undefined; }); }); + it('should return an error if form data (excluding file) exceeds 20KB', done => { const { url } = testContext; const fields = calculateFields(ak, sk); @@ -847,44 +1313,71 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); - return formData.getLength((err, length) => { + formData.getLength((err, length) => { if (err) { return done(err); } - return axios.post(url, formData, { - headers: { - ...formData.getHeaders(), - 'Content-Length': length, - }, - }) - .then(() => { - done(new Error('Request should not succeed with form data exceeding 20KB')); - }) - .catch(err => { - assert.ok(err.response, 'Error should be returned by axios'); + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; - xml2js.parseString(err.response.data, (err, result) => { - if (err) { - return done(err); + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + // Handle the response + req.on('response', res => { + if (res.statusCode !== 400) { + return done(new Error('Request should not succeed with form data exceeding 20KB')); + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); } - const error = result.Error; - assert.equal(error.Code[0], 'MaxPostPreDataLengthExceeded'); - assert.equal(error.Message[0], - 'Your POST request fields preceeding the upload file was too large.'); - return done(); + try { + const error = result.Error; + assert.equal(error.Code[0], 'MaxPostPreDataLengthExceeded'); + assert.equal(error.Message[0], + 'Your POST request fields preceeding the upload file was too large.'); + return done(); + } catch (err) { + return done(err); + } }); }); + return undefined; + }); + + req.on('error', done); + + formData.pipe(req); + + return undefined; }); }); it('should return an error if a query parameter is present in the URL', done => { const { url } = testContext; const queryParam = '?invalidParam=true'; - const invalidUrl = `${url}${queryParam}`; + const invalidUrl = new URL(url.toString() + queryParam); const fields = calculateFields(ak, sk); const formData = new FormData(); @@ -893,36 +1386,62 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); - return formData.getLength((err, length) => { + formData.getLength((err, length) => { if (err) { return done(err); } - return axios.post(invalidUrl, formData, { - headers: { - ...formData.getHeaders(), - 'Content-Length': length, - }, - }) - .then(() => { - done(new Error('Request should not succeed with an invalid query parameter')); - }) - .catch(err => { - assert.ok(err.response, 'Error should be returned by axios'); + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; - xml2js.parseString(err.response.data, (err, result) => { - if (err) { - return done(err); + const options = { + method: 'POST', + hostname: invalidUrl.hostname, + port: invalidUrl.port, + path: invalidUrl.pathname + invalidUrl.search, + headers, + }; + + const req = http.request(options); + + // Handle the response + req.on('response', res => { + if (res.statusCode !== 400) { + return done(new Error('Request should not succeed with an invalid query parameter')); + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); } - const error = result.Error; - assert.equal(error.Code[0], 'InvalidArgument'); - assert.equal(error.Message[0], 'Query String Parameters not allowed on POST requests.'); - return done(); + try { + const error = result.Error; + assert.equal(error.Code[0], 'InvalidArgument'); + assert.equal(error.Message[0], 'Query String Parameters not allowed on POST requests.'); + return done(); + } catch (err) { + return done(err); + } }); }); + return undefined; + }); + + req.on('error', done); + + formData.pipe(req); + return undefined; }); }); @@ -930,7 +1449,7 @@ describe('POST object', () => { const { url } = testContext; const objectKey = 'someObjectKey'; const queryParam = '?nonMatchingParam=true'; - const invalidUrl = `${url}/${objectKey}${queryParam}`; + const invalidUrl = new URL(`${url}/${objectKey}${queryParam}`); const fields = calculateFields(ak, sk); const formData = new FormData(); @@ -939,14 +1458,132 @@ describe('POST object', () => { formData.append(field.name, field.value); }); - formData.append('file', fileBuffer, filename); + formData.append('file', fileBuffer, { filename }); - return formData.getLength((err, length) => { + formData.getLength((err, length) => { if (err) { return done(err); } - return axios.post(invalidUrl, formData, { + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; + + const options = { + method: 'POST', + hostname: invalidUrl.hostname, + port: invalidUrl.port, + path: invalidUrl.pathname + invalidUrl.search, + headers, + }; + + const req = http.request(options); + + // Handle the response + req.on('response', res => { + if (res.statusCode !== 405) { + return done(new Error('Request should not succeed with a non-matching query parameter')); + } + + let responseData = ''; + res.on('data', chunk => { + responseData += chunk; + }); + + res.on('end', () => { + xml2js.parseString(responseData, (parseErr, result) => { + if (parseErr) { + return done(parseErr); + } + + try { + const error = result.Error; + assert.equal(error.Code[0], 'MethodNotAllowed'); + assert.equal(error.Message[0], + 'The specified method is not allowed against this resource.'); + return done(); + } catch (err) { + return done(err); + } + }); + }); + return undefined; + }); + + req.on('error', done); + + formData.pipe(req); + return undefined; + }); + }); + + it('should successfully upload an object with bucket versioning enabled and verify version ID', done => { + const { url } = testContext; + + // Enable versioning on the bucket + const versioningParams = { + Bucket: bucketName, + VersioningConfiguration: { + Status: 'Enabled', + }, + }; + + return s3.putBucketVersioning(versioningParams, (err) => { + if (err) { + return done(err); + } + + const fields = calculateFields(ak, sk, [{ bucket: bucketName }]); + const formData = new FormData(); + + fields.forEach(field => { + formData.append(field.name, field.value); + }); + + formData.append('file', fileBuffer, { filename }); + + formData.getLength((err, length) => { + if (err) { + return done(err); + } + + const headers = { + ...formData.getHeaders(), + 'Content-Length': length, + }; + + const options = { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + headers, + }; + + const req = http.request(options); + + req.on('response', res => { + if (res.statusCode !== 204) { + return done(new Error(`Expected status 204 but got ${res.statusCode}`)); + } + + // Verify version ID is present in the response headers + const versionId = res.headers['x-amz-version-id']; + assert.ok(versionId, 'Version ID should be present in the response headers'); + return done(); + }); + + req.on('error', done); + + formData.pipe(req); + return undefined; + }); + return undefined; + }); + }); +}); + headers: { ...formData.getHeaders(), 'Content-Length': length,