From 9258b32ea818a2c7f6bc81399540c3f934091801 Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Mon, 25 Mar 2024 02:22:35 +0000 Subject: [PATCH 1/8] fix: fail on infinite recursion in `encode.js` --- src/encode.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/encode.js b/src/encode.js index 0214990f5..b0d196737 100644 --- a/src/encode.js +++ b/src/encode.js @@ -10,6 +10,9 @@ import ParseObject from './ParseObject'; import { Op } from './ParseOp'; import ParseRelation from './ParseRelation'; +const MAX_RECURSIVE_CALLS = 999; +let recursiveCallsCount = 0; + function encode( value: mixed, disallowObjects: boolean, @@ -17,6 +20,20 @@ function encode( seen: Array, offline: boolean ): any { + recursiveCallsCount++; + + if (recursiveCallsCount > MAX_RECURSIVE_CALLS) { + const errorMessage = 'Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'; + console.warn(errorMessage); + console.debug('Value causing potential infinite recursion:', value); + console.debug('Disallow objects:', disallowObjects); + console.debug('Force pointers:', forcePointers); + console.debug('Seen:', seen); + console.debug('Offline:', offline); + + throw new Error(errorMessage); + } + if (value instanceof ParseObject) { if (disallowObjects) { throw new Error('Parse Objects not allowed here'); @@ -89,5 +106,6 @@ export default function ( seen?: Array, offline?: boolean ): any { + recursiveCallsCount = 0; return encode(value, !!disallowObjects, !!forcePointers, seen || [], offline); } From f63a5a246b340dd6ccb73dbdaaa174fcbd90ab65 Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Mon, 25 Mar 2024 15:12:52 +0000 Subject: [PATCH 2/8] fix: per-call encoding count argument --- src/encode.js | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/encode.js b/src/encode.js index b0d196737..0724d3431 100644 --- a/src/encode.js +++ b/src/encode.js @@ -11,27 +11,26 @@ import { Op } from './ParseOp'; import ParseRelation from './ParseRelation'; const MAX_RECURSIVE_CALLS = 999; -let recursiveCallsCount = 0; function encode( value: mixed, disallowObjects: boolean, forcePointers: boolean, seen: Array, - offline: boolean + offline: boolean, + counter: number = 0 ): any { - recursiveCallsCount++; + counter++; - if (recursiveCallsCount > MAX_RECURSIVE_CALLS) { - const errorMessage = 'Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'; - console.warn(errorMessage); - console.debug('Value causing potential infinite recursion:', value); - console.debug('Disallow objects:', disallowObjects); - console.debug('Force pointers:', forcePointers); - console.debug('Seen:', seen); - console.debug('Offline:', offline); + if (counter > MAX_RECURSIVE_CALLS) { + console.error('Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'); + console.error('Value causing potential infinite recursion:', value); + console.error('Disallow objects:', disallowObjects); + console.error('Force pointers:', forcePointers); + console.error('Seen:', seen); + console.error('Offline:', offline); - throw new Error(errorMessage); + throw new Error('Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'); } if (value instanceof ParseObject) { @@ -84,14 +83,14 @@ function encode( if (Array.isArray(value)) { return value.map(v => { - return encode(v, disallowObjects, forcePointers, seen, offline); + return encode(v, disallowObjects, forcePointers, seen, offline, counter); }); } if (value && typeof value === 'object') { const output = {}; for (const k in value) { - output[k] = encode(value[k], disallowObjects, forcePointers, seen, offline); + output[k] = encode(value[k], disallowObjects, forcePointers, seen, offline, counter); } return output; } @@ -106,6 +105,5 @@ export default function ( seen?: Array, offline?: boolean ): any { - recursiveCallsCount = 0; - return encode(value, !!disallowObjects, !!forcePointers, seen || [], offline); + return encode(value, !!disallowObjects, !!forcePointers, seen || [], offline, 0); } From e0c322a2c8b59552a40154e05fc07aabb407c2bb Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Mon, 1 Apr 2024 00:59:27 +0000 Subject: [PATCH 3/8] Added tests Also included support for the `traverse` function, which experienced the same issue. --- src/__tests__/ParseObject-test.js | 38 +++++++++++++++++++++++++++++++ src/encode.js | 5 ++-- src/unsavedChildren.js | 22 ++++++++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index c67c4a3e1..8846abbfd 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'); @@ -3855,4 +3856,41 @@ describe('ParseObject pin', () => { }); CoreManager.set('ALLOW_CUSTOM_OBJECT_ID', false); }); + + it('handles unsaved circular references', async () => { + const xhrs = []; + RESTController._setXHR(function () { + const xhr = { + setRequestHeader: jest.fn(), + open: jest.fn(), + send: jest.fn(), + status: 200, + readyState: 4, + }; + xhrs.push(xhr); + return xhr; + }); + + const a = {}; + const b = {}; + a.b = b; + b.a = a; + + const object = new ParseObject('Test'); + object.set('a', a); + expect(() => { + object.save(); + }).toThrowError( + 'Maximum recursive calls exceeded in traverse function. Potential infinite recursion detected.' + ); + }); + + it('throws error for infinite recursion', () => { + const circularObject = {}; + circularObject.circularReference = circularObject; + + expect(() => { + encode(circularObject, false, false, [], false); + }).toThrowError('Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'); + }); }); diff --git a/src/encode.js b/src/encode.js index 0724d3431..38c77be04 100644 --- a/src/encode.js +++ b/src/encode.js @@ -23,14 +23,15 @@ function encode( counter++; if (counter > MAX_RECURSIVE_CALLS) { - console.error('Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'); + const message = 'Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'; + console.error(message); console.error('Value causing potential infinite recursion:', value); console.error('Disallow objects:', disallowObjects); console.error('Force pointers:', forcePointers); console.error('Seen:', seen); console.error('Offline:', offline); - throw new Error('Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'); + throw new Error(message); } if (value instanceof ParseObject) { diff --git a/src/unsavedChildren.js b/src/unsavedChildren.js index 088bd6084..8f88d4e5d 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 = 'Maximum recursive calls exceeded in traverse function. Potential infinite recursion detected.'; + console.error(message); + console.error('Object causing potential infinite recursion:', obj); + console.error('Encountered objects:', encountered); + + 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); } } } @@ -78,13 +92,13 @@ function traverse( if (Array.isArray(obj)) { obj.forEach(el => { if (typeof el === 'object') { - traverse(el, encountered, shouldThrow, allowDeepUnsaved); + traverse(el, encountered, shouldThrow, allowDeepUnsaved, counter); } }); } for (const k in obj) { if (typeof obj[k] === 'object') { - traverse(obj[k], encountered, shouldThrow, allowDeepUnsaved); + traverse(obj[k], encountered, shouldThrow, allowDeepUnsaved, counter); } } } From d33a54a9d6097863aa042dd382250e346b0acf50 Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Mon, 1 Apr 2024 21:30:27 +0000 Subject: [PATCH 4/8] fix: removes `xhrs` mock from test --- src/__tests__/ParseObject-test.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index 56c4b653f..a7085dcf0 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -3865,19 +3865,6 @@ describe('ParseObject pin', () => { }); it('handles unsaved circular references', async () => { - const xhrs = []; - RESTController._setXHR(function () { - const xhr = { - setRequestHeader: jest.fn(), - open: jest.fn(), - send: jest.fn(), - status: 200, - readyState: 4, - }; - xhrs.push(xhr); - return xhr; - }); - const a = {}; const b = {}; a.b = b; From 21f14c21c9601e2608d9c2ce580a0275d3989355 Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Wed, 3 Apr 2024 14:48:38 +0000 Subject: [PATCH 5/8] fix: revised `encode.js` error message --- src/encode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/encode.js b/src/encode.js index 38c77be04..3ff0a0e03 100644 --- a/src/encode.js +++ b/src/encode.js @@ -23,7 +23,7 @@ function encode( counter++; if (counter > MAX_RECURSIVE_CALLS) { - const message = 'Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'; + 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:', value); console.error('Disallow objects:', disallowObjects); From 12165c4112a00d603d59e3b18758a44fbe480a6f Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Wed, 3 Apr 2024 14:50:17 +0000 Subject: [PATCH 6/8] fix: revised `unsavedChildren.js` message --- src/unsavedChildren.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unsavedChildren.js b/src/unsavedChildren.js index 8f88d4e5d..4626316be 100644 --- a/src/unsavedChildren.js +++ b/src/unsavedChildren.js @@ -56,7 +56,7 @@ function traverse( counter++; if (counter > MAX_RECURSIVE_CALLS) { - const message = 'Maximum recursive calls exceeded in traverse function. Potential infinite recursion detected.'; + const message = 'Traversing object failed due to high number of recursive calls, likely caused by circular reference within object.'; console.error(message); console.error('Object causing potential infinite recursion:', obj); console.error('Encountered objects:', encountered); From b22ff516a10969b6ffdb4777c32eb3b6aaa8374a Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Wed, 3 Apr 2024 16:10:44 +0000 Subject: [PATCH 7/8] fix: update tests to match new messages --- src/__tests__/ParseObject-test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index a7085dcf0..7c51498fc 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -3875,7 +3875,7 @@ describe('ParseObject pin', () => { expect(() => { object.save(); }).toThrowError( - 'Maximum recursive calls exceeded in traverse function. Potential infinite recursion detected.' + 'Traversing object failed due to high number of recursive calls, likely caused by circular reference within object.' ); }); @@ -3885,6 +3885,6 @@ describe('ParseObject pin', () => { expect(() => { encode(circularObject, false, false, [], false); - }).toThrowError('Maximum recursive calls exceeded in encode function. Potential infinite recursion detected.'); + }).toThrowError('Encoding object failed due to high number of recursive calls, likely caused by circular reference within object.'); }); }); From 3b6684db919d889a04bf7ca87011bd45014f9a83 Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Wed, 10 Apr 2024 19:29:07 -0400 Subject: [PATCH 8/8] Failsafe for anonymous functions in `encode` --- src/CryptoUtils.js | 21 ++++++++ src/ParseOp.js | 2 +- src/__tests__/ParseObject-test.js | 9 ---- src/__tests__/encode-test.js | 9 ++++ src/encode.js | 81 ++++++++++++++++++------------- src/unsavedChildren.js | 15 ++---- 6 files changed, 84 insertions(+), 53 deletions(-) create mode 100644 src/CryptoUtils.js 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 7c51498fc..41605c6e9 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -3878,13 +3878,4 @@ describe('ParseObject pin', () => { 'Traversing object failed due to high number of recursive calls, likely caused by circular reference within object.' ); }); - - it('throws error for infinite recursion', () => { - const circularObject = {}; - circularObject.circularReference = circularObject; - - expect(() => { - encode(circularObject, false, false, [], false); - }).toThrowError('Encoding 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 3ff0a0e03..d63db6c9a 100644 --- a/src/encode.js +++ b/src/encode.js @@ -9,6 +9,7 @@ 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; @@ -18,18 +19,15 @@ function encode( forcePointers: boolean, seen: Array, offline: boolean, - counter: number = 0 + 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:', value); - console.error('Disallow objects:', disallowObjects); - console.error('Force pointers:', forcePointers); - console.error('Seen:', seen); - console.error('Offline:', offline); + console.error('Value causing potential infinite recursion:', initialValue); throw new Error(message); } @@ -38,23 +36,21 @@ function encode( 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 || @@ -62,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, counter); - }); - } - - 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, counter); + 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 ( @@ -104,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, 0); + return encode(value, !!disallowObjects, !!forcePointers, seen || [], !!offline, counter || 0, initialValue || value); } diff --git a/src/unsavedChildren.js b/src/unsavedChildren.js index 4626316be..feb9d04b9 100644 --- a/src/unsavedChildren.js +++ b/src/unsavedChildren.js @@ -58,8 +58,8 @@ function traverse( 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('Object causing potential infinite recursion:', obj); console.error('Encountered objects:', encountered); + console.error('Object causing potential infinite recursion:', obj); throw new Error(message); } @@ -89,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, counter); + 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, counter); } } }