Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tech story: [M3-8385] - Replace lodash set utility function to handle prototype pollution security threat #10814

Merged
merged 20 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Tech Stories
---

Replace lodash set utility function to handle security threat raised by Dependabot ([#10814](https://github.com/linode/manager/pull/10814))
2 changes: 0 additions & 2 deletions packages/manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"libphonenumber-js": "^1.10.6",
"lodash.clonedeep": "^4.5.0",
"lodash.curry": "^4.1.1",
"lodash.set": "^4.3.2",
"logic-query-parser": "^0.0.5",
"luxon": "3.4.4",
"markdown-it": "^12.3.2",
Expand Down Expand Up @@ -143,7 +142,6 @@
"@types/jspdf": "^1.3.3",
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.curry": "^4.1.9",
"@types/lodash.set": "^4.3.9",
"@types/luxon": "3.4.2",
"@types/markdown-it": "^10.0.2",
"@types/md5": "^2.1.32",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { omit } from 'lodash';
import { useHistory } from 'react-router-dom';

import { imageQueries } from 'src/queries/images';
Expand All @@ -7,6 +6,7 @@ import { stackscriptQueries } from 'src/queries/stackscripts';
import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalytics';
import { privateIPRegex } from 'src/utilities/ipUtils';
import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined';
import { omitProps } from 'src/utilities/omittedProps';
import { getQueryParamsFromQueryString } from 'src/utilities/queryParams';

import { utoa } from '../LinodesCreate/utilities';
Expand Down Expand Up @@ -144,7 +144,7 @@ export const tabs: LinodeCreateType[] = [
export const getLinodeCreatePayload = (
formValues: LinodeCreateFormValues
): CreateLinodeRequest => {
const values = omit(formValues, ['linode', 'hasSignedEUAgreement']);
const values = omitProps(formValues, ['linode', 'hasSignedEUAgreement']);
if (values.metadata?.user_data) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

omitProps is a direct replacement for lodash's omit

should we rename it to omit as well?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes this change is fine. I just realized lodash is still in out bundle because of formik, but we don't import it directly (in other words this import would break the build if we were to get rid of formik for instance). I am going to add an eslint rule to prevent that from happening in the future

values.metadata.user_data = utoa(values.metadata.user_data);
}
Expand Down
219 changes: 206 additions & 13 deletions packages/manager/src/utilities/formikErrorUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
getFormikErrorsFromAPIErrors,
handleAPIErrors,
handleVPCAndSubnetErrors,
set,
} from './formikErrorUtils';

const errorWithoutField = [{ reason: 'Internal server error' }];
Expand Down Expand Up @@ -35,51 +36,51 @@ describe('handleAPIErrors', () => {

const subnetMultipleErrorsPerField = [
Copy link
Contributor Author

@coliu-akamai coliu-akamai Aug 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are just eslint changes for this test

(note - the specific method these tests are for may be able to be removed in favor of using getFormikErrorsFromAPIErrors actually - planning to look into this!)

{
reason: 'not expected error for label',
field: 'subnets[0].label',
reason: 'not expected error for label',
},
{
reason: 'expected error for label',
field: 'subnets[0].label',
reason: 'expected error for label',
},
{
reason: 'not expected error for label',
field: 'subnets[3].label',
reason: 'not expected error for label',
},
{
reason: 'expected error for label',
field: 'subnets[3].label',
reason: 'expected error for label',
},
{
reason: 'not expected error for ipv4',
field: 'subnets[3].ipv4',
reason: 'not expected error for ipv4',
},
{
reason: 'expected error for ipv4',
field: 'subnets[3].ipv4',
reason: 'expected error for ipv4',
},
];

const subnetErrors = [
{
reason: 'Label required',
field: 'subnets[1].label',
reason: 'Label required',
},
{
reason: 'bad label',
field: 'subnets[2].label',
reason: 'bad label',
},
{
reason: 'cidr ipv4',
field: 'subnets[2].ipv4',
reason: 'cidr ipv4',
},
{
reason: 'needs an ip',
field: 'subnets[4].ipv4',
reason: 'needs an ip',
},
{
reason: 'needs an ipv6',
field: 'subnets[4].ipv6',
reason: 'needs an ipv6',
},
];

Expand All @@ -93,7 +94,7 @@ describe('handleVpcAndConvertSubnetErrors', () => {
expect(Object.keys(errors)).toHaveLength(3);
expect(Object.keys(errors)).toEqual(['1', '2', '4']);
expect(errors[1]).toEqual({ label: 'Label required' });
expect(errors[2]).toEqual({ label: 'bad label', ipv4: 'cidr ipv4' });
expect(errors[2]).toEqual({ ipv4: 'cidr ipv4', label: 'bad label' });
expect(errors[4]).toEqual({ ipv4: 'needs an ip', ipv6: 'needs an ipv6' });
});

Expand All @@ -106,8 +107,8 @@ describe('handleVpcAndConvertSubnetErrors', () => {
expect(Object.keys(errors)).toHaveLength(2);
expect(errors[0]).toEqual({ label: 'expected error for label' });
expect(errors[3]).toEqual({
label: 'expected error for label',
ipv4: 'expected error for ipv4',
label: 'expected error for label',
});
});

Expand Down Expand Up @@ -201,3 +202,195 @@ describe('getFormikErrorsFromAPIErrors', () => {
}
});
});

describe('Tests for set', () => {
it("returns the passed in 'object' as is if it's not actually a (non array) object", () => {
expect(set([], 'path not needed', 3)).toEqual([]);
});

describe('Correctly setting the value at the given path', () => {
it('sets the value for a simple path', () => {
const object = {};
let settedObject = set(object, 'test', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({ test: 1 });

settedObject = set(object, 'test2', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({ test: 1, test2: 1 });
});

it('sets the value for complex string paths (without indexes)', () => {
const object = {};

set(object, 'a.b.c', 'c');
expect(object).toEqual({ a: { b: { c: 'c' } } });

set(object, 'a.b.d', 'd');
expect(object).toEqual({
a: { b: { c: 'c', d: 'd' } },
});

set(object, 'e[f][g]', 'g');
expect(object).toEqual({
a: { b: { c: 'c', d: 'd' } },
e: { f: { g: 'g' } },
});
});

it('sets the value for complex string paths (with indexes)', () => {
const object = {};

set(object, 'a.b.1', 'b1');
expect(object).toEqual({ a: { b: [undefined, 'b1'] } });
set(object, 'a.b[0]', 5);
expect(object).toEqual({ a: { b: [5, 'b1'] } });

set(object, 'a.b.2', 'b2');
expect(object).toEqual({
a: { b: [5, 'b1', 'b2'] },
});

set(object, 'a.b[3].c', 'c');
expect(object).toEqual({
a: { b: [5, 'b1', 'b2', { c: 'c' }] },
});
});

it('only considers 0 or positive integers for setting array values', () => {
const object = {};

expect(set(object, 'test[-01].test1', 'test')).toEqual({
test: { '-01': { test1: 'test' } },
});
expect(set(object, 'test[-01][-02]', 'test2')).toEqual({
test: { '-01': { '-02': 'test2', test1: 'test' } },
});
expect(set(object, 'test[ 02]', 'test3')).toEqual({
test: {
' 02': 'test3',
'-01': { '-02': 'test2', test1: 'test' },
},
});
expect(set(object, 'test[0 0]', 'test4')).toEqual({
test: {
' 02': 'test3',
'-01': { '-02': 'test2', test1: 'test' },
'0 0': 'test4',
},
});
});

it('considers numbers as keys if they are not followed by another number or if there is an already existing object', () => {
const object = {};
set(object, '1', 'test');
expect(object).toEqual({ 1: 'test' });

set(object, '2', '2');
expect(object).toEqual({ 1: 'test', 2: '2' });

expect(set({ test: { test1: 'test' } }, 'test[1]', 'test2')).toEqual({
test: { '1': 'test2', test1: 'test' },
});
});

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'] });

const obj2 = set({}, '1.2', 'test');
expect(obj2).toEqual({ 1: [undefined, undefined, 'test'] });
});

it('can replace the value at an already existing key', () => {
const alreadyExisting = { test: 'test' };
expect(set(alreadyExisting, 'test', 'changed')).toEqual({
test: 'changed',
});
expect(set(alreadyExisting, 'test[test2][test3]', 'changed x4')).toEqual({
test: { test2: { test3: 'changed x4' } },
});
});

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' },
});
});
});

