diff --git a/keyserver/src/utils/validation-utils.js b/keyserver/src/utils/validation-utils.js index 17fc52650..af10142e8 100644 --- a/keyserver/src/utils/validation-utils.js +++ b/keyserver/src/utils/validation-utils.js @@ -1,346 +1,232 @@ // @flow -import _mapKeys from 'lodash/fp/mapKeys.js'; -import _mapValues from 'lodash/fp/mapValues.js'; -import type { TType, TInterface } from 'tcomb'; +import type { TType } from 'tcomb'; import type { PolicyType } from 'lib/facts/policies.js'; import { hasMinCodeVersion, FUTURE_CODE_VERSION, } from 'lib/shared/version-utils.js'; import { type PlatformDetails, isWebPlatform } from 'lib/types/device-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { tCookie, tPassword, tPlatform, tPlatformDetails, assertWithValidator, - tID, + convertToNewIDSchema, + keyserverPrefixID, + convertClientIDsToServerIDs, + convertObject, + convertServerIDsToClientIDs, } from 'lib/utils/validation-utils.js'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { verifyClientSupported } from '../session/version.js'; import type { Viewer } from '../session/viewer.js'; -const convertToNewIDSchema = false; -const keyserverPrefixID = '256'; - async function validateInput( viewer: Viewer, inputValidator: TType, input: mixed, ): Promise { if (!viewer.isSocket) { await checkClientSupported(viewer, inputValidator, input); } const convertedInput = checkInputValidator(inputValidator, input); if ( hasMinCodeVersion(viewer.platformDetails, FUTURE_CODE_VERSION) && !isWebPlatform(viewer.platformDetails?.platform) && convertToNewIDSchema ) { - return convertClientIDsToServerIDs( - keyserverPrefixID, - inputValidator, - convertedInput, - ); + try { + return convertClientIDsToServerIDs( + keyserverPrefixID, + inputValidator, + convertedInput, + ); + } catch (err) { + throw new ServerError(err.message); + } } return convertedInput; } function validateOutput( platformDetails: ?PlatformDetails, outputValidator: TType, data: T, ): T { if (!outputValidator.is(data)) { console.trace( 'Output validation failed, validator is:', outputValidator.displayName, ); return data; } if ( hasMinCodeVersion(platformDetails, FUTURE_CODE_VERSION) && !isWebPlatform(platformDetails?.platform) && convertToNewIDSchema ) { return convertServerIDsToClientIDs( keyserverPrefixID, outputValidator, data, ); } return data; } -function convertServerIDsToClientIDs( - serverPrefixID: string, - outputValidator: TType, - data: T, -): T { - const conversionFunction = id => { - if (id.indexOf('|') !== -1) { - console.warn(`Server id '${id}' already has a prefix`); - return id; - } - return `${serverPrefixID}|${id}`; - }; - - return convertObject(outputValidator, data, [tID], conversionFunction); -} - -function convertClientIDsToServerIDs( - serverPrefixID: string, - outputValidator: TType, - data: T, -): T { - const prefix = serverPrefixID + '|'; - const conversionFunction = id => { - if (id.startsWith(prefix)) { - return id.substr(prefix.length); - } - - throw new ServerError('invalid_client_id_prefix'); - }; - - return convertObject(outputValidator, data, [tID], conversionFunction); -} - function checkInputValidator(inputValidator: TType, input: mixed): T { if (inputValidator.is(input)) { return assertWithValidator(input, inputValidator); } const error = new ServerError('invalid_parameters'); error.sanitizedInput = input ? sanitizeInput(inputValidator, input) : null; throw error; } async function checkClientSupported( viewer: Viewer, inputValidator: ?TType, input: T, ) { let platformDetails; if (inputValidator) { platformDetails = findFirstInputMatchingValidator( inputValidator, tPlatformDetails, input, ); } if (!platformDetails && inputValidator) { const platform = findFirstInputMatchingValidator( inputValidator, tPlatform, input, ); if (platform) { platformDetails = { platform }; } } if (!platformDetails) { ({ platformDetails } = viewer); } await verifyClientSupported(viewer, platformDetails); } const redactedString = '********'; const redactedTypes = [tPassword, tCookie]; function sanitizeInput(inputValidator: TType, input: T): T { return convertObject( inputValidator, input, redactedTypes, () => redactedString, ); } function findFirstInputMatchingValidator( wholeInputValidator: *, inputValidatorToMatch: *, input: *, ): any { if (!wholeInputValidator || input === null || input === undefined) { return null; } if ( wholeInputValidator === inputValidatorToMatch && wholeInputValidator.is(input) ) { return input; } if (wholeInputValidator.meta.kind === 'maybe') { return findFirstInputMatchingValidator( wholeInputValidator.meta.type, inputValidatorToMatch, input, ); } if ( wholeInputValidator.meta.kind === 'interface' && typeof input === 'object' ) { for (const key in input) { const value = input[key]; const validator = wholeInputValidator.meta.props[key]; const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } if (wholeInputValidator.meta.kind === 'union') { for (const validator of wholeInputValidator.meta.types) { if (validator.is(input)) { return findFirstInputMatchingValidator( validator, inputValidatorToMatch, input, ); } } } if (wholeInputValidator.meta.kind === 'list' && Array.isArray(input)) { const validator = wholeInputValidator.meta.type; for (const value of input) { const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } return null; } -function convertObject( - validator: TType, - input: I, - typesToConvert: $ReadOnlyArray>, - conversionFunction: T => T, -): I { - if (input === null || input === undefined) { - return input; - } - - // While they should be the same runtime object, - // `TValidator` is `TType` and `validator` is `TType`. - // Having them have different types allows us to use `assertWithValidator` - // to change `input` flow type - const TValidator = typesToConvert[typesToConvert.indexOf(validator)]; - if (TValidator && TValidator.is(input)) { - const TInput = assertWithValidator(input, TValidator); - const converted = conversionFunction(TInput); - return assertWithValidator(converted, validator); - } - - if (validator.meta.kind === 'maybe' || validator.meta.kind === 'subtype') { - return convertObject( - validator.meta.type, - input, - typesToConvert, - conversionFunction, - ); - } - if (validator.meta.kind === 'interface' && typeof input === 'object') { - const recastValidator: TInterface = (validator: any); - const result = {}; - for (const key in input) { - const innerValidator = recastValidator.meta.props[key]; - result[key] = convertObject( - innerValidator, - input[key], - typesToConvert, - conversionFunction, - ); - } - return assertWithValidator(result, recastValidator); - } - if (validator.meta.kind === 'union') { - for (const innerValidator of validator.meta.types) { - if (innerValidator.is(input)) { - return convertObject( - innerValidator, - input, - typesToConvert, - conversionFunction, - ); - } - } - return input; - } - if (validator.meta.kind === 'list' && Array.isArray(input)) { - const innerValidator = validator.meta.type; - return (input.map(value => - convertObject(innerValidator, value, typesToConvert, conversionFunction), - ): any); - } - if (validator.meta.kind === 'dict' && typeof input === 'object') { - const domainValidator = validator.meta.domain; - const codomainValidator = validator.meta.codomain; - if (typesToConvert.includes(domainValidator)) { - input = _mapKeys(key => conversionFunction(key))(input); - } - return _mapValues(value => - convertObject( - codomainValidator, - value, - typesToConvert, - conversionFunction, - ), - )(input); - } - - return input; -} - async function policiesValidator( viewer: Viewer, policies: $ReadOnlyArray, ) { if (!policies.length) { return; } if (!hasMinCodeVersion(viewer.platformDetails, 181)) { return; } const notAcknowledgedPolicies = await fetchNotAcknowledgedPolicies( viewer.id, policies, ); if (notAcknowledgedPolicies.length) { throw new ServerError('policies_not_accepted', { notAcknowledgedPolicies, }); } } export { validateInput, validateOutput, checkInputValidator, redactedString, sanitizeInput, findFirstInputMatchingValidator, checkClientSupported, - convertServerIDsToClientIDs, - convertClientIDsToServerIDs, - convertObject, policiesValidator, }; diff --git a/keyserver/src/utils/validation-utils.test.js b/keyserver/src/utils/validation-utils.test.js index 4b50b7595..244e5c6e2 100644 --- a/keyserver/src/utils/validation-utils.test.js +++ b/keyserver/src/utils/validation-utils.test.js @@ -1,122 +1,74 @@ // @flow import t from 'tcomb'; -import { tPassword, tShape, tID } from 'lib/utils/validation-utils.js'; +import { tPassword, tShape } from 'lib/utils/validation-utils.js'; -import { - convertServerIDsToClientIDs, - sanitizeInput, - redactedString, - convertClientIDsToServerIDs, -} from './validation-utils.js'; +import { sanitizeInput, redactedString } from './validation-utils.js'; describe('sanitization', () => { it('should redact a string', () => { expect(sanitizeInput(tPassword, 'password')).toStrictEqual(redactedString); }); it('should redact a string inside an object', () => { const validator = tShape({ password: tPassword }); const object = { password: 'password' }; const redacted = { password: redactedString }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact an optional string', () => { const validator = tShape({ password: t.maybe(tPassword) }); const object = { password: 'password' }; const redacted = { password: redactedString }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string in optional object', () => { const validator = tShape({ obj: t.maybe(tShape({ password: tPassword })) }); const object = { obj: { password: 'password' } }; const redacted = { obj: { password: redactedString } }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string array', () => { const validator = tShape({ passwords: t.list(tPassword) }); const object = { passwords: ['password', 'password'] }; const redacted = { passwords: [redactedString, redactedString] }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string inside a dict', () => { const validator = tShape({ passwords: t.dict(t.String, tPassword) }); const object = { passwords: { a: 'password', b: 'password' } }; const redacted = { passwords: { a: redactedString, b: redactedString } }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact password dict key', () => { const validator = tShape({ passwords: t.dict(tPassword, t.Bool) }); const object = { passwords: { password1: true, password2: false } }; const redacted = { passwords: {} }; redacted.passwords[redactedString] = false; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string inside a union', () => { const validator = tShape({ password: t.union([tPassword, t.String, t.Bool]), }); const object = { password: 'password' }; const redacted = { password: redactedString }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string inside an object array', () => { const validator = tShape({ passwords: t.list(tShape({ password: tPassword })), }); const object = { passwords: [{ password: 'password' }] }; const redacted = { passwords: [{ password: redactedString }] }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); }); - -describe('id conversion', () => { - it('should convert string id', () => { - const validator = tShape({ id: tID }); - const serverData = { id: '1' }; - const clientData = { id: '0|1' }; - - expect( - convertServerIDsToClientIDs('0', validator, serverData), - ).toStrictEqual(clientData); - expect( - convertClientIDsToServerIDs('0', validator, clientData), - ).toStrictEqual(serverData); - }); - - it('should convert a complex type', () => { - const validator = tShape({ ids: t.dict(tID, t.list(tID)) }); - const serverData = { ids: { '1': ['11', '12'], '2': [], '3': ['13'] } }; - const clientData = { - ids: { '0|1': ['0|11', '0|12'], '0|2': [], '0|3': ['0|13'] }, - }; - - expect( - convertServerIDsToClientIDs('0', validator, serverData), - ).toStrictEqual(clientData); - expect( - convertClientIDsToServerIDs('0', validator, clientData), - ).toStrictEqual(serverData); - }); - - it('should convert a refinement', () => { - const validator = t.refinement(tID, () => true); - const serverData = '1'; - const clientData = '0|1'; - - expect( - convertServerIDsToClientIDs('0', validator, serverData), - ).toStrictEqual(clientData); - expect( - convertClientIDsToServerIDs('0', validator, clientData), - ).toStrictEqual(serverData); - }); -}); diff --git a/lib/utils/validation-utils.js b/lib/utils/validation-utils.js index 0f1dfc9cf..f99a40531 100644 --- a/lib/utils/validation-utils.js +++ b/lib/utils/validation-utils.js @@ -1,126 +1,250 @@ // @flow import invariant from 'invariant'; +import _mapKeys from 'lodash/fp/mapKeys.js'; +import _mapValues from 'lodash/fp/mapValues.js'; import t from 'tcomb'; import type { TStructProps, TIrreducible, TRefinement, TEnums, TInterface, TUnion, TType, } from 'tcomb'; import { validEmailRegex, oldValidUsernameRegex, validHexColorRegex, } from '../shared/account-utils.js'; import type { PlatformDetails } from '../types/device-types'; import type { MediaMessageServerDBContent, PhotoMessageServerDBContent, VideoMessageServerDBContent, } from '../types/messages/media'; function tBool(value: boolean): TIrreducible { return t.irreducible('literal bool', x => x === value); } function tString(value: string): TIrreducible { return t.irreducible('literal string', x => x === value); } function tNumber(value: number): TIrreducible { return t.irreducible('literal number', x => x === value); } function tShape(spec: TStructProps): TInterface { return t.interface(spec, { strict: true }); } type TRegex = TRefinement; function tRegex(regex: RegExp): TRegex { return t.refinement(t.String, val => regex.test(val)); } function tNumEnum(nums: $ReadOnlyArray): TRefinement { return t.refinement(t.Number, (input: number) => { for (const num of nums) { if (input === num) { return true; } } return false; }); } const tNull: TIrreducible = t.irreducible('null', x => x === null); const tDate: TRegex = tRegex(/^[0-9]{4}-[0-1][0-9]-[0-3][0-9]$/); const tColor: TRegex = tRegex(validHexColorRegex); // we don't include # char const tPlatform: TEnums = t.enums.of([ 'ios', 'android', 'web', 'windows', 'macos', ]); const tDeviceType: TEnums = t.enums.of(['ios', 'android']); const tPlatformDetails: TInterface = tShape({ platform: tPlatform, codeVersion: t.maybe(t.Number), stateVersion: t.maybe(t.Number), }); const tPassword: TRefinement = t.refinement( t.String, (password: string) => !!password, ); const tCookie: TRegex = tRegex(/^(user|anonymous)=[0-9]+:[0-9a-f]+$/); const tEmail: TRegex = tRegex(validEmailRegex); const tOldValidUsername: TRegex = tRegex(oldValidUsernameRegex); const tID: TRefinement = t.refinement(t.String, (id: string) => !!id); const tMediaMessagePhoto: TInterface = tShape({ type: tString('photo'), uploadID: tID, }); const tMediaMessageVideo: TInterface = tShape({ type: tString('video'), uploadID: tID, thumbnailUploadID: tID, }); const tMediaMessageMedia: TUnion = t.union([ tMediaMessagePhoto, tMediaMessageVideo, ]); function assertWithValidator(data: mixed, validator: TType): T { invariant(validator.is(data), "data isn't of type T"); return (data: any); } +const convertToNewIDSchema = false; +const keyserverPrefixID = '256'; + +function convertServerIDsToClientIDs( + serverPrefixID: string, + outputValidator: TType, + data: T, +): T { + const conversionFunction = id => { + if (id.indexOf('|') !== -1) { + console.warn(`Server id '${id}' already has a prefix`); + return id; + } + return `${serverPrefixID}|${id}`; + }; + + return convertObject(outputValidator, data, [tID], conversionFunction); +} + +function convertClientIDsToServerIDs( + serverPrefixID: string, + outputValidator: TType, + data: T, +): T { + const prefix = serverPrefixID + '|'; + const conversionFunction = id => { + if (id.startsWith(prefix)) { + return id.substr(prefix.length); + } + + throw new Error('invalid_client_id_prefix'); + }; + + return convertObject(outputValidator, data, [tID], conversionFunction); +} + +function convertObject( + validator: TType, + input: I, + typesToConvert: $ReadOnlyArray>, + conversionFunction: T => T, +): I { + if (input === null || input === undefined) { + return input; + } + + // While they should be the same runtime object, + // `TValidator` is `TType` and `validator` is `TType`. + // Having them have different types allows us to use `assertWithValidator` + // to change `input` flow type + const TValidator = typesToConvert[typesToConvert.indexOf(validator)]; + if (TValidator && TValidator.is(input)) { + const TInput = assertWithValidator(input, TValidator); + const converted = conversionFunction(TInput); + return assertWithValidator(converted, validator); + } + + if (validator.meta.kind === 'maybe' || validator.meta.kind === 'subtype') { + return convertObject( + validator.meta.type, + input, + typesToConvert, + conversionFunction, + ); + } + if (validator.meta.kind === 'interface' && typeof input === 'object') { + const recastValidator: TInterface = (validator: any); + const result = {}; + for (const key in input) { + const innerValidator = recastValidator.meta.props[key]; + result[key] = convertObject( + innerValidator, + input[key], + typesToConvert, + conversionFunction, + ); + } + return assertWithValidator(result, recastValidator); + } + if (validator.meta.kind === 'union') { + for (const innerValidator of validator.meta.types) { + if (innerValidator.is(input)) { + return convertObject( + innerValidator, + input, + typesToConvert, + conversionFunction, + ); + } + } + return input; + } + if (validator.meta.kind === 'list' && Array.isArray(input)) { + const innerValidator = validator.meta.type; + return (input.map(value => + convertObject(innerValidator, value, typesToConvert, conversionFunction), + ): any); + } + if (validator.meta.kind === 'dict' && typeof input === 'object') { + const domainValidator = validator.meta.domain; + const codomainValidator = validator.meta.codomain; + if (typesToConvert.includes(domainValidator)) { + input = _mapKeys(key => conversionFunction(key))(input); + } + return _mapValues(value => + convertObject( + codomainValidator, + value, + typesToConvert, + conversionFunction, + ), + )(input); + } + + return input; +} + export { tBool, tString, tNumber, tShape, tRegex, tNumEnum, tNull, tDate, tColor, tPlatform, tDeviceType, tPlatformDetails, tPassword, tCookie, tEmail, tOldValidUsername, tID, tMediaMessagePhoto, tMediaMessageVideo, tMediaMessageMedia, assertWithValidator, + convertToNewIDSchema, + keyserverPrefixID, + convertClientIDsToServerIDs, + convertServerIDsToClientIDs, + convertObject, }; diff --git a/lib/utils/validation-utils.test.js b/lib/utils/validation-utils.test.js index 963c9b133..d87526f89 100644 --- a/lib/utils/validation-utils.test.js +++ b/lib/utils/validation-utils.test.js @@ -1,142 +1,191 @@ // @flow +import t from 'tcomb'; + import { tMediaMessagePhoto, tMediaMessageVideo, tNumEnum, + tShape, + tID, + convertServerIDsToClientIDs, + convertClientIDsToServerIDs, } from './validation-utils.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { values } from '../utils/objects.js'; describe('Validation utils', () => { describe('tNumEnum validator', () => { it('Should discard when accepted set is empty', () => { expect(tNumEnum([]).is(1)).toBe(false); }); it('Should accept when array contains number', () => { expect(tNumEnum([1, 2, 3]).is(2)).toBe(true); }); it('Should discard when array does not contain number', () => { expect(tNumEnum([1, 2, 3]).is(4)).toBe(false); }); it('Should accept when value is a part of enum', () => { expect(tNumEnum(values(threadTypes)).is(threadTypes.SIDEBAR)).toBe(true); }); it('Should discard when value is not a part of enum', () => { expect(tNumEnum(values(threadTypes)).is(123)).toBe(false); }); }); describe('tMediaMessagePhoto validator', () => { it('Should succeed when valid MediaMessagePhoto', () => { expect(tMediaMessagePhoto.is({ type: 'photo', uploadID: '12345' })).toBe( true, ); }); it('Should fail when missing uploadID', () => { expect(tMediaMessagePhoto.is({ type: 'photo' })).toBe(false); }); it('Should fail when uploadID is not a string', () => { expect(tMediaMessagePhoto.is({ type: 'photo', uploadID: 12345 })).toBe( false, ); }); it('Should fail when type is not photo', () => { expect(tMediaMessagePhoto.is({ type: 'blah', uploadID: '12345' })).toBe( false, ); }); it('Should fail when type is video', () => { expect(tMediaMessagePhoto.is({ type: 'video', uploadID: '12345' })).toBe( false, ); }); }); describe('tMediaMessageVideo validator', () => { it('Should succeed when valid tMediaMessageVideo', () => { expect( tMediaMessageVideo.is({ type: 'video', uploadID: '12345', thumbnailUploadID: '7890', }), ).toBe(true); }); it('Should fail when missing thumbnailUploadID', () => { expect( tMediaMessageVideo.is({ type: 'video', uploadID: '12345', }), ).toBe(false); }); it('Should fail when uploadID is not a string', () => { expect( tMediaMessageVideo.is({ type: 'video', uploadID: 12345, thumbnailUploadID: '7890', }), ).toBe(false); }); it('Should fail when type is not video', () => { expect( tMediaMessageVideo.is({ type: 'blah', uploadID: '12345', thumbnailUploadID: '7890', }), ).toBe(false); }); it('Should fail when type is photo', () => { expect( tMediaMessageVideo.is({ type: 'photo', uploadID: '12345', thumbnailUploadID: '7890', }), ).toBe(false); }); }); describe('tMediaMessageMedia validator', () => { it('Should succeed when valid MediaMessagePhoto', () => { expect(tMediaMessagePhoto.is({ type: 'photo', uploadID: '12345' })).toBe( true, ); }); it('Should succeed when valid tMediaMessageVideo', () => { expect( tMediaMessageVideo.is({ type: 'video', uploadID: '12345', thumbnailUploadID: '7890', }), ).toBe(true); }); it('Should fail when not valid MediaMessagePhoto or tMediaMessageVideo', () => { expect( tMediaMessageVideo.is({ type: 'audio', uploadID: '1000', thumbnailUploadID: '1000', }), ).toBe(false); }); }); }); + +describe('id conversion', () => { + it('should convert string id', () => { + const validator = tShape({ id: tID }); + const serverData = { id: '1' }; + const clientData = { id: '0|1' }; + + expect( + convertServerIDsToClientIDs('0', validator, serverData), + ).toStrictEqual(clientData); + expect( + convertClientIDsToServerIDs('0', validator, clientData), + ).toStrictEqual(serverData); + }); + + it('should convert a complex type', () => { + const validator = tShape({ ids: t.dict(tID, t.list(tID)) }); + const serverData = { ids: { '1': ['11', '12'], '2': [], '3': ['13'] } }; + const clientData = { + ids: { '0|1': ['0|11', '0|12'], '0|2': [], '0|3': ['0|13'] }, + }; + + expect( + convertServerIDsToClientIDs('0', validator, serverData), + ).toStrictEqual(clientData); + expect( + convertClientIDsToServerIDs('0', validator, clientData), + ).toStrictEqual(serverData); + }); + + it('should convert a refinement', () => { + const validator = t.refinement(tID, () => true); + const serverData = '1'; + const clientData = '0|1'; + + expect( + convertServerIDsToClientIDs('0', validator, serverData), + ).toStrictEqual(clientData); + expect( + convertClientIDsToServerIDs('0', validator, clientData), + ).toStrictEqual(serverData); + }); +});