diff --git a/lib/auth/v4/formAuthCheck.ts b/lib/auth/v4/formAuthCheck.ts new file mode 100644 index 000000000..e6b0373d9 --- /dev/null +++ b/lib/auth/v4/formAuthCheck.ts @@ -0,0 +1,108 @@ +import { Logger } from 'werelogs'; +import * as constants from '../../constants'; +import errors from '../../errors'; +import { convertAmzTimeToMs } from './timeUtils'; +import { validateCredentials } from './validateInputs'; + +/** + * V4 query auth check + * @param request - HTTP request object + * @param log - logging object + * @param data - Contain authentification params (GET or POST data) + */ +export function check(request: any, log: Logger, data: { [key: string]: string }) { + let signatureFromRequest; + let timestamp; + let expiration; + let credential; + + if (data['x-amz-algorithm'] !== 'AWS4-HMAC-SHA256') { + log.debug('algorithm param incorrect', { algo: data['X-Amz-Algorithm'] }); + return { err: errors.InvalidArgument }; + } + + signatureFromRequest = data['x-amz-signature']; + if (!signatureFromRequest) { + log.debug('missing signature'); + return { err: errors.InvalidArgument }; + } + + timestamp = data['x-amz-date']; + if (!timestamp || timestamp.length !== 16) { + log.debug('missing or invalid timestamp', { timestamp: data['x-amz-date'] }); + return { err: errors.InvalidArgument }; + } + + const policy = data['policy']; + if (policy && policy.length > 0) { + const decryptedPolicy = Buffer.from(policy, 'base64').toString('utf8'); + const policyObj = JSON.parse(decryptedPolicy); + expiration = policyObj.expiration; + } else { + log.debug('missing or invalid policy', { policy: data['policy'] }); + return { err: errors.InvalidArgument }; + } + + credential = data['x-amz-credential']; + if (credential && credential.length > 28 && credential.indexOf('/') > -1) { + // @ts-ignore + credential = credential.split('/'); + const validationResult = validateCredentials(credential, timestamp, + log); + if (validationResult instanceof Error) { + log.debug('credentials in improper format', { credential, + timestamp, validationResult }); + return { err: validationResult }; + } + } else { + log.debug('invalid credential param', { credential: data['X-Amz-Credential'] }); + return { err: errors.InvalidArgument }; + } + + const token = data['x-amz-security-token']; + if (token && !constants.iamSecurityToken.pattern.test(token)) { + log.debug('invalid security token', { token }); + return { err: errors.InvalidToken }; + } + + // check if the expiration date is past the current time + if (Date.parse(expiration) < Date.now()) { + return { err: errors.AccessDenied.customizeDescription('Invalid according to Policy: Policy expired.') }; + } + + const validationResult = validateCredentials(credential, timestamp, + log); + if (validationResult instanceof Error) { + log.debug('credentials in improper format', { credential, + timestamp, validationResult }); + return { err: validationResult }; + } + const accessKey = credential[0]; + const scopeDate = credential[1]; + const region = credential[2]; + const service = credential[3]; + + // string to sign is the policy for form requests + const stringToSign = data['policy']; + + log.trace('constructed stringToSign', { stringToSign }); + return { + err: null, + params: { + version: 4, + data: { + accessKey, + signatureFromRequest, + region, + scopeDate, + stringToSign, + service, + authType: 'REST-FORM-DATA', + signatureVersion: 'AWS4-HMAC-SHA256', + signatureAge: Date.now() - convertAmzTimeToMs(timestamp), + timestamp, + securityToken: token, + }, + }, + }; +} diff --git a/lib/errors/arsenalErrors.ts b/lib/errors/arsenalErrors.ts index 391191bf2..52bcebf30 100644 --- a/lib/errors/arsenalErrors.ts +++ b/lib/errors/arsenalErrors.ts @@ -281,10 +281,10 @@ export const MaxMessageLengthExceeded: ErrorFormat = { description: 'Your request was too big.', }; -export const MaxPostPreDataLengthExceededError: ErrorFormat = { +export const MaxPostPreDataLengthExceeded: ErrorFormat = { code: 400, description: - 'Your POST request fields preceding the upload file were too large.', + 'Your POST request fields preceeding the upload file was too large.', }; export const MetadataTooLarge: ErrorFormat = { diff --git a/lib/policyEvaluator/utils/actionMaps.ts b/lib/policyEvaluator/utils/actionMaps.ts index 9f2c75d5f..625193178 100644 --- a/lib/policyEvaluator/utils/actionMaps.ts +++ b/lib/policyEvaluator/utils/actionMaps.ts @@ -137,6 +137,7 @@ const actionMonitoringMapS3 = { objectGetTagging: 'GetObjectTagging', objectHead: 'HeadObject', objectPut: 'PutObject', + objectPost: 'PostObject', objectPutACL: 'PutObjectAcl', objectPutCopyPart: 'UploadPartCopy', objectPutLegalHold: 'PutObjectLegalHold', diff --git a/lib/s3routes/routes.ts b/lib/s3routes/routes.ts index f3bcbd885..25455bbbd 100644 --- a/lib/s3routes/routes.ts +++ b/lib/s3routes/routes.ts @@ -149,7 +149,10 @@ export type Params = { object: string[]; }; unsupportedQueries: any; - api: { callApiMethod: routesUtils.CallApiMethod }; + api: { + callApiMethod: routesUtils.CallApiMethod, + callPostObject: routesUtils.CallApiMethod, + }; } /** routes - route request to appropriate method diff --git a/lib/s3routes/routes/routeDELETE.ts b/lib/s3routes/routes/routeDELETE.ts index cd647cafd..1cdcc5b9f 100644 --- a/lib/s3routes/routes/routeDELETE.ts +++ b/lib/s3routes/routes/routeDELETE.ts @@ -8,7 +8,7 @@ import * as http from 'http'; export default function routeDELETE( request: http.IncomingMessage, response: http.ServerResponse, - api: { callApiMethod: routesUtils.CallApiMethod }, + api: routesUtils.ApiMethods, log: RequestLogger, statsClient?: StatsClient, ) { diff --git a/lib/s3routes/routes/routeGET.ts b/lib/s3routes/routes/routeGET.ts index f642c7b34..e5f3f7f5c 100644 --- a/lib/s3routes/routes/routeGET.ts +++ b/lib/s3routes/routes/routeGET.ts @@ -8,7 +8,7 @@ import StatsClient from '../../metrics/StatsClient'; export default function routerGET( request: http.IncomingMessage, response: http.ServerResponse, - api: { callApiMethod: routesUtils.CallApiMethod }, + api: routesUtils.ApiMethods, log: RequestLogger, statsClient?: StatsClient, dataRetrievalParams?: any, diff --git a/lib/s3routes/routes/routeHEAD.ts b/lib/s3routes/routes/routeHEAD.ts index f79a5eba6..f5072eccd 100644 --- a/lib/s3routes/routes/routeHEAD.ts +++ b/lib/s3routes/routes/routeHEAD.ts @@ -8,7 +8,7 @@ import * as http from 'http'; export default function routeHEAD( request: http.IncomingMessage, response: http.ServerResponse, - api: { callApiMethod: routesUtils.CallApiMethod }, + api: routesUtils.ApiMethods, log: RequestLogger, statsClient?: StatsClient, ) { diff --git a/lib/s3routes/routes/routeOPTIONS.ts b/lib/s3routes/routes/routeOPTIONS.ts index 9e919d09b..b89ea170b 100644 --- a/lib/s3routes/routes/routeOPTIONS.ts +++ b/lib/s3routes/routes/routeOPTIONS.ts @@ -8,7 +8,7 @@ import StatsClient from '../../metrics/StatsClient'; export default function routeOPTIONS( request: http.IncomingMessage, response: http.ServerResponse, - api: { callApiMethod: routesUtils.CallApiMethod }, + api: routesUtils.ApiMethods, log: RequestLogger, statsClient?: StatsClient, ) { diff --git a/lib/s3routes/routes/routePOST.ts b/lib/s3routes/routes/routePOST.ts index 1854d4b97..cac109552 100644 --- a/lib/s3routes/routes/routePOST.ts +++ b/lib/s3routes/routes/routePOST.ts @@ -8,7 +8,7 @@ import * as http from 'http'; export default function routePOST( request: http.IncomingMessage, response: http.ServerResponse, - api: { callApiMethod: routesUtils.CallApiMethod }, + api: routesUtils.ApiMethods, log: RequestLogger, ) { log.debug('routing request', { method: 'routePOST' }); @@ -58,6 +58,20 @@ export default function routePOST( corsHeaders)); } + if (objectKey && Object.keys(query).length !== 0) { + return routesUtils.responseNoBody(errors.MethodNotAllowed, null, response, 405, log); + } + + if (objectKey === undefined) { + // Handle invalid query string parameters + if (Object.keys(query).length > 0) { + return routesUtils.responseNoBody(errors.InvalidArgument + .customizeDescription("Query String Parameters not allowed on POST requests."), null, response, 400, log); + } + return api.callPostObject('objectPost', request, response, log, (err, resHeaders) => + routesUtils.responseNoBody(err, resHeaders, response, 204, log)); + } + return routesUtils.responseNoBody(errors.NotImplemented, null, response, 200, log); } diff --git a/lib/s3routes/routes/routePUT.ts b/lib/s3routes/routes/routePUT.ts index 3d3d6a410..7139f28ae 100644 --- a/lib/s3routes/routes/routePUT.ts +++ b/lib/s3routes/routes/routePUT.ts @@ -8,7 +8,7 @@ import StatsClient from '../../metrics/StatsClient'; export default function routePUT( request: http.IncomingMessage, response: http.ServerResponse, - api: { callApiMethod: routesUtils.CallApiMethod }, + api: routesUtils.ApiMethods, log: RequestLogger, statsClient?: StatsClient, ) { diff --git a/lib/s3routes/routesUtils.ts b/lib/s3routes/routesUtils.ts index ab655e575..7ce196c60 100644 --- a/lib/s3routes/routesUtils.ts +++ b/lib/s3routes/routesUtils.ts @@ -10,6 +10,11 @@ import * as constants from '../constants'; import DataWrapper from '../storage/data/DataWrapper'; import StatsClient from '../metrics/StatsClient'; +export type ApiMethods = { + callApiMethod: CallApiMethod; + callPostObject: CallApiMethod; +}; + export type CallApiMethod = ( methodName: string, request: http.IncomingMessage, diff --git a/tests/unit/auth/v4/formAuthCheck.spec.js b/tests/unit/auth/v4/formAuthCheck.spec.js new file mode 100644 index 000000000..0ca3c50d7 --- /dev/null +++ b/tests/unit/auth/v4/formAuthCheck.spec.js @@ -0,0 +1,175 @@ +'use strict'; // eslint-disable-line strict + +const assert = require('assert'); +const fakeTimers = require('@sinonjs/fake-timers'); + +const errors = require('../../../../lib/errors').default; + +const createAlteredRequest = require('../../helpers').createAlteredRequest; +const formAuthCheck = require('../../../../lib/auth/v4/formAuthCheck').check; +const DummyRequestLogger = require('../../helpers').DummyRequestLogger; + +const log = new DummyRequestLogger(); + +const method = 'POST'; +const path = decodeURIComponent('/mybucket'); +const host = 'localhost:8000'; + +const formatDate = now => now.toISOString().replace(/[:-]|\.\d{3}/g, ''); + +const requestDate = new Date(Date.now()); + +function prepPolicy(data, expiration = new Date(requestDate.getTime() + 15 * 60 * 1000)) { + try { + // 15 minutes + const policy = { expiration: expiration.toISOString() }; + policy.conditions = Object.keys(data).map(key => ({ key: data[key] })); + // return base64 version of policy + return policy; + } catch (e) { + throw new Error('Policy is not a valid JSON'); + } +} + +const formData = { + 'x-amz-algorithm': 'AWS4-HMAC-SHA256', + 'x-amz-credential': `accessKey1/${formatDate(requestDate).split('T')[0]}/us-east-1/s3/aws4_request`, + 'x-amz-date': formatDate(requestDate), + 'x-amz-signature': '036c5d854aca98a003c1c155a' + + '7723157d8148ad5888b3aee1133784eb5aec08b', +}; +formData.policy = `${btoa(JSON.stringify(prepPolicy(formData)))}`; + +const headers = { + host, +}; +const request = { + method, + path, + headers, + formData, +}; + +describe('v4 formAuthCheck', () => { + let clock; + + afterEach(() => { + if (clock) { + clock.uninstall(); + } + }); + + it('should return error if algorithm param incorrect', done => { + const alteredRequest = createAlteredRequest({ + 'x-amz-algorithm': + 'AWS4-HMAC-SHA1', + }, 'formData', request, formData); + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.InvalidArgument); + done(); + }); + + it('should return error if x-amz-credential param is undefined', done => { + const alteredRequest = createAlteredRequest({ + 'x-amz-credential': + undefined, + }, 'formData', request, formData); + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.InvalidArgument); + done(); + }); + + it('should return error if credential param format incorrect', done => { + const alteredRequest = createAlteredRequest({ + 'x-amz-credential': + 'incorrectformat', + }, 'formData', request, formData); + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.InvalidArgument); + done(); + }); + + it('should return error if service set forth in ' + + 'credential param is not s3', done => { + const alteredRequest = createAlteredRequest({ + 'x-amz-credential': + `accessKey1/${formatDate(requestDate).split('T')[0]}/us-east-1/EC2/aws4_request`, + }, + 'formData', request, formData); + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.InvalidArgument); + done(); + }); + + it('should return error if requestType set forth in ' + + 'credential param is not aws4_request', done => { + const alteredRequest = createAlteredRequest({ + 'x-amz-credential': + `accessKey1/${formatDate(requestDate).split('T')[0]}/us-east-1/s3/aws2_request`, + }, + 'formData', request, formData); + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.InvalidArgument); + done(); + }); + + it('should return error if undefined x-amz-signature param', done => { + const alteredRequest = createAlteredRequest({ + 'x-amz-signature': + undefined, + }, 'formData', request, formData); + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.InvalidArgument); + done(); + }); + + it('should return error if undefined x-amz-date param', done => { + const alteredRequest = createAlteredRequest({ + 'x-amz-date': + undefined, + }, 'formData', request, formData); + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.InvalidArgument); + done(); + }); + + it('should return error if expiration param is too old', done => { + const expiredDate = new Date(Date.now() - 30 * 60 * 1000); + + // Update the expiration date in formData + const alteredFormData = Object.assign({}, formData, { + policy: `${btoa(JSON.stringify(prepPolicy(formData, expiredDate)))}`, + }); + + // Assuming alteredRequest is the request object that includes formData + const alteredRequest = Object.assign({}, request, { + formData: alteredFormData, + }); + + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.AccessDenied); + done(); + }); + + it('should return error if scope date from x-amz-credential param' + + 'does not match date from x-amz-date param', done => { + clock = fakeTimers.install({ now: 1454974984001 }); + const alteredRequest = createAlteredRequest({ + 'x-amz-credential': 'accessKey1/20160209/' + + 'us-east-1/s3/aws4_request', + }, 'formData', request, formData); + const res = formAuthCheck(alteredRequest, log, alteredRequest.formData); + assert.deepStrictEqual(res.err, errors.RequestTimeTooSkewed); + done(); + }); + + it('should successfully return v4 and no error', done => { + // Freezes time so date created within function will be Feb 8, 2016 + // (within 15 minutes of timestamp in request) + clock = fakeTimers.install({ now: 1454974984001 }); + const res = formAuthCheck(request, log, request.formData); + assert.deepStrictEqual(res.err, null); + assert.strictEqual(res.params.version, 4); + done(); + }); +});