Skip to content

Commit 0aee2f6

Browse files
authored
fix: two security vulnerabilities allowing execution of arbitrary JavaScript via the expression parser (#3656)
* fix: validate that the `index` in the `.get()` methods is an Array * fix: improve the internal `setSafeProperty` to not allow setting properties other than numeric indices or `length` on arrays
1 parent f7c10b1 commit 0aee2f6

8 files changed

Lines changed: 233 additions & 89 deletions

File tree

src/utils/array.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,9 @@ export function stretch (arrayToStretch, sizeToStretch, dimToStretch) {
844844
*/
845845
export function get (array, index) {
846846
if (!Array.isArray(array)) { throw new Error('Array expected') }
847+
if (!Array.isArray(index)) {
848+
throw new Error('Array expected for index')
849+
}
847850
const size = arraySize(array)
848851
if (index.length !== size.length) { throw new DimensionError(index.length, size.length) }
849852
for (let x = 0; x < index.length; x++) { validateIndex(index[x], size[x]) }

src/utils/customs.js

Lines changed: 61 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,83 @@
11
import { hasOwnProperty } from './object.js'
22

33
/**
4-
* Get a property of a plain object
4+
* Get a property of a plain object or array
55
* Throws an error in case the object is not a plain object or the
66
* property is not defined on the object itself
77
* @param {Object} object
88
* @param {string} prop
99
* @return {*} Returns the property value when safe
1010
*/
11-
function getSafeProperty (object, prop) {
12-
// only allow getting safe properties of a plain object
13-
if (isSafeProperty(object, prop)) {
11+
export function getSafeProperty (object, prop) {
12+
if (isSafeObjectProperty(object, prop) || isSafeArrayProperty(object, prop)) {
1413
return object[prop]
1514
}
1615

17-
if (typeof object[prop] === 'function' && isSafeMethod(object, prop)) {
18-
throw new Error('Cannot access method "' + prop + '" as a property')
16+
if (isSafeMethod(object, prop)) {
17+
throw new Error(`Cannot access method "${prop}" as a property`)
18+
}
19+
20+
if (object === null || object === undefined) {
21+
throw new TypeError(`Cannot access property "${prop}": object is ${object}`)
1922
}
2023

2124
throw new Error('No access to property "' + prop + '"')
2225
}
2326

2427
/**
25-
* Set a property on a plain object.
28+
* Set a property on a plain object or array.
2629
* Throws an error in case the object is not a plain object or the
2730
* property would override an inherited property like .constructor or .toString
2831
* @param {Object} object
2932
* @param {string} prop
3033
* @param {*} value
3134
* @return {*} Returns the value
3235
*/
33-
// TODO: merge this function into access.js?
34-
function setSafeProperty (object, prop, value) {
35-
// only allow setting safe properties of a plain object
36-
if (isSafeProperty(object, prop)) {
36+
export function setSafeProperty (object, prop, value) {
37+
if (isSafeObjectProperty(object, prop) || isSafeArrayProperty(object, prop)) {
3738
object[prop] = value
3839
return value
3940
}
4041

41-
throw new Error('No access to property "' + prop + '"')
42+
throw new Error(`No access to property "${prop}"`)
4243
}
4344

4445
/**
45-
* Test whether a property is safe to use on an object or Array.
46-
* For example .toString and .constructor are not safe
47-
* @param {Object | Array} object
46+
* Test whether a property is safe for reading and writing on an object
47+
* For example .constructor and .__proto__ are not safe
48+
* @param {Object} object
4849
* @param {string} prop
4950
* @return {boolean} Returns true when safe
5051
*/
51-
function isSafeProperty (object, prop) {
52-
if (!isPlainObject(object) && !Array.isArray(object)) {
52+
export function isSafeObjectProperty (object, prop) {
53+
if (!isPlainObject(object)) {
5354
return false
5455
}
55-
// SAFE: whitelisted
56-
// e.g length
57-
if (hasOwnProperty(safeNativeProperties, prop)) {
58-
return true
59-
}
60-
// UNSAFE: inherited from Object prototype
61-
// e.g constructor
62-
if (prop in Object.prototype) {
63-
// 'in' is used instead of hasOwnProperty for nodejs v0.10
64-
// which is inconsistent on root prototypes. It is safe
65-
// here because Object.prototype is a root object
66-
return false
67-
}
68-
// UNSAFE: inherited from Function prototype
69-
// e.g call, apply
70-
if (prop in Function.prototype) {
71-
// 'in' is used instead of hasOwnProperty for nodejs v0.10
72-
// which is inconsistent on root prototypes. It is safe
73-
// here because Function.prototype is a root object
56+
57+
return !(prop in Object.prototype)
58+
}
59+
60+
/**
61+
* Test whether a property is safe for reading and writing on an Array
62+
* For example .__proto__ and .constructor are not safe
63+
* @param {unknown} array
64+
* @param {string | number} prop
65+
* @return {boolean} Returns true when safe
66+
*/
67+
export function isSafeArrayProperty (array, prop) {
68+
if (!Array.isArray(array)) {
7469
return false
7570
}
76-
return true
71+
72+
return (
73+
typeof prop === 'number' ||
74+
(typeof prop === 'string' && isInteger(prop)) ||
75+
prop === 'length'
76+
)
77+
}
78+
79+
function isInteger (prop) {
80+
return /^\d+$/.test(prop)
7781
}
7882

7983
/**
@@ -83,7 +87,7 @@ function isSafeProperty (object, prop) {
8387
* @param {string} method
8488
* @return {function} Returns the method when valid
8589
*/
86-
function getSafeMethod (object, method) {
90+
export function getSafeMethod (object, method) {
8791
if (!isSafeMethod(object, method)) {
8892
throw new Error('No access to method "' + method + '"')
8993
}
@@ -98,22 +102,32 @@ function getSafeMethod (object, method) {
98102
* @param {string} method
99103
* @return {boolean} Returns true when safe, false otherwise
100104
*/
101-
function isSafeMethod (object, method) {
102-
if (object === null || object === undefined || typeof object[method] !== 'function') {
105+
export function isSafeMethod (object, method) {
106+
if (
107+
object === null ||
108+
object === undefined ||
109+
typeof object[method] !== 'function'
110+
) {
103111
return false
104112
}
113+
105114
// UNSAFE: ghosted
106115
// e.g overridden toString
107116
// Note that IE10 doesn't support __proto__ and we can't do this check there.
108-
if (hasOwnProperty(object, method) &&
109-
(Object.getPrototypeOf && (method in Object.getPrototypeOf(object)))) {
117+
if (
118+
hasOwnProperty(object, method) &&
119+
Object.getPrototypeOf &&
120+
method in Object.getPrototypeOf(object)
121+
) {
110122
return false
111123
}
124+
112125
// SAFE: whitelisted
113126
// e.g toString
114-
if (hasOwnProperty(safeNativeMethods, method)) {
127+
if (safeNativeMethods.has(method)) {
115128
return true
116129
}
130+
117131
// UNSAFE: inherited from Object prototype
118132
// e.g constructor
119133
if (method in Object.prototype) {
@@ -122,6 +136,7 @@ function isSafeMethod (object, method) {
122136
// here because Object.prototype is a root object
123137
return false
124138
}
139+
125140
// UNSAFE: inherited from Function prototype
126141
// e.g call, apply
127142
if (method in Function.prototype) {
@@ -130,27 +145,12 @@ function isSafeMethod (object, method) {
130145
// here because Function.prototype is a root object
131146
return false
132147
}
148+
133149
return true
134150
}
135151

136-
function isPlainObject (object) {
152+
export function isPlainObject (object) {
137153
return typeof object === 'object' && object && object.constructor === Object
138154
}
139155

140-
const safeNativeProperties = {
141-
length: true,
142-
name: true
143-
}
144-
145-
const safeNativeMethods = {
146-
toString: true,
147-
valueOf: true,
148-
toLocaleString: true
149-
}
150-
151-
export { getSafeProperty }
152-
export { setSafeProperty }
153-
export { isSafeProperty }
154-
export { getSafeMethod }
155-
export { isSafeMethod }
156-
export { isPlainObject }
156+
const safeNativeMethods = new Set(['toString', 'valueOf', 'toLocaleString'])

src/utils/map.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { getSafeProperty, isSafeProperty, setSafeProperty } from './customs.js'
1+
import {
2+
getSafeProperty,
3+
isSafeObjectProperty,
4+
setSafeProperty
5+
} from './customs.js'
26
import { isMap, isObject } from './is.js'
37

48
/**
@@ -32,7 +36,7 @@ export class ObjectWrappingMap {
3236
}
3337

3438
has (key) {
35-
return isSafeProperty(this.wrappedObject, key) && key in this.wrappedObject
39+
return isSafeObjectProperty(this.wrappedObject, key) && key in this.wrappedObject
3640
}
3741

3842
entries () {
@@ -46,7 +50,7 @@ export class ObjectWrappingMap {
4650
}
4751

4852
delete (key) {
49-
if (isSafeProperty(this.wrappedObject, key)) {
53+
if (isSafeObjectProperty(this.wrappedObject, key)) {
5054
delete this.wrappedObject[key]
5155
}
5256
}

test/unit-tests/expression/security.test.js

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,55 @@ describe('security', function () {
7676
it('should not allow calling Function via Object.assign', function () {
7777
// TODO: simplify this test case, let it output console.log('hacked...')
7878
assert.throws(function () {
79-
math.evaluate('{}.constructor.assign(cos.constructor, {binding: cos.bind})\n' +
79+
math.evaluate(
80+
'{}.constructor.assign(cos.constructor, {binding: cos.bind})\n' +
8081
'{}.constructor.assign(cos.constructor, {bind: null})\n' +
8182
'f=cos.constructor.binding()("console.log(\'hacked...\')")\n' +
82-
'f()')
83-
}, /Error: No access to property "bind/)
83+
'f()'
84+
)
85+
}, /Error: No access to property "constructor/)
86+
})
87+
88+
it('should not allow misusing setSafeProperty to set properties on Arrays', function () {
89+
assert.throws(function () {
90+
const exploit = `
91+
constantNode = reviver('',{'mathjs':'ConstantNode','value':1});
92+
93+
# get a reference to a raw JavaScript array
94+
array = reviver('',{'mathjs':'ArrayNode'}).map().toJSON()['items'];
95+
array.push({'name':'a','type':{}});
96+
97+
# override params.map to hijack this.params and this.types
98+
array.map = f(callback)=callback({'name':{'name':'a','map':f2(callback2)=callback2({},'constructor')},'type':cos});
99+
100+
# trigger callback and get the pointer to Function.constructor
101+
functionAssignmentNode = reviver('',{'mathjs':'FunctionAssignmentNode','name':'a','params':array,'expr':constantNode});
102+
func = functionAssignmentNode.toJSON()['params']['type'];
103+
104+
getProcess = func('return process');
105+
getProcess()`
106+
107+
console.log(
108+
'Hacked! node.js version:',
109+
math.evaluate(exploit).entries[0].version
110+
)
111+
}, /Error: No access to property "map"/)
112+
})
113+
114+
it('should not allow getting access to constructor via DenseMatrix.get index argument', function () {
115+
assert.throws(function () {
116+
const exploit = `
117+
m = matrix();
118+
func = m.get({"length":1,"reduce":f(callback,a)=callback(cos,"constructor")});
119+
getProcess = func("return process");
120+
getProcess()
121+
`
122+
123+
console.log(
124+
'Hacked! node.js version:',
125+
math.evaluate(exploit).entries[0].version
126+
)
127+
}, /Error: Array expected for index/)
84128
})
85129

86130
it('should not allow disguising forbidden properties with unicode characters', function () {

test/unit-tests/type/matrix/DenseMatrix.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,16 @@ describe('DenseMatrix', function () {
440440
assert.throws(function () { m.get(math.index(1, 1)) })
441441
assert.throws(function () { m.get([[1, 1]]) })
442442
})
443+
444+
it('should throw an error when getting a value given an index that is not an array', function () {
445+
assert.throws(function () {
446+
m.get({ length: 1, reduce: () => {} })
447+
}, /Error: Array expected for index/)
448+
449+
assert.throws(function () {
450+
m.get(new Date())
451+
}, /Error: Array expected for index/)
452+
})
443453
})
444454

445455
describe('set', function () {

test/unit-tests/type/matrix/SparseMatrix.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,21 @@ describe('SparseMatrix', function () {
730730
assert.strictEqual(m.get([4, 5]), 13)
731731
assert.strictEqual(m.get([5, 5]), -1)
732732
})
733+
734+
it('should throw an error when getting a value given an index that is not an array', function () {
735+
const m = new SparseMatrix([
736+
[1, 2],
737+
[3, 4]
738+
])
739+
740+
assert.throws(function () {
741+
m.get({ length: 1, reduce: () => {} })
742+
}, /Error: Array expected/)
743+
744+
assert.throws(function () {
745+
m.get(new Date())
746+
}, /Error: Array expected/)
747+
})
733748
})
734749

735750
describe('set', function () {

test/unit-tests/utils/array.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,16 @@ describe('util.array', function () {
678678
assert.throws(function () { get(m, null) })
679679
assert.throws(function () { get(m, [[1, 1]]) })
680680
})
681+
682+
it('should throw an error when getting a value given an index that is not an array', function () {
683+
assert.throws(function () {
684+
get(m, { length: 1, reduce: () => {} })
685+
}, /Error: Array expected for index/)
686+
687+
assert.throws(function () {
688+
get(m, new Date())
689+
}, /Error: Array expected for index/)
690+
})
681691
})
682692

683693
describe('checkBroadcastingRules', function () {

0 commit comments

Comments
 (0)