describe('Ensuring safety against prototype pollution and that the passed in and returned object are the same', () => {
it('protects against the given string path matching a prototype pollution key', () => {
const object = {};
// __proto__
let settedObject = set(object, '__proto__', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});

// constructor
settedObject = set(object, 'constructor', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});

// prototype
settedObject = set(object, 'prototype', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});
});

it('protects against the given string path containing prototype pollution keys that are separated by path delimiters', () => {
const object = {};
// prototype pollution key separated by .
let settedObject = set(object, 'test.__proto__.test', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});

settedObject = set(object, 'test.constructor.test', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});

settedObject = set(object, 'test.prototype.test', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});

// prototype pollution key separated by []
settedObject = set(object, 'test.test[__proto__]', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});

settedObject = set(object, 'test.test[constructor]', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});

settedObject = set(object, 'test.test[prototype]', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({});
});

it('is not considered prototype pollution if the string paths have a key not separated by delimiters', () => {
const object = {};
// prototype pollution key separated by .
let settedObject = set(object, 'test__proto__test', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({ test__proto__test: 1 });

settedObject = set(object, 'constructortest', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({ constructortest: 1, test__proto__test: 1 });

settedObject = set(object, 'testprototype', 1);
expect(object).toBe(settedObject);
expect(object).toEqual({
constructortest: 1,
test__proto__test: 1,
testprototype: 1,
});
});
});
});
58 changes: 57 additions & 1 deletion packages/manager/src/utilities/formikErrorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import set from 'lodash.set';
import { reverse } from 'ramda';

