Skip to content

Commit c50bfff

Browse files
authored
Merge pull request from GHSA-j3rv-w43q-f9x2
Fix arbitrary code execution
2 parents 09a0ca9 + 9ace906 commit c50bfff

File tree

7 files changed

+206
-19
lines changed

7 files changed

+206
-19
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 2.2.2
2+
## Fix
3+
- Add `allowFunctionEvaluation` prop to mitigate a security vulnerability
4+
- Use [`Function`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function) instead of [`eval`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval) for function evaluation
15

26
# 2.2.0
37
## Feature

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ React Editable Json Tree
22
========================
33
[![Build Status](https://travis-ci.org/oxyno-zeta/react-editable-json-tree.svg?branch=master)](https://travis-ci.org/oxyno-zeta/react-editable-json-tree)[![Build Status](https://circleci.com/gh/oxyno-zeta/react-editable-json-tree.png)](https://circleci.com/gh/oxyno-zeta/react-editable-json-tree)[![npm](https://img.shields.io/npm/v/react-editable-json-tree.svg)]()
44

5+
## ⚠ Security advisory
6+
7+
This library was previously affected by an `eval` security vulnerability.
8+
We have taken steps to mitigate this issue with non-breaking changes in this
9+
patch, v2.2.2, but for more info, please read
10+
[our security advisory](https://github.com/oxyno-zeta/react-editable-json-tree/security/advisories/GHSA-j3rv-w43q-f9x2).
11+
12+
If you do not have time to read and want to completely mitigate this issue,
13+
simply set the [allowFunctionEvaluation](#allowfunctionevaluation)
14+
prop to `false`. In the next major version, we will set this value to `false` by
15+
default.
16+
517
## Demo
618
Demo is available here : [Demo](https://oxyno-zeta.github.io/react-editable-json-tree/)
719

@@ -323,6 +335,12 @@ Function parameters :
323335
| key | Key of current node/value | String | 'string' for data: { object: { string: 'test' } } |
324336
| rawValue | Raw value from inputElement or textareaElement component | String | 'string' for data: { object: { string: 'test' } } |
325337

338+
### allowFunctionEvaluation
339+
| Key | Description | Type | Required | Default |
340+
|:-----------------------:|:-------------------------------------------------------------------------------------------------------:|:-------:|:--------:|:-------:|
341+
| allowFunctionEvaluation | Allow strings that appear to be Javascript function definitions to be evaluated as Javascript functions | Boolean | False | True |
342+
343+
326344
## Design
327345
The library provide CSS class on elements. All are prefixed by "rejt" to avoid conflict.
328346
To avoid being linked with a CSS file, the library will use the inline style.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-editable-json-tree",
3-
"version": "2.2.1",
3+
"version": "2.2.2",
44
"description": "React Editable Json Tree",
55
"main": "dist/JsonTree.js",
66
"scripts": {
@@ -21,6 +21,9 @@
2121
"url": "git@github.com:oxyno-zeta/react-editable-json-tree.git"
2222
},
2323
"author": "Oxyno-zeta",
24+
"contributors": [
25+
"Phanabani"
26+
],
2427
"license": "MIT",
2528
"devDependencies": {
2629
"autoprefixer": "^6.5.3",

src/JsonTree.js

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const propTypes = {
4949
beforeUpdateAction: PropTypes.func,
5050
logger: PropTypes.object,
5151
onSubmitValueParser: PropTypes.func,
52+
allowFunctionEvaluation: PropTypes.bool,
5253
};
5354
// Default props
5455
const defaultProps = {
@@ -75,21 +76,41 @@ const defaultProps = {
7576
beforeAddAction: (key, keyPath, deep, newValue) => new Promise(resolve => resolve()),
7677
beforeUpdateAction: (key, keyPath, deep, oldValue, newValue) => new Promise(resolve => resolve()),
7778
logger: { error: () => {} },
78-
onSubmitValueParser: (isEditMode, keyPath, deep, name, rawValue) => parse(rawValue),
7979
inputElement: (usage, keyPath, deep, keyName, data, dataType) => <input />,
8080
textareaElement: (usage, keyPath, deep, keyName, data, dataType) => <textarea />,
81-
/* eslint-enable */
81+
/* eslint-enable no-unused-vars */
82+
allowFunctionEvaluation: true,
8283
};
8384

85+
const createParsingFunction = allowFunctionEvaluation =>
86+
(isEditMode, keyPath, deep, name, rawValue) =>
87+
parse(rawValue, allowFunctionEvaluation);
88+
8489
/* ************************************* */
8590
/* ******** COMPONENT ******** */
8691
/* ************************************* */
8792
class JsonTree extends Component {
8893
constructor(props) {
8994
super(props);
95+
96+
let onSubmitValueParser;
97+
// This WasGenerated value lets us know whether we generated the parsing
98+
// function, so we can appropriately react to changes of
99+
// `allowFunctionEvaluation`
100+
let onSubmitValueParserWasGenerated;
101+
if (props.onSubmitValueParser) {
102+
onSubmitValueParser = props.onSubmitValueParser;
103+
onSubmitValueParserWasGenerated = false;
104+
} else {
105+
onSubmitValueParser = createParsingFunction(props.allowFunctionEvaluation);
106+
onSubmitValueParserWasGenerated = true;
107+
}
108+
90109
this.state = {
91110
data: props.data,
92111
rootName: props.rootName,
112+
onSubmitValueParser,
113+
onSubmitValueParserWasGenerated,
93114
};
94115
// Bind
95116
this.onUpdate = this.onUpdate.bind(this);
@@ -100,6 +121,33 @@ class JsonTree extends Component {
100121
data: nextProps.data,
101122
rootName: nextProps.rootName,
102123
});
124+
125+
let onSubmitValueParserWasGenerated = this.state.onSubmitValueParserWasGenerated;
126+
if (
127+
nextProps.onSubmitValueParser &&
128+
nextProps.onSubmitValueParser !== this.state.onSubmitValueParser
129+
) {
130+
// We just added a new submit value parser, so this is definitely
131+
// not our default parser anymore
132+
onSubmitValueParserWasGenerated = false;
133+
this.setState({
134+
onSubmitValueParser: nextProps.onSubmitValueParser,
135+
onSubmitValueParserWasGenerated,
136+
});
137+
}
138+
139+
if (
140+
onSubmitValueParserWasGenerated &&
141+
nextProps.allowFunctionEvaluation !== this.props.allowFunctionEvaluation
142+
) {
143+
// Create a new submit value parser that adheres to the new
144+
// `allowFunctionEvaluation` value as long as we know the parser
145+
// was generated by us
146+
this.setState({
147+
onSubmitValueParser:
148+
createParsingFunction(nextProps.allowFunctionEvaluation),
149+
});
150+
}
103151
}
104152

105153
onUpdate(key, data) {
@@ -112,7 +160,7 @@ class JsonTree extends Component {
112160
}
113161

114162
render() {
115-
const { data, rootName } = this.state;
163+
const { data, rootName, onSubmitValueParser } = this.state;
116164
const {
117165
isCollapsed,
118166
onDeltaUpdate,
@@ -129,7 +177,6 @@ class JsonTree extends Component {
129177
beforeAddAction,
130178
beforeUpdateAction,
131179
logger,
132-
onSubmitValueParser,
133180
} = this.props;
134181

135182
// Node type

src/components/JsonFunctionValue.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import PropTypes from 'prop-types';
1111
import { HotKeys } from 'react-hotkeys';
1212
import { isComponentWillChange } from '../utils/objectTypes';
1313
import inputUsageTypes from '../types/inputUsageTypes';
14+
import { functionToString } from '../utils/parse';
1415

1516
/* ************************************* */
1617
/* ******** VARIABLES ******** */
@@ -155,7 +156,7 @@ class JsonFunctionValue extends Component {
155156
});
156157
const textareaElementLayout = React.cloneElement(textareaElement, {
157158
ref: this.refInput,
158-
defaultValue: originalValue,
159+
defaultValue: functionToString(originalValue),
159160
});
160161

161162
result = (<span className="rejt-edit-form" style={style.editForm}>

src/components/JsonNode.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import JsonArray from './JsonArray';
1414
import JsonFunctionValue from './JsonFunctionValue';
1515
import { getObjectType } from '../utils/objectTypes';
1616
import dataTypes from '../types/dataTypes';
17+
import { functionToString } from '../utils/parse';
1718

1819
/* ************************************* */
1920
/* ******** VARIABLES ******** */
@@ -292,7 +293,7 @@ class JsonNode extends Component {
292293
case dataTypes.FUNCTION:
293294
return (<JsonFunctionValue
294295
name={name}
295-
value={data.toString()}
296+
value={functionToString(data)}
296297
originalValue={data}
297298
keyPath={keyPath}
298299
deep={deep}

src/utils/parse.js

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,38 +12,151 @@
1212
/* ******** VARIABLES ******** */
1313
/* ************************************* */
1414

15+
const basicFunctionPattern = new RegExp(
16+
// eslint-disable-next-line prefer-template
17+
''
18+
+ /^function/.source
19+
+ / *([$_a-zA-Z][$\w]*)?/.source // name
20+
+ / *\([ \n]*([$_a-zA-Z][$\w]*(?:[ \n]*,[ \n]*[$_a-zA-Z][$\w]*)*)*?,?[ \n]*\)/.source // params
21+
+ /[ \n]*{\n*(.*?)\n? *}$/.source, // body
22+
's',
23+
);
1524

1625
/* ************************************* */
1726
/* ******** PRIVATE FUNCTIONS ******** */
1827
/* ************************************* */
1928

29+
/**
30+
* Try to regex match a string as a javascript function.
31+
* @param functionString {string} string to match
32+
* @param splitParams {boolean} whether to split parameters into an array
33+
* @returns {{name: string, params: string | string[], body: string} | null}
34+
*/
35+
function matchFunction(functionString, splitParams = false) {
36+
const match = basicFunctionPattern.exec(functionString);
37+
if (match === null) return null;
38+
return {
39+
name: match[1],
40+
params: splitParams ? commaSplit(match[2]) : match[2],
41+
body: match[3],
42+
};
43+
}
44+
45+
/**
46+
* Split comma separated strings and trim surrounding whitespace.
47+
* @param string {string | undefined} a string of comma-separated strings
48+
* @returns {string[]} an array of elements that were separated by commas with
49+
* surrounding whitespace trimmed. May be empty.
50+
*/
51+
function commaSplit(string) {
52+
if (!string) return [];
53+
return string.split(',').map(x => x.trim());
54+
}
55+
56+
/**
57+
* Try creating an anonymous function from a string, or return null if it's
58+
* not a valid function definition.
59+
* Note that this is not a completely safe, there are still security flaws,
60+
* but it is safer than using `exec`.
61+
* @param functionString {string} string to try to parse as a function
62+
* definition
63+
* @returns {Function | null} an anonymous function if the string is a valid
64+
* function definition, else null
65+
*/
66+
function createFunction(functionString) {
67+
/* This is not an exhaustive check by any means
68+
* For instance, function names may have a wide variety of
69+
* unicode characters and still be valid... oh well!
70+
*
71+
* TEST CASES:
72+
*
73+
* // Should match (single-line):
74+
* function() {}
75+
* function () {}
76+
* function myFunc(){}
77+
* function myFunc(arg1){}
78+
* function(arg1,arg2, arg3, arg4) {}
79+
* function myFunc(arg1, arg2, arg3){}
80+
* function myFunc(arg1, arg2, arg3){console.log('something');}
81+
* function myFunc(arg1,){}
82+
* function myFunc(arg1, ){}
83+
* function myFunc(arg1) {if (true) {var moreCurlyBraces = 1;}}
84+
*
85+
* // Should match (multi-line):
86+
* function myFunc(arg1, arg2, arg3) {
87+
* console.log('something');
88+
* }
89+
*
90+
* function myFunc() {
91+
* console.log('test');
92+
* if (true) {
93+
* console.log('test2');
94+
* }
95+
* }
96+
*
97+
* // Should not match (single-line):
98+
* anotherFunction()
99+
* function myFunc {}
100+
* function myFunc()); (anotherFunction()
101+
* function myFunc(){}, anotherFunction()
102+
*/
103+
const match = matchFunction(functionString, true);
104+
if (!match) return null;
105+
106+
// Here's the security flaw. We want this functionality for supporting
107+
// JSONP, so we've opted for the best attempt at maintaining some amount
108+
// of security. This should be a little better than eval because it
109+
// shouldn't automatically execute code, just create a function which can
110+
// be called later.
111+
// eslint-disable-next-line no-new-func
112+
const func = new Function(...match.params, match.body || '');
113+
func.displayName = match.name;
114+
return func;
115+
}
116+
20117

21118
/* ************************************* */
22119
/* ******** PUBLIC FUNCTIONS ******** */
23120
/* ************************************* */
24121

25122
/**
26-
* Parse.
27-
* @param string {String} string to parse
28-
* @returns {*}
123+
* Parse a string into either a function or a JSON element.
124+
* @param string {string} string to parse
125+
* @param allowFunctionEvaluation {boolean} whether to parse strings that
126+
* are function definitions as Javascript
127+
* @returns {Function | Object | Array | null | boolean | number | string}
29128
*/
30-
function parse(string) {
31-
let result = string;
32-
33-
// Check if string contains 'function' and start with it to eval it
34-
if (result.indexOf('function') === 0) {
35-
return eval(`(${result})`); // eslint-disable-line no-eval
129+
function parse(string, allowFunctionEvaluation) {
130+
// Try parsing (and sanitizing) a function
131+
if (allowFunctionEvaluation) {
132+
const func = createFunction(string);
133+
if (func !== null) return func;
36134
}
37135

38136
try {
39-
result = JSON.parse(string);
137+
return JSON.parse(string);
40138
} catch (e) {
41-
// Error
139+
return string;
140+
}
141+
}
142+
143+
/**
144+
* A different implementation of Function.prototype.toString which tries to get
145+
* a function name using displayName when name is "anonymous".
146+
* @param func {Function} function to transform into a string
147+
* @returns {string} a string representing the function
148+
*/
149+
function functionToString(func) {
150+
const pattern = /^function anonymous/;
151+
const funcStr = func.toString();
152+
if (pattern.test(funcStr) && func.displayName) {
153+
return func.toString().replace(pattern, `function ${func.displayName}`);
42154
}
43-
return result;
155+
return funcStr;
44156
}
45157

46158
/* ************************************* */
47159
/* ******** EXPORTS ******** */
48160
/* ************************************* */
49161
export default parse;
162+
export { functionToString };

0 commit comments

Comments
 (0)