diff --git a/keyserver/src/utils/validation-utils.js b/keyserver/src/utils/validation-utils.js index a386201b7..df33ac7a4 100644 --- a/keyserver/src/utils/validation-utils.js +++ b/keyserver/src/utils/validation-utils.js @@ -1,190 +1,260 @@ // @flow +import _mapKeys from 'lodash/fp/mapKeys.js'; +import _mapValues from 'lodash/fp/mapValues.js'; +import type { TType, TInterface } from 'tcomb'; + import type { PolicyType } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { tCookie, tPassword, tPlatform, tPlatformDetails, + assertWithValidator, } 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'; -async function validateInput(viewer: Viewer, inputValidator: *, input: *) { +async function validateInput( + viewer: Viewer, + inputValidator: ?TType, + input: T, +) { if (!viewer.isSocket) { await checkClientSupported(viewer, inputValidator, input); } checkInputValidator(inputValidator, input); } -function checkInputValidator(inputValidator: *, input: *) { +function checkInputValidator(inputValidator: ?TType, input: T) { if (!inputValidator || inputValidator.is(input)) { return; } const error = new ServerError('invalid_parameters'); error.sanitizedInput = input ? sanitizeInput(inputValidator, input) : null; throw error; } -async function checkClientSupported( +async function checkClientSupported( viewer: Viewer, - inputValidator: *, - input: *, + 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: any, input: any): any { - if (!inputValidator) { - return input; - } - if (redactedTypes.includes(inputValidator) && typeof input === 'string') { - return redactedString; - } - if ( - inputValidator.meta.kind === 'maybe' && - redactedTypes.includes(inputValidator.meta.type) && - typeof input === 'string' - ) { - return redactedString; - } - if ( - inputValidator.meta.kind !== 'interface' || - typeof input !== 'object' || - !input - ) { - return input; - } - const result = {}; - for (const key in input) { - const value = input[key]; - const validator = inputValidator.meta.props[key]; - result[key] = sanitizeInput(validator, value); - } - return result; +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') { + 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, checkInputValidator, redactedString, sanitizeInput, findFirstInputMatchingValidator, checkClientSupported, + convertObject, policiesValidator, }; diff --git a/keyserver/src/utils/validation-utils.test.js b/keyserver/src/utils/validation-utils.test.js index 2563342bb..244e5c6e2 100644 --- a/keyserver/src/utils/validation-utils.test.js +++ b/keyserver/src/utils/validation-utils.test.js @@ -1,27 +1,74 @@ // @flow import t from 'tcomb'; import { tPassword, tShape } from 'lib/utils/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); + }); }); diff --git a/lib/utils/validation-utils.js b/lib/utils/validation-utils.js index d0107108b..a9d8e5300 100644 --- a/lib/utils/validation-utils.js +++ b/lib/utils/validation-utils.js @@ -1,117 +1,125 @@ // @flow +import invariant from 'invariant'; 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 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: t.String, }); const tMediaMessageVideo: TInterface = tShape({ type: tString('video'), uploadID: t.String, thumbnailUploadID: t.String, }); 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); +} + export { tBool, tString, tNumber, tShape, tRegex, tNumEnum, tDate, tColor, tPlatform, tDeviceType, tPlatformDetails, tPassword, tCookie, tEmail, tOldValidUsername, tID, tMediaMessagePhoto, tMediaMessageVideo, tMediaMessageMedia, + assertWithValidator, };