diff --git a/keyserver/src/utils/validation-utils.js b/keyserver/src/utils/validation-utils.js index c5473e2b5..7d33818dd 100644 --- a/keyserver/src/utils/validation-utils.js +++ b/keyserver/src/utils/validation-utils.js @@ -1,245 +1,246 @@ // @flow import type { TType } from 'tcomb'; import type { PolicyType } from 'lib/facts/policies.js'; import { hasMinCodeVersion, hasMinStateVersion, } from 'lib/shared/version-utils.js'; import { type PlatformDetails } from 'lib/types/device-types.js'; import { convertClientIDsToServerIDs, convertObject, convertServerIDsToClientIDs, } from 'lib/utils/conversion-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'; import { thisKeyserverID } from '../user/identity.js'; async function validateInput( viewer: Viewer, inputValidator: TType, input: mixed, source: string, ): Promise { if (!viewer.isSocket) { await checkClientSupported(viewer, inputValidator, input); } const convertedInput = checkInputValidator(inputValidator, input, source); const keyserverID = await thisKeyserverID(); if ( hasMinStateVersion(viewer.platformDetails, { native: 43, web: 3, }) ) { try { return convertClientIDsToServerIDs( keyserverID, inputValidator, convertedInput, ); } catch (err) { throw new ServerError(err.message); } } return convertedInput; } async function validateOutput( platformDetails: ?PlatformDetails, outputValidator: TType, data: T, ): Promise { if (!outputValidator.is(data)) { console.trace( 'Output validation failed, validator is:', outputValidator.displayName, ); return data; } const keyserverID = await thisKeyserverID(); if ( hasMinStateVersion(platformDetails, { native: 43, web: 3, }) ) { return convertServerIDsToClientIDs(keyserverID, outputValidator, data); } return data; } function checkInputValidator( inputValidator: TType, input: mixed, source: string, ): T { if (inputValidator.is(input)) { return assertWithValidator(input, inputValidator); } const error = new ServerError('invalid_parameters'); try { error.sanitizedInput = input ? sanitizeInput(inputValidator, input) : null; } catch { error.sanitizedInput = null; } console.log(`failed input validation on ${source}`); 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, + { dontValidateInput: true }, ); } 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; } async function policiesValidator( viewer: Viewer, policies: $ReadOnlyArray, ) { if (!policies.length || !viewer.loggedIn) { return; } if (!hasMinCodeVersion(viewer.platformDetails, { native: 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, policiesValidator, }; diff --git a/keyserver/src/utils/validation-utils.test.js b/keyserver/src/utils/validation-utils.test.js index 5c0d46561..91db65b31 100644 --- a/keyserver/src/utils/validation-utils.test.js +++ b/keyserver/src/utils/validation-utils.test.js @@ -1,90 +1,128 @@ // @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: string }>({ 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: ?string }>({ 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?: ?{ +password: string } }>({ obj: t.maybe(tShape<{ +password: string }>({ 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: $ReadOnlyArray }>({ 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: { +[string]: string } }>({ 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: { +[string]: boolean } }>({ passwords: t.dict(tPassword, t.Bool), }); const object = { passwords: { password1: true, password2: false } }; const redacted: { +passwords: { [string]: mixed } } = { passwords: {} }; redacted.passwords[redactedString] = false; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); it('should redact a string inside a union', () => { const validator = tShape<{ +password: string | boolean, }>({ 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: $ReadOnlyArray<{ +password: string, }>, }>({ passwords: t.list(tShape({ password: tPassword })), }); const object = { passwords: [{ password: 'password' }] }; const redacted = { passwords: [{ password: redactedString }] }; expect(sanitizeInput(validator, object)).toStrictEqual(redacted); }); + + it('should redact a string inside an object even if it fails validation', () => { + const validator = tShape<{ +password: string, +blah: string }>({ + password: tPassword, + blah: t.String, + }); + const object = { password: 'password' }; + const redacted = { password: redactedString }; + expect(sanitizeInput(validator, object)).toStrictEqual(redacted); + }); + + it('should redact a string inside a nested object even if inner fails validation', () => { + const validator = tShape<{ + +nested: { +blah: string, +password: string }, + }>({ + nested: tShape<{ +blah: string, +password: string }>({ + blah: t.String, + password: tPassword, + }), + }); + const object = { nested: { password: 'password' } }; + const redacted = { nested: { password: redactedString } }; + expect(sanitizeInput(validator, object)).toStrictEqual(redacted); + }); + it('should redact a string inside a nested object even if outer fails validation', () => { + const validator = tShape<{ + +blah: string, + +nested: { +password: string }, + }>({ + blah: t.String, + nested: tShape<{ +password: string }>({ + password: tPassword, + }), + }); + const object = { nested: { password: 'password' } }; + const redacted = { nested: { password: redactedString } }; + expect(sanitizeInput(validator, object)).toStrictEqual(redacted); + }); }); diff --git a/lib/utils/conversion-utils.js b/lib/utils/conversion-utils.js index 368241729..67dbe510d 100644 --- a/lib/utils/conversion-utils.js +++ b/lib/utils/conversion-utils.js @@ -1,179 +1,198 @@ // @flow import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import type { TInterface, TType } from 'tcomb'; import { convertIDToNewSchema } from './migration-utils.js'; import { assertWithValidator, tID, tUserID } from './validation-utils.js'; import { getPendingThreadID, parsePendingThreadID, } from '../shared/thread-utils.js'; function convertServerIDsToClientIDs( serverPrefixID: string, outputValidator: TType, data: T, ): T { const conversionFunction = (id: string) => { if (id.indexOf('|') !== -1) { console.warn(`Server id '${id}' already has a prefix`); return id; } return convertIDToNewSchema(id, serverPrefixID); }; return convertObject(outputValidator, data, [tID], conversionFunction); } function convertClientIDsToServerIDs( serverPrefixID: string, outputValidator: TType, data: T, ): T { const prefix = serverPrefixID + '|'; const conversionFunction = (id: string) => { if (id.startsWith(prefix)) { return id.substr(prefix.length); } const pendingIDContents = parsePendingThreadID(id); if (!pendingIDContents) { throw new Error('invalid_client_id_prefix'); } if (!pendingIDContents.sourceMessageID) { return id; } return getPendingThreadID( pendingIDContents.threadType, pendingIDContents.memberIDs, pendingIDContents.sourceMessageID.substr(prefix.length), ); }; return convertObject(outputValidator, data, [tID], conversionFunction); } function extractUserIDsFromPayload( outputValidator: TType, data: T, ): $ReadOnlyArray { const result = new Set(); const conversionFunction = (id: string) => { result.add(id); return id; }; try { convertObject(outputValidator, data, [tUserID], conversionFunction); } catch {} return [...result]; } +type ConvertObjectOptions = { + +dontValidateInput?: ?boolean, +}; function convertObject( validator: TType, input: I, typesToConvert: $ReadOnlyArray>, conversionFunction: T => T, + options?: ?ConvertObjectOptions, ): I { if (input === null || input === undefined) { return input; } + const dontValidateInput = options?.dontValidateInput; // While they should be the same runtime object, - // `TValidator` is `TType` and `validator` is `TType`. + // `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); + 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, + options, ); } if (validator.meta.kind === 'interface' && typeof input === 'object') { const recastValidator: TInterface = (validator: any); const result: { [string]: mixed } = {}; for (const key in input) { const innerValidator = recastValidator.meta.props[key]; result[key] = convertObject( innerValidator, input[key], typesToConvert, conversionFunction, + options, ); } - return assertWithValidator(result, recastValidator); + if (dontValidateInput) { + return (result: any); + } else { + 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, + options, ); } } return input; } if (validator.meta.kind === 'list' && Array.isArray(input)) { const innerValidator = validator.meta.type; return (input.map(value => - convertObject(innerValidator, value, typesToConvert, conversionFunction), + convertObject( + innerValidator, + value, + typesToConvert, + conversionFunction, + options, + ), ): 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, + options, ), )(input); } return input; } // NOTE: This function should not be called from native. On native, we should // use `convertObjToBytes` in native/backup/conversion-utils.js instead. function convertObjToBytes(obj: T): Uint8Array { const objStr = JSON.stringify(obj); return new TextEncoder().encode(objStr ?? ''); } // NOTE: This function should not be called from native. On native, we should // use `convertBytesToObj` in native/backup/conversion-utils.js instead. function convertBytesToObj(bytes: Uint8Array): T { const str = new TextDecoder().decode(bytes.buffer); return JSON.parse(str); } export { convertClientIDsToServerIDs, convertServerIDsToClientIDs, extractUserIDsFromPayload, convertObject, convertObjToBytes, convertBytesToObj, };