diff --git a/.size-limit.js b/.size-limit.js index f7763abbdf..8a1527a7fc 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -4,21 +4,21 @@ module.exports = [ { name: 'CJS', path: 'dist/lightweight-charts.production.cjs', - limit: '49.32 KB', + limit: '49.37 KB', }, { name: 'ESM', path: 'dist/lightweight-charts.production.mjs', - limit: '49.25 KB', + limit: '49.29 KB', }, { name: 'Standalone-ESM', path: 'dist/lightweight-charts.standalone.production.mjs', - limit: '50.97 KB', + limit: '51.02 KB', }, { name: 'Standalone', path: 'dist/lightweight-charts.standalone.production.js', - limit: '51.00 KB', + limit: '51.06 KB', }, ]; diff --git a/src/helpers/strict-type-checks.ts b/src/helpers/strict-type-checks.ts index 7832fa980a..40f3882cba 100644 --- a/src/helpers/strict-type-checks.ts +++ b/src/helpers/strict-type-checks.ts @@ -14,7 +14,11 @@ export function merge(dst: Record, ...sources: Record[ for (const src of sources) { // eslint-disable-next-line no-restricted-syntax for (const i in src) { - if (src[i] === undefined) { + if ( + src[i] === undefined || + !Object.prototype.hasOwnProperty.call(src, i) || + ['__proto__', 'constructor', 'prototype'].includes(i) + ) { continue; } diff --git a/tests/unittests/helpers.spec.ts b/tests/unittests/helpers.spec.ts new file mode 100644 index 0000000000..8a2b79642a --- /dev/null +++ b/tests/unittests/helpers.spec.ts @@ -0,0 +1,173 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { clone, merge } from '../../src/helpers/strict-type-checks'; + +describe('Helpers', () => { + /* eslint-disable + @typescript-eslint/unbound-method, + @typescript-eslint/no-explicit-any, + @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unused-expressions, + */ + describe('merge', () => { + it('should perform deep merge of objects', () => { + const dst = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + + const src = { + b: { + d: { + f: 4, + }, + }, + g: 5, + }; + + const result = merge(dst, src); + + expect(result).to.deep.equal({ + a: 1, + b: { + c: 2, + d: { + e: 3, + f: 4, + }, + }, + g: 5, + }); + }); + + it('should handle arrays correctly', () => { + const dst = { arr: [1, 2, 3] }; + const src = { arr: [4, 5, 6] }; + + const result = merge(dst, src); + expect(result.arr).to.deep.equal([4, 5, 6]); + }); + + it('should handle undefined values', () => { + const dst = { a: 1, b: 2 }; + const src = { b: undefined, c: 3 }; + + const result = merge(dst, src); + expect(result).to.deep.equal({ a: 1, b: 2, c: 3 }); + }); + + it('should protect against prototype pollution', () => { + const originalProto = Object.prototype.toString; + const maliciousPayload = JSON.parse( + '{"__proto__": {"polluted": true}}' + ) as Record; + + const dst = { legitimate: 'data' }; + merge(dst, maliciousPayload); + + // Check if prototype was not polluted + expect(({} as any).polluted).to.be.undefined; + expect((Object.prototype as any).polluted).to.be.undefined; + expect(Object.prototype.toString).to.equal(originalProto); + }); + + it('should handle multiple sources', () => { + const dst = { a: 1 }; + const src1 = { b: 2 }; + const src2 = { c: 3 }; + + const result = merge(dst, src1, src2); + expect(result).to.deep.equal({ a: 1, b: 2, c: 3 }); + }); + + it('should protect against constructor pollution', () => { + const dst = {}; + const malicious = JSON.parse('{ "constructor": { "prototype": { "polluted": true } } }') as Record; + + merge(dst, malicious); + + expect(({} as any).polluted).to.be.undefined; + expect((Object.prototype as any).polluted).to.be.undefined; + }); + + it('should protect against nested prototype pollution', () => { + const dst = { nested: {} }; + const malicious = JSON.parse('{"nested":{"__proto__": { "polluted": true }}}') as Record; + + merge(dst, malicious); + + expect(({} as any).polluted).to.be.undefined; + expect((Object.prototype as any).polluted).to.be.undefined; + }); + + it('should handle circular references safely', () => { + const dst: Record = { a: 1 }; + dst.circular = dst; + + const src = { b: 2 }; + + const result = merge(dst, src); + expect(result.b).to.equal(2); + expect(result.circular).to.equal(result); + }); + }); + + describe('clone', () => { + it('should deep clone objects', () => { + const original = { + a: 1, + b: { + c: 2, + d: [1, 2, { e: 3 }], + }, + }; + + const cloned = clone(original); + + expect(cloned).to.deep.equal(original); + expect(cloned).to.not.equal(original); + expect(cloned.b).to.not.equal(original.b); + expect(cloned.b.d).to.not.equal(original.b.d); + }); + + it('should handle primitive values', () => { + expect(clone(42)).to.equal(42); + expect(clone('string')).to.equal('string'); + expect(clone(null)).to.equal(null); + expect(clone(undefined)).to.equal(undefined); + }); + + it('should protect against __proto__ chain climbing', () => { + const malicious = JSON.parse('{"__proto__": {"__proto__": {"polluted": true}}}'); + + clone(malicious); + + expect(({} as any).polluted).to.be.undefined; + expect((Object.prototype as any).polluted).to.be.undefined; + }); + + it('should protect against prototype pollution', () => { + const originalProto = Object.prototype.toString; + const maliciousObject = JSON.parse('{ "__proto__": { "polluted": true }, "value": 1234 }'); + + const cloned = clone(maliciousObject); + + expect(cloned.value).to.equal(1234); + expect(({} as any).polluted).to.be.undefined; + expect((Object.prototype as any).polluted).to.be.undefined; + expect(Object.prototype.toString).to.equal(originalProto); + }); + }); + /* eslint-enable + @typescript-eslint/unbound-method, + @typescript-eslint/no-explicit-any, + @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unused-expressions, + */ +});