diff --git a/src/CryptoUtils.js b/src/CryptoUtils.js new file mode 100644 index 000000000..225360b74 --- /dev/null +++ b/src/CryptoUtils.js @@ -0,0 +1,21 @@ +/** + * Helper function that turns a string into a unique 53-bit hash. + * @ref https://stackoverflow.com/a/52171480/6456163 + * @param {string} str + * @param {number} seed + * @returns {number} + */ +export const cyrb53 = (str, seed = 0) => { + let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; diff --git a/src/ParseOp.js b/src/ParseOp.js index ed00441da..cf7249f60 100644 --- a/src/ParseOp.js +++ b/src/ParseOp.js @@ -78,7 +78,7 @@ export class SetOp extends Op { } toJSON(offline?: boolean) { - return encode(this._value, false, true, undefined, offline); + return encode(this._value, false, true, undefined, offline, 0); } } diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index 06a7f3c2f..116a0ac59 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -153,6 +153,7 @@ const ParseObject = require('../ParseObject').default; const ParseOp = require('../ParseOp'); const RESTController = require('../RESTController'); const SingleInstanceStateController = require('../SingleInstanceStateController'); +const encode = require('../encode').default; const unsavedChildren = require('../unsavedChildren').default; const mockXHR = require('./test_helpers/mockXHR'); @@ -3863,4 +3864,19 @@ describe('ParseObject pin', () => { }); CoreManager.set('ALLOW_CUSTOM_OBJECT_ID', false); }); + + it('handles unsaved circular references', async () => { + const a = {}; + const b = {}; + a.b = b; + b.a = a; + + const object = new ParseObject('Test'); + object.set('a', a); + expect(() => { + object.save(); + }).toThrowError( + 'Traversing object failed due to high number of recursive calls, likely caused by circular reference within object.' + ); + }); }); diff --git a/src/__tests__/encode-test.js b/src/__tests__/encode-test.js index ff9244c59..35ad2d650 100644 --- a/src/__tests__/encode-test.js +++ b/src/__tests__/encode-test.js @@ -183,4 +183,13 @@ describe('encode', () => { str: 'abc', }); }); + + it('handles circular references', () => { + const circularObject = {}; + circularObject.circularReference = circularObject; + + expect(() => { + encode(circularObject, false, false, [], false); + }).not.toThrow(); + }); }); diff --git a/src/encode.js b/src/encode.js index 0214990f5..d63db6c9a 100644 --- a/src/encode.js +++ b/src/encode.js @@ -9,35 +9,48 @@ import ParsePolygon from './ParsePolygon'; import ParseObject from './ParseObject'; import { Op } from './ParseOp'; import ParseRelation from './ParseRelation'; +import { cyrb53 } from './CryptoUtils'; + +const MAX_RECURSIVE_CALLS = 999; function encode( value: mixed, disallowObjects: boolean, forcePointers: boolean, seen: Array, - offline: boolean + offline: boolean, + counter: number, + initialValue: mixed ): any { + counter++; + + if (counter > MAX_RECURSIVE_CALLS) { + const message = 'Encoding object failed due to high number of recursive calls, likely caused by circular reference within object.'; + console.error(message); + console.error('Value causing potential infinite recursion:', initialValue); + + throw new Error(message); + } + if (value instanceof ParseObject) { if (disallowObjects) { throw new Error('Parse Objects not allowed here'); } - const seenEntry = value.id ? value.className + ':' + value.id : value; + const entryIdentifier = value.id ? value.className + ':' + value.id : value; if ( forcePointers || - !seen || - seen.indexOf(seenEntry) > -1 || + seen.includes(entryIdentifier) || value.dirty() || - Object.keys(value._getServerData()).length < 1 + Object.keys(value._getServerData()).length === 0 ) { if (offline && value._getId().startsWith('local')) { return value.toOfflinePointer(); } return value.toPointer(); } - seen = seen.concat(seenEntry); + seen.push(entryIdentifier); return value._toFullJSON(seen, offline); - } - if ( + } else if ( value instanceof Op || value instanceof ParseACL || value instanceof ParseGeoPoint || @@ -45,41 +58,58 @@ function encode( value instanceof ParseRelation ) { return value.toJSON(); - } - if (value instanceof ParseFile) { + } else if (value instanceof ParseFile) { if (!value.url()) { throw new Error('Tried to encode an unsaved file.'); } return value.toJSON(); - } - if (Object.prototype.toString.call(value) === '[object Date]') { + } else if (Object.prototype.toString.call(value) === '[object Date]') { if (isNaN(value)) { throw new Error('Tried to encode an invalid date.'); } return { __type: 'Date', iso: (value: any).toJSON() }; - } - if ( + } else if ( Object.prototype.toString.call(value) === '[object RegExp]' && typeof value.source === 'string' ) { return value.source; - } - - if (Array.isArray(value)) { - return value.map(v => { - return encode(v, disallowObjects, forcePointers, seen, offline); - }); - } - - if (value && typeof value === 'object') { + } else if (Array.isArray(value)) { + return value.map(v => encode(v, disallowObjects, forcePointers, seen, offline, counter, initialValue)); + } else if (value && typeof value === 'object') { const output = {}; for (const k in value) { - output[k] = encode(value[k], disallowObjects, forcePointers, seen, offline); + try { + // Attempts to get the name of the object's constructor + // Ref: https://stackoverflow.com/a/332429/6456163 + const name = value[k].name || value[k].constructor.name; + if (name && name != "undefined") { + if (seen.includes(name)) { + output[k] = value[k]; + continue; + } else { + seen.push(name); + } + } + } catch (e) { + // Support anonymous functions by hashing the function body, + // preventing infinite recursion in the case of circular references + if (value[k] instanceof Function) { + const funcString = value[k].toString(); + if (seen.includes(funcString)) { + output[k] = value[k]; + continue; + } else { + const hash = cyrb53(funcString); + seen.push(hash); + } + } + } + output[k] = encode(value[k], disallowObjects, forcePointers, seen, offline, counter, initialValue); } return output; + } else { + return value; } - - return value; } export default function ( @@ -87,7 +117,9 @@ export default function ( disallowObjects?: boolean, forcePointers?: boolean, seen?: Array, - offline?: boolean + offline?: boolean, + counter?: number, + initialValue?: mixed ): any { - return encode(value, !!disallowObjects, !!forcePointers, seen || [], offline); + return encode(value, !!disallowObjects, !!forcePointers, seen || [], !!offline, counter || 0, initialValue || value); } diff --git a/src/unsavedChildren.js b/src/unsavedChildren.js index 088bd6084..feb9d04b9 100644 --- a/src/unsavedChildren.js +++ b/src/unsavedChildren.js @@ -6,6 +6,8 @@ import ParseFile from './ParseFile'; import ParseObject from './ParseObject'; import ParseRelation from './ParseRelation'; +const MAX_RECURSIVE_CALLS = 999; + type EncounterMap = { objects: { [identifier: string]: ParseObject | boolean }, files: Array, @@ -48,8 +50,20 @@ function traverse( obj: ParseObject, encountered: EncounterMap, shouldThrow: boolean, - allowDeepUnsaved: boolean + allowDeepUnsaved: boolean, + counter: number = 0 ) { + counter++; + + if (counter > MAX_RECURSIVE_CALLS) { + const message = 'Traversing object failed due to high number of recursive calls, likely caused by circular reference within object.'; + console.error(message); + console.error('Encountered objects:', encountered); + console.error('Object causing potential infinite recursion:', obj); + + throw new Error(message); + } + if (obj instanceof ParseObject) { if (!obj.id && shouldThrow) { throw new Error('Cannot create a pointer to an unsaved Object.'); @@ -60,7 +74,7 @@ function traverse( const attributes = obj.attributes; for (const attr in attributes) { if (typeof attributes[attr] === 'object') { - traverse(attributes[attr], encountered, !allowDeepUnsaved, allowDeepUnsaved); + traverse(attributes[attr], encountered, !allowDeepUnsaved, allowDeepUnsaved, counter); } } } @@ -75,16 +89,11 @@ function traverse( if (obj instanceof ParseRelation) { return; } - if (Array.isArray(obj)) { - obj.forEach(el => { - if (typeof el === 'object') { - traverse(el, encountered, shouldThrow, allowDeepUnsaved); + if (Array.isArray(obj) || typeof obj === 'object') { + for (const k in obj) { + if (typeof obj[k] === 'object') { + traverse(obj[k], encountered, shouldThrow, allowDeepUnsaved, counter); } - }); - } - for (const k in obj) { - if (typeof obj[k] === 'object') { - traverse(obj[k], encountered, shouldThrow, allowDeepUnsaved); } } }