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

[utils] Fix GitHub-reported prototype pollution vulnerability in deepmerge #41652

Merged
merged 3 commits into from
May 27, 2024

Conversation

tjcouch-sil
Copy link
Contributor

Added more checks for prototype pollution in deepmerge to abide by GitHub's vulnerability report in another repo using MUI.

It was hard to figure out which library was contributing the code the reported vulnerability is in, but this line helped me to find isPlainObject exported from the same module which then led me to find deepmerge.

Thanks for all the hard work!

@mui-bot
Copy link

mui-bot commented Mar 25, 2024

Netlify deploy preview

https://deploy-preview-41652--material-ui.netlify.app/

Bundle size report

Details of bundle changes (Toolpad)
Details of bundle changes

Generated by 🚫 dangerJS against 4c06bae

@zannager zannager added the package: material-ui Specific to @mui/material label Mar 26, 2024
@zannager zannager requested a review from mnajdova March 26, 2024 13:06
@danilo-leal danilo-leal changed the title Fixed github-reported prototype pollution vulnerability in deepmerge [utils] Fix GitHub-reported prototype pollution vulnerability in deepmerge Mar 26, 2024
@danilo-leal danilo-leal added package: utils Specific to the @mui/utils package and removed package: material-ui Specific to @mui/material labels Mar 26, 2024
Copy link
Member

@ZeeshanTamboli ZeeshanTamboli left a comment

Choose a reason for hiding this comment

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

The GitHub vulnerability link isn't accessible and throws 404. Can you update the link?

@tjcouch-sil
Copy link
Contributor Author

tjcouch-sil commented May 13, 2024

Unfortunately (in some sense haha), it looks like GitHub makes code scanning results private even on open-source projects. Oops. Sorry about that! I'll post screenshots of the report here.

image
image
image

Here are the CWE links on the side of the page:
CWE - CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') (4.14)
CWE - CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (4.14)
CWE - CWE-94: Improper Control of Generation of Code ('Code Injection') (4.14)
CWE - CWE-400: Uncontrolled Resource Consumption (4.14)
CWE - CWE-471: Modification of Assumed-Immutable Data (MAID) (4.14)
CWE - CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes (4.14)

Here is the line of code flagged by this code scan. It's a Vite-bundled dist file containing this deepmerge code from MUI that my PR here fixes (please ignore the fact that a dist file is in GitHub for the purposes of this conversation haha). See my original comment for how I traced from this minified mess of a line of code to the deepmerge code.

Hopefully that will help you at least to get some idea of what I'm seeing and why I submitted this PR. Please let me know if there's any additional information I can provide from my end.

Thanks!

(Sorry for the delays. I was on vacation for two weeks.)

@ZeeshanTamboli ZeeshanTamboli requested a review from a team May 14, 2024 05:32
@Janpot
Copy link
Member

Janpot commented May 14, 2024

I don't believe disallowing certain properties is the right solution here. i.e. the following code should pass, but it doesn't

const result = deepmerge(
  {},
  JSON.parse('{ "myProperty": "a", "__proto__" : { "isAdmin" : true } }'),
);

expect(result.__proto__).to.have.property('isAdmin');
expect({}).not.to.have.property('isAdmin');

I believe the better solution is to avoid traversing into the prototype instead with Object.prototype.hasOwnProperty().