import { getAPIErrorOrDefault } from './errorUtils';
Expand All @@ -23,6 +22,63 @@ export const getFormikErrorsFromAPIErrors = <T>(
}, {});
};

// regex used in the below set function
const onlyDigitsRegex = /^\d+$/;

/**
* Helper for getFormikErrorsFromAPIErrors, sets the given value at a specified path of the given object.
* Note that while we are using this function in place of lodash's set, it is not an exact replacement.
* This method both mutates the passed in object and returns it.
*
* @param object β€” The object to modify.
* @param path β€” The path of the property to set.
* @param value β€” The value to set.
* @return β€” Returns object.
*/
export const set = (obj: object, path: string, value: any): object => {
const parts = path.split(/\.|\[|\]/).filter(Boolean);
coliu-akamai marked this conversation as resolved.
Show resolved Hide resolved

// ensure that obj is not an array and that the path is prototype pollution safe
if (Array.isArray(obj) || !isPrototypePollutionSafe(parts)) {
return obj;
}

parts.reduce((acc: any, part: string, index: number) => {
if (index === parts.length - 1) {
coliu-akamai marked this conversation as resolved.
Show resolved Hide resolved
// Last part, set the value
acc[part] = value;
} else if (part.match(onlyDigitsRegex)) {
// Handle array indices
const arrayIndex = parseInt(part, 10);
acc[arrayIndex] =
acc[arrayIndex] ?? (parts[index + 1].match(onlyDigitsRegex) ? [] : {});
} else {
// Handle nested objects
const potentialNextVal = parts[index + 1].match(onlyDigitsRegex)
? []
: {};
acc[part] = typeof acc[part] === 'object' ? acc[part] : potentialNextVal;
}
return acc[part];
}, obj);

return obj;
};

/**
* Ensures a path cannot lead to a prototype pollution issue.
*
* @param path - The path to check
* @return - boolean depending on whether the path is safe or not
*/
const isPrototypePollutionSafe = (path: string[]): boolean => {
return path.reduce((safeSoFar, val) => {
const isCurKeySafe =
val !== '__proto__' && val !== 'prototype' && val !== 'constructor';
return safeSoFar && isCurKeySafe;
}, true);
};

export const handleFieldErrors = (
callback: (error: unknown) => void,
fieldErrors: APIError[] = []
Expand Down
Loading
Loading