-
Notifications
You must be signed in to change notification settings - Fork 358
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
Conversation
@@ -1,4 +1,3 @@ | |||
import { omit } from 'lodash'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
honestly seems like we're still able to import from lodash even after removing the dependency earlier? based on how this snuck in
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
right, cause Formik has it as a dependency... 😢
still i created M3-8489 to prevent direct imports cause we shouldn't
@@ -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']); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
@@ -35,51 +35,51 @@ describe('handleAPIErrors', () => { | |||
|
|||
const subnetMultipleErrorsPerField = [ |
There was a problem hiding this comment.
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!)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for tackling this, and nice writing! Going to spend a little bit more time with this one. My initial feedback is that in general, writing a suite of utils to serve only one case should be avoided, that being said I am aware this isn't a simple instance. Wondering if there's a way to satisfy the existing test cases for formikErrorUtils
without having to re-create a set
function. Happy to go with this solution if not, but worth a bit more digging
thanks for the feedback Alban - will also think about this more too! |
@coliu-akamai came up with this smaller footprint solution which seems to satisfy the existing // local 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);
// 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),
};
}; // getFormikErrorsFromAPIErrors export const getFormikErrorsFromAPIErrors = <T>(
errors: APIError[],
prefixToRemoveFromFields?: string
): FormikErrors<T> => {
return errors.reduce((acc: FormikErrors<T>, error: APIError) => {
if (error.field) {
const field = prefixToRemoveFromFields
? error.field.replace(prefixToRemoveFromFields, '')
: error.field;
return set(acc, field, error.reason); // only difference is needing to return the accumulated result here
}
return acc;
}, {});
}; |
thanks Alban! Been poking around your solution - added the prototype pollution check to it + ran it against my test cases from earlier in an offshoot branch (see test PR I opened up here). IMO the main test case failing that may be a cause for concern is this one (the commented object is what's returned), but we could argue that here, having an object instead of an array makes sense. Maybe this one as well, but I think it would be quick to fix it. One of my main questions is - while we're only using lodash's set once right now, are there potential other use cases in the future where we'd want to use set again + have lodash's flexibility when using it? If not, then I agree with switching to the less complex version and will change to it ty for your help + feedback! 😆 |
This is a valid question, but also a premature optimization? All together your version is still valid - my primary concern is for the script to perhaps not exactly replicate if you wanna pursue the smaller/localized footprint route, this updated version could get you a little closer and is safer against prototype pollution (duh,, this was the whole point 🤦 ) but still unsure it'll satisfy all your requirements. this mutates the input, but i believe again, splitting hairs a bit here but worth considering either option const set = (obj: any, path: string, value: any): any => {
// Safeguard against prototype pollution
if (path === '__proto__' || path === 'constructor' || path === 'prototype') {
return obj;
}
const parts = path.split(/\.|\[|\]/).filter(Boolean);
return parts.reduce((acc: any, part: string, index: number) => {
if (index === parts.length - 1) {
// Last part, set the value
acc[part] = value;
} else if (part.match(/^\d+$/)) {
// Handle array indices
const arrayIndex = parseInt(part, 10);
acc[arrayIndex] =
acc[arrayIndex] || (parts[index + 1].match(/^\d+$/) ? [] : {});
} else {
// Handle nested objects
acc[part] = acc[part] || (parts[index + 1].match(/^\d+$/) ? [] : {});
}
return acc[part];
}, obj);
}; |
thanks Alban will take another look! |
@abailly-akamai tested it in a another branch (put up another draft PR here to see changes). In short, with a few slight tweaks the behavior actually really closely matches the set here 👀 (there are some differences but for the functionality we want but imo they wouldn't be important for what we're currently using set for). Planning to clean up + switch to this version then! 😆 Would you recommend still creating a separate file and tests for the new set? Or just keep it as an untested helper function in the Also, another question - in general, is there a rule of thumb for when we should try to optimize/consider wider use cases vs making something more specific to what we currently have? |
I would keep it as a helper function in
External utils are meant to be shared, portable and reusable. If we're supporting only one function, it's fair not to optimize for something we don't know we would ever need. Those mutation patterns should be seldomly used anyway. Always go small, avoid a package if we can code it simply enough and nae things well so people can find the util if they are thinking of reusing it in the future 👍 |
Coverage Report: ✅ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great, thx for coping with me 🤣
Approving pending a couple type improvements
@@ -1,4 +1,3 @@ | |||
import { omit } from 'lodash'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
right, cause Formik has it as a dependency... 😢
still i created M3-8489 to prevent direct imports cause we shouldn't
Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for doing this!
When I delete Linode Create v1, I'll be able to remove the last two lodash dependencies (lodash.curry
and lodash.clonedeep
) 🎉
Description 📝
The lodash.set package causes a security concern for prototype pollution. This PR aims to resolve the security concern by creating our own version of set (specific to our use cases, not a rehash of lodash's set) that is also prototype pollution safe -- see internal ticket for full details on reasoning behind this approach, but the tldr:
I tried to recreate lodash's set, but it isn't a complete recreation of it - there's a lot of edge cases (see skipped tests). we've made a simpler version of set that is specific to our use case (for example, while lodash's set might accept paths in array format, we only accept paths in string format)Changes 🔄
Target release date 🗓️
?
How to test 🧪
Verification steps
yarn test formikErrorUtils.test.ts
and that there are no UI changes for places that usegetFormikErrorsFromAPIErrors
(specifically PlacementGroups stuff!)confirm that(removing because this is no longer a direct copy for lodash's set)yarn test utilities/set.test.ts
passes if you switch out the new version of set for lodash.setAs an Author I have considered 🤔
Check all that apply