index a8035a0c47..bb31f5b7cc 100644
--- a/packages/mui-utils/src/deepmerge/deepmerge.ts
+++ b/packages/mui-utils/src/deepmerge/deepmerge.ts
@@ -41,12 +41,11 @@ export default function deepmerge<T>(
 
   if (isPlainObject(target) && isPlainObject(source)) {
     Object.keys(source).forEach((key) => {
-      // Avoid prototype pollution
-      if (key === '__proto__') {
-        return;
-      }
-
-      if (isPlainObject(source[key]) && key in target && isPlainObject(target[key])) {
+      if (
+        isPlainObject(source[key]) &&
+        Object.prototype.hasOwnProperty.call(target, key) &&
+        isPlainObject(target[key])
+      ) {
         // Since `output` is a clone of `target` and we have narrowed `target` in this block we can cast to the same type.
         (output as Record<keyof any, unknown>)[key] = deepmerge(target[key], source[key], options);
       } else if (options.clone) {

Which is also what the code scanning tool suggests as a solution. We could even consider disallowing the in operator altogether with eslint.

edit: This reminds me of this superjson bug which is caused by very similar logic that disallows certain properties instead of checking the own property. 🙂

@tjcouch-sil
Copy link
Contributor Author

Seems reasonable to me! I was just trying to make as small a change as possible to avoid bothering anyone ;) I noticed the package: utils (private) label is now on this PR. Does that mean this PR doesn't actually do anything and the change will be made internally somewhere? If so, should I still update this PR with your suggestion, or is it irrelevant at this point?

Thanks!

@Janpot
Copy link
Member

Janpot commented May 20, 2024

The label just denotes that the exports of these packages are not meant to be exposed as public API. Rest assured, it is used by @mui/material, you are editing the correct code. Feel free to update the PR.

@tjcouch-sil
Copy link
Contributor Author

Thanks for letting me know! Looks like that change breaks a couple of tests:

  • "should merge objects across realms" - I guess hasOwnProperty doesn't work on objects across realms or something
  • "should not merge HTML elements" - I didn't really look too closely into this one to be honest

@Janpot
Copy link
Member

Janpot commented May 21, 2024

Both tests seem to work on my end, can you push your changes to see if it fails in CI as well?

@tjcouch-sil
Copy link
Contributor Author

I think I was running the tests wrong. Sorry about that! Pushed :)

Copy link
Member

@Janpot Janpot left a comment

Choose a reason for hiding this comment

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

Looks good! I think it could be valuable to have a positive assertion like

expect(result.__proto__).to.have.property('isAdmin');

in each of the tests. Other than that, this is good to merge 👍

Copy link
Member

@Janpot Janpot left a comment

Choose a reason for hiding this comment

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

Thank you!

@Janpot Janpot merged commit 5581928 into mui:next May 27, 2024
19 checks passed
DiegoAndai pushed a commit to DiegoAndai/material-ui that referenced this pull request Jun 10, 2024
@oliviertassinari oliviertassinari added security Pull requests that address a security vulnerability bug 🐛 Something doesn't work and removed security Pull requests that address a security vulnerability labels Jun 10, 2024
@oliviertassinari
Copy link
Member

oliviertassinari commented Jun 10, 2024

If I read this correctly, the framing of this pull request is wrong. There is no prototype pollution vulnerability. All the tests related to it pass with or without the changes to the code. It can be tested with:

describe('deepmerge', () => {
  // https://snyk.io/blog/after-three-years-of-silence-a-new-jquery-prototype-pollution-vulnerability-emerges-once-again/
  it('should not be subject to prototype pollution via __proto__', () => {
    const result = deepmerge(
      {},
      JSON.parse('{ "myProperty": "a", "__proto__" : { "isAdmin" : true } }'),
      {
        clone: false,
      },
    );

    // @ts-expect-error __proto__ is not on this object type
    // eslint-disable-next-line no-proto
    // expect(result.__proto__).to.have.property('isAdmin');
    expect({}).not.to.have.property('isAdmin');
  });

  // https://cwe.mitre.org/data/definitions/915.html
  it('should not be subject to prototype pollution via constructor', () => {
    const result = deepmerge(
      {},
      JSON.parse('{ "myProperty": "a", "constructor" : { "prototype": { "isAdmin" : true } } }'),
      {
        clone: true,
      },
    );

    // expect(result.constructor.prototype).to.have.property('isAdmin');
    expect({}).not.to.have.property('isAdmin');
  });

  // https://cwe.mitre.org/data/definitions/915.html
  it('should not be subject to prototype pollution via prototype', () => {
    const result = deepmerge(
      {},
      JSON.parse('{ "myProperty": "a", "prototype": { "isAdmin" : true } }'),
      {
        clone: false,
      },
    );

    // @ts-expect-error prototype is not on this object type
    // expect(result.prototype).to.have.property('isAdmin');
    expect({}).not.to.have.property('isAdmin');
  });

  it('should appropriately copy the fields without prototype pollution', () => {
    const result = deepmerge(
      {},
      JSON.parse('{ "myProperty": "a", "__proto__" : { "isAdmin" : true } }'),
    );

    // @ts-expect-error __proto__ is not on this object type
    // eslint-disable-next-line no-proto
    // expect(result.__proto__).to.have.property('isAdmin');
    expect({}).not.to.have.property('isAdmin');
  });
});

What we fixed here looks like to be a small bug, where in the result of the deep merge, the key __proto__ is missing.
We may have fixed a CodeQL false-positive too.
Per https://mui.com/material-ui/getting-started/support/#supported-versions, I would have expected us to release it in v5 and v4 if there was truly a flaw, I assume we judge that there was not when we merged this. So it looks like https://security.snyk.io/vuln/SNYK-JS-MUIUTILS-7231125 and #42607 should be closed, they are hallucinations of CodeQL.

If there is truly a flaw, then we are missing a regression test case.

@tjcouch-sil
Copy link
Contributor Author

I'm sorry for all the stir and for my lack of thoroughness and forthrightness; I didn't intend for all this to get out of hand as it did. I should have been more considerate, thoughtful, and clear in my approach to this situation. I recognize that I have caused a preemptive and likely unnecessary public stir over this, and I'm sorry for having done so. I hope you can forgive me, and I intend to do what I can to resolve this problem.

Now, on to the work:

I also noticed when working on my change that the tests succeeded whether or not I changed any code. However, I also noticed the test named should not be subject to prototype pollution via constructor also passed even when I removed the "avoid prototype pollution" if statement completely. As such, I did some research and tested some things and still couldn't reproduce the failing test. I just assumed I was doing something wrong and was not well informed on all the details of prototype pollution to be able to reproduce the issue that the test case resolves. I figured one possible situation here was that node used to have some kind of vulnerability with constructor that this test properly exercised but had been fixed sometime before my node version (20.11.1). So I ran with my assumption that I was probably actually fixing prototype pollution somewhere in some context or at least was improving this prototype pollution check in some way and created this PR. Surely GitHub was reporting this for a reason, right...? I shouldn't have made this assumption.

Now (again, I should have explained this before. I'm sorry I didn't; I'm just hoping I can correct my error as best I can moving forward), I have dedicated further effort to figuring out what prototype pollution I am supposedly fixing with my change (according to the GitHub report) to make sure this change is actually necessary. However, I still have come up short. I have read everything I can about prototype pollution via constructor, made lots of test cases, and done everything I can to try to cause prototype pollution via constructor without the if (key === '__proto__' || key === 'constructor') { return; } check. However, I cannot seem to cause prototype pollution via constructor no matter what I do. I think it is even less likely that I will figure out how to cause prototype pollution via prototype, which is the fix I implemented in the first place because of the GitHub report.

(Note snyk mentions prototype pollution via constructor very briefly in their article on it (see the "It is hard to get right" box). They even link to how lodash fixed the issue, but I couldn't figure out how to reproduce in this context based on what they fixed either)

It seems that original reference equality must be preserved for {}.constructor.prototype to cause prototype pollution (aka setting {}.constructor to something with a prototype member, which seems to be what is always happening in the test cases, doesn't cause prototype pollution). I even tried the most contrived example I could devise, creating a proxy over an empty object that causes key in target in deepmerge to pass for constructor so deepmerge would go into the original constructor for an empty object, but then it doesn't pass isPlainObject and doesn't go inside constructor. On Node v20.11.1, I cannot find any way to cause prototype pollution via constructor using deepmerge with the prototype pollution check removed.

I see a couple of possibilities as to what is happening here:

  1. I just do not understand prototype pollution well enough to reproduce it using constructor in deepmerge without the prototype pollution check. Therefore there is a missing test case that is properly fixing the constructor prototype pollution. Therefore there is possibly also an actual prototype pollution issue for prototype and there there is possibly also a missing test case for it. In this case, it would be best not to flag https://security.snyk.io/vuln/SNYK-JS-MUIUTILS-7231125 as false.
  2. There was never a problem with constructor in the first place (or it was fixed at some point by isPlainObject and is now a redundant check). Therefore there was likely never a problem with prototype in the first place, and my PR was pretty much just to keep the code checkers quiet. In this case, it would obviously be best to flag https://security.snyk.io/vuln/SNYK-JS-MUIUTILS-7231125 as false.

With all the work I have done on this so far, I am leaning toward thinking 2 is more likely. As such, I'm going to request that they close https://security.snyk.io/vuln/SNYK-JS-MUIUTILS-7231125 for now. We can always ask them to re-open it later if we find a problem.

Here are some other test cases I ran against deepmerge without any prototype pollution checks that still pass (sorry they're a bit dirty and hacky. I just tried anything I could and didn't try to make it pretty):

Prototype pollution tests with "constructor"

it('should not be subject to prototype pollution via constructor using proxies', () => {
    const proxy = new Proxy(
      {},
      {
        has(target, p) {
          if (p === 'constructor') {
            return true;
          }
          return p in target;
        },
      },
    );
    deepmerge(proxy, JSON.parse('{ "constructor" : { "prototype": { "isAdmin" : true } } }'), {
      clone: false,
    });

    expect({}).not.to.have.property('isAdmin');
  });

  it('should not be subject to prototype pollution via constructor and Object', () => {
    deepmerge(Object, JSON.parse('{ "constructor" : { "prototype": { "isAdmin" : true } } }'), {
      clone: false,
    });

    expect({}).not.to.have.property('isAdmin');
  });

  it('should not be subject to prototype pollution via prototype and Object', () => {
    deepmerge(Object, JSON.parse('{ "prototype": { "isAdmin" : true } }'), {
      clone: false,
    });

    expect({}).not.to.have.property('isAdmin');
  });

  // https://cwe.mitre.org/data/definitions/915.html
  it('should not be subject to prototype pollution via constructor 2', () => {
    deepmerge({}, JSON.parse('{ "myProperty": "a", "constructor" : { "isAdmin" : true } }'), {
      clone: false,
    });

    expect(Object).not.to.have.property('isAdmin');
  });

  // https://cwe.mitre.org/data/definitions/915.html
  it('should not be subject to prototype pollution via constructor 3', () => {
    deepmerge(
      {},
      { myProperty: 'a', constructor: () => ({ isAdmin: true }) },
      {
        clone: false,
      },
    );

    expect(Object).not.to.have.property('isAdmin');
  });

  // https://cwe.mitre.org/data/definitions/915.html
  it('should not be subject to prototype pollution via constructor and class', () => {
    const NewClass = class { constructor() {} }
    deepmerge(
      new NewClass(),
      { myProperty: 'a', constructor: () => ({ isAdmin: true }) },
      {
        clone: false,
      },
    );

    expect(Object).not.to.have.property('isAdmin');
  });

Please let me know if you have ideas regarding how to successfully cause prototype pollution using constructor on deepmerge without the prototype pollution check. That would pretty clearly show us our missing test case. Then we could do further investigation regarding the prototype property to determine if this indeed was a false positive.

DiegoAndai added a commit that referenced this pull request Jun 11, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug 🐛 Something doesn't work package: utils Specific to the @mui/utils package
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants