diff --git a/packages/manager/src/utilities/formikErrorUtils.test.ts b/packages/manager/src/utilities/formikErrorUtils.test.ts index 245fd29361b..bec66e72565 100644 --- a/packages/manager/src/utilities/formikErrorUtils.test.ts +++ b/packages/manager/src/utilities/formikErrorUtils.test.ts @@ -2,6 +2,7 @@ import { getFormikErrorsFromAPIErrors, handleAPIErrors, handleVPCAndSubnetErrors, + set, } from './formikErrorUtils'; const errorWithoutField = [{ reason: 'Internal server error' }]; @@ -128,7 +129,7 @@ describe('handleVpcAndConvertSubnetErrors', () => { }); describe('getFormikErrorsFromAPIErrors', () => { - it('should convert APIError[] to errors in the shape formik expects', () => { + it.only('should convert APIError[] to errors in the shape formik expects', () => { const testCases = [ { apiErrors: [{ field: 'ip', reason: 'Incorrect IP' }], @@ -201,3 +202,270 @@ describe('getFormikErrorsFromAPIErrors', () => { } }); }); + +describe('Tests for set', () => { + // fails (sets object) (probably a quick fix) (jk would need some more thinking) + it("returns the passed in 'object' as is if it's not actually a (non array) object", () => { + expect(set([], 'path not needed', 3)).toEqual([]); + }); + + // pass + describe('Correctly setting the value at the given path', () => { + it('sets the value for a simple path for both string and array paths', () => { + let object = {}; + object = set(object, 'test', 1); + expect(object).toEqual({ test: 1 }); + + object = set(object, 'test2', 1); + expect(object).toEqual({ test: 1, test2: 1 }); + }); + + // pass + it('sets the value for complex string and array paths (without indexes)', () => { + let object = {}; + + // the given paths are equivalent in string vs array format + object = set(object, 'a.b.c', 'c'); + expect(object).toEqual({ a: { b: { c: 'c' } } }); + + object = set(object, 'a.b.d', 'd'); + expect(object).toEqual({ + a: { b: { c: 'c', d: 'd' } }, + }); + + object = set(object, 'e[f][g]', 'g'); + expect(object).toEqual({ + a: { b: { c: 'c', d: 'd' } }, + e: { f: { g: 'g' } }, + }); + }); + + it.only('sets the value for complex string paths pt2', () => { + const obj = set({}, 'a[1].b[0].c', 'test'); + expect(obj).toEqual({ + a: [undefined, { b: [{ c: 'test' }] }], + }); + + const obj2 = set({}, 'a[1].b.c', 'test'); + expect(obj2).toEqual({ + a: [undefined, { b: { c: 'test' } }], + }); + }); + + // fails + it.only('sets the value for complex string and array paths (with indexes)', () => { + let object = {}; + + // the given paths are equivalent in string vs array format + object = set(object, 'a.b[1]', 'b1'); + /* + { + a: { + b: { 1: "b1" } + } + } + */ + expect(object).toEqual({ a: { b: [undefined, 'b1'] } }); + object = set(object, 'a.b[0]', 5); + expect(object).toEqual({ a: { b: [5] } }); + + // If path is an array, indexes can be passed in as a string or as a number + object = set(object, 'a.b.2', 'b2'); + expect(object).toEqual({ + a: { b: [5, 'b1', 'b2'] }, + }); + + object = set(object, 'a.b[3].c', 'c'); + expect(object).toEqual({ + a: { b: [undefined, undefined, undefined, { c: 'c' }] }, + }); + }); + + // fails + it('creates an empty string key', () => { + expect(set({}, '', 'empty string')).toEqual({ '': 'empty string' }); + }); + + // fails + it('only considers valid indexes for setting array values', () => { + let object = {}; + + object = set(object, 'test[01].test1', 'test'); + expect(object).toEqual({ + test: { '01': { test1: 'test' } }, + }); + object = set(object, 'test[01].[02]', 'test2'); + expect(object).toEqual({ + test: { '01': { '02': 'test2', test1: 'test' } }, + }); + object = set(object, 'test[-01]', 'test3'); + expect(object).toEqual({ + test: { '-01': 'test3', '01': { '02': 'test2', test1: 'test' } }, + }); + object = set(object, 'test[ 02]', 'test4'); + expect(object).toEqual({ + test: { + ' 02': 'test4', + '-01': 'test3', + '01': { '02': 'test2', test1: 'test' }, + }, + }); + object = set(object, 'test[00]', 'test5'); + expect(object).toEqual({ + test: { + ' 02': 'test4', + '-01': 'test3', + '00': 'test5', + '01': { '02': 'test2', test1: 'test' }, + }, + }); + }); + + // pass + it('considers numbers as keys if they are not followed by another number or if there is an already existing object', () => { + let object = {}; + object = set(object, '1', 'test'); + expect(object).toEqual({ 1: 'test' }); + + object = set({ test: { test1: 'test' } }, 'test[1]', 'test2'); + expect(object).toEqual({ + test: { '1': 'test2', test1: 'test' }, + }); + }); + + // fails + it('treats numbers as array indexes if they precede some previous key (if they are valid indexes)', () => { + const obj1 = set({}, '1[1]', 'test'); + expect(obj1).toEqual({ 1: [undefined, 'test'] }); // [undefined, { 1: 'test' }] + + const obj2 = set({}, '1.2', 'test'); + expect(obj2).toEqual({ 1: [undefined, undefined, 'test'] }); + }); + + // fails + it('can replace the value at an already existing key', () => { + let alreadyExisting = { test: 'test' }; + alreadyExisting = set(alreadyExisting, 'test', 'changed'); + + expect(alreadyExisting).toEqual({ + test: 'changed', + }); + + // fails + alreadyExisting = set( + alreadyExisting, + 'test[test2][test3]', + 'changed x4' + ); + expect(alreadyExisting).toEqual({ + test: { test2: { test3: 'changed x4' } }, + }); + }); + + // pass + it('sets the value for nonstandard paths', () => { + expect(set({}, 'test.[.test]', 'testing 2')).toEqual({ + test: { test: 'testing 2' }, + }); + expect(set({}, 'test.[te[st]', 'testing 3')).toEqual({ + test: { te: { st: 'testing 3' } }, + }); + expect(set({}, 'test.]test', 'testing 4')).toEqual({ + test: { test: 'testing 4' }, + }); + }); + + // it will take an incredible amount of effort to get set to this level of finesse + it.skip('sets the value for non standard paths - not working currently', () => { + expect(set({}, 'test.[]', 'testing 5')).toEqual({ + test: { '': { '': 'testing 5' } }, + }); + expect(set({}, '[].test', 'testing 6')).toEqual({ + '': { test: 'testing 6' }, + }); + expect(set({}, '.', 'testing 7')).toEqual({ + '': { '': 'testing 7' }, + }); + expect(set({}, '[', 'testing 8')).toEqual({ + '[': 'testing 8', + }); + expect(set({}, ']', 'testing 9')).toEqual({ + ']': 'testing 9', + }); + expect(set({}, 'test..test', 'testing')).toEqual({ + test: { '': { test: 'testing' } }, + }); + }); + }); + + describe('Ensuring safety against prototype pollution and that the passed in and returned object are the same', () => { + // pass + it.only('protects against the given string path matching a prototype pollution key', () => { + let object = {}; + // __proto__ + object = set(object, '__proto__', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({}); + + // constructor + object = set(object, 'constructor', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({}); + + // prototype + object = set(object, 'prototype', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({}); + }); + + // pass with changes (this is more inline with lodash's set anyway) + it.only('protects against the given string path containing prototype pollution keys that are separated by path delimiters', () => { + let object = {}; + // prototype pollution key separated by . + object = set(object, 'test.__proto__.test', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ test: {} }); + + object = set(object, 'test.constructor.test', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ test: {} }); + + object = set(object, 'test.prototype.test', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ test: {} }); + + // prototype pollution key separated by [] + object = set(object, 'test.test[__proto__]', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ test: { test: {} } }); + + object = set(object, 'test.test[constructor]', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ test: { test: {} } }); + + object = set(object, 'test.test[prototype]', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ test: { test: {} } }); + }); + + it('is not considered prototype pollution if the string paths have a key not separated by delimiters', () => { + let object = {}; + // prototype pollution key separated by . + object = set(object, 'test__proto__test', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ test__proto__test: 1 }); + + object = set(object, 'constructortest', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ constructortest: 1, test__proto__test: 1 }); + + object = set(object, 'testprototype', 1); + // expect(object).toBe(settedObject); + expect(object).toEqual({ + constructortest: 1, + test__proto__test: 1, + testprototype: 1, + }); + }); + }); +}); diff --git a/packages/manager/src/utilities/formikErrorUtils.ts b/packages/manager/src/utilities/formikErrorUtils.ts index b17047942fe..a20c69b59f4 100644 --- a/packages/manager/src/utilities/formikErrorUtils.ts +++ b/packages/manager/src/utilities/formikErrorUtils.ts @@ -2,7 +2,7 @@ import { reverse } from 'ramda'; import { getAPIErrorOrDefault } from './errorUtils'; import { isNilOrEmpty } from './isNilOrEmpty'; -import { set } from './set'; +// import { set } from './set'; import type { APIError } from '@linode/api-v4/lib/types'; import type { FormikErrors } from 'formik'; @@ -17,12 +17,45 @@ export const getFormikErrorsFromAPIErrors = ( ? error.field.replace(prefixToRemoveFromFields, '') : error.field; - set(acc, field, error.reason); + return set(acc, field, error.reason); } return acc; }, {}); }; +export const set = (obj: any, path: string, value: any): any => { + // Split the path into parts, handling both dot notation and array indices + const [head, ...rest] = path.split(/\.|\[|\]/).filter(Boolean); + + // protect against prototype pollution + if (head === 'prototype' || head === '__proto__' || head === 'constructor') { + return { ...obj }; + } + + // Since this is recursive, if there are no more parts in the path, set the value and return + if (rest.length === 0) { + return { ...obj, [head]: value }; + } + + // Handle array indices + if (head.match(/^\d+$/)) { + const index = parseInt(head, 10); + // Copy the existing one or create a new empty one + const newArray = Array.isArray(obj) ? [...obj] : []; + // Recursively set the value at the specified index + newArray[index] = set(newArray[index] || {}, rest.join('.'), value); + + return newArray; + } + + // Handle nested objects + return { + ...obj, + // Recursively set the value for the nested path + [head]: set(obj[head] || {}, rest.join('.'), value), + }; +}; + export const handleFieldErrors = ( callback: (error: unknown) => void, fieldErrors: APIError[] = [] diff --git a/packages/manager/src/utilities/set.test.ts b/packages/manager/src/utilities/set.test.ts index 683bd99c472..5e54b1ee33d 100644 --- a/packages/manager/src/utilities/set.test.ts +++ b/packages/manager/src/utilities/set.test.ts @@ -78,6 +78,18 @@ describe('Tests for set', () => { expect(object2).toEqual(object); }); + it('sets the value for complex string paths pt2', () => { + const obj = set({}, 'a[1].b[0].c', 'test'); + expect(obj).toEqual({ + a: [undefined, { b: [{ c: 'test' }] }], + }); + + const obj2 = set({}, 'a[1].b.c', 'test'); + expect(obj2).toEqual({ + a: [undefined, { b: { c: 'test' } }], + }); + }); + it('creates an empty string key', () => { expect(set({}, '', 'empty string')).toEqual({ '': 'empty string' }); expect(set({}, [''], 'empty string for array')).toEqual({