Skip to content

Commit 971adb5

Browse files
authored
fix: security vulnerability that allows remote code execution (GHSA-p6h4-93qp-jhcm) (#7843)
1 parent a48015c commit 971adb5

11 files changed

+445
-40
lines changed

.madgerc

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"detectiveOptions": {
3+
"ts": {
4+
"skipTypeImports": true
5+
},
6+
"es6": {
7+
"skipTypeImports": true
8+
}
9+
}
10+
}

resources/buildConfigDefinitions.js

+14
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,20 @@ function parseDefaultValue(elt, value, t) {
172172
literalValue = t.arrayExpression(array.map((value) => {
173173
if (typeof value == 'string') {
174174
return t.stringLiteral(value);
175+
} else if (typeof value == 'number') {
176+
return t.numericLiteral(value);
177+
} else if (typeof value == 'object') {
178+
const object = parsers.objectParser(value);
179+
const props = Object.entries(object).map(([k, v]) => {
180+
if (typeof v == 'string') {
181+
return t.objectProperty(t.identifier(k), t.stringLiteral(v));
182+
} else if (typeof v == 'number') {
183+
return t.objectProperty(t.identifier(k), t.numericLiteral(v));
184+
} else if (typeof v == 'boolean') {
185+
return t.objectProperty(t.identifier(k), t.booleanLiteral(v));
186+
}
187+
});
188+
return t.objectExpression(props);
175189
} else {
176190
throw new Error('Unable to parse array');
177191
}

spec/vulnerabilities.spec.js

+283
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
const request = require('../lib/request');
2+
3+
describe('Vulnerabilities', () => {
4+
describe('Object prototype pollution', () => {
5+
it('denies object prototype to be polluted with keyword "constructor"', async () => {
6+
const headers = {
7+
'Content-Type': 'application/json',
8+
'X-Parse-Application-Id': 'test',
9+
'X-Parse-REST-API-Key': 'rest',
10+
};
11+
const response = await request({
12+
headers: headers,
13+
method: 'POST',
14+
url: 'http://localhost:8378/1/classes/PP',
15+
body: JSON.stringify({
16+
obj: {
17+
constructor: {
18+
prototype: {
19+
dummy: 0,
20+
},
21+
},
22+
},
23+
}),
24+
}).catch(e => e);
25+
expect(response.status).toBe(400);
26+
const text = JSON.parse(response.text);
27+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
28+
expect(text.error).toBe('Prohibited keyword in request data: {"key":"constructor"}.');
29+
expect(Object.prototype.dummy).toBeUndefined();
30+
});
31+
32+
it('denies object prototype to be polluted with keypath string "constructor"', async () => {
33+
const headers = {
34+
'Content-Type': 'application/json',
35+
'X-Parse-Application-Id': 'test',
36+
'X-Parse-REST-API-Key': 'rest',
37+
};
38+
const objResponse = await request({
39+
headers: headers,
40+
method: 'POST',
41+
url: 'http://localhost:8378/1/classes/PP',
42+
body: JSON.stringify({
43+
obj: {},
44+
}),
45+
}).catch(e => e);
46+
const pollResponse = await request({
47+
headers: headers,
48+
method: 'PUT',
49+
url: `http://localhost:8378/1/classes/PP/${objResponse.data.objectId}`,
50+
body: JSON.stringify({
51+
'obj.constructor.prototype.dummy': {
52+
__op: 'Increment',
53+
amount: 1,
54+
},
55+
}),
56+
}).catch(e => e);
57+
expect(Object.prototype.dummy).toBeUndefined();
58+
expect(pollResponse.status).toBe(400);
59+
const text = JSON.parse(pollResponse.text);
60+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
61+
expect(text.error).toBe('Prohibited keyword in request data: {"key":"constructor"}.');
62+
expect(Object.prototype.dummy).toBeUndefined();
63+
});
64+
65+
it('denies object prototype to be polluted with keyword "__proto__"', async () => {
66+
const headers = {
67+
'Content-Type': 'application/json',
68+
'X-Parse-Application-Id': 'test',
69+
'X-Parse-REST-API-Key': 'rest',
70+
};
71+
const response = await request({
72+
headers: headers,
73+
method: 'POST',
74+
url: 'http://localhost:8378/1/classes/PP',
75+
body: JSON.stringify({ 'obj.__proto__.dummy': 0 }),
76+
}).catch(e => e);
77+
expect(response.status).toBe(400);
78+
const text = JSON.parse(response.text);
79+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
80+
expect(text.error).toBe('Prohibited keyword in request data: {"key":"__proto__"}.');
81+
expect(Object.prototype.dummy).toBeUndefined();
82+
});
83+
});
84+
85+
describe('Request denylist', () => {
86+
it('denies BSON type code data in write request by default', async () => {
87+
const headers = {
88+
'Content-Type': 'application/json',
89+
'X-Parse-Application-Id': 'test',
90+
'X-Parse-REST-API-Key': 'rest',
91+
};
92+
const params = {
93+
headers: headers,
94+
method: 'POST',
95+
url: 'http://localhost:8378/1/classes/RCE',
96+
body: JSON.stringify({
97+
obj: {
98+
_bsontype: 'Code',
99+
code: 'delete Object.prototype.evalFunctions',
100+
},
101+
}),
102+
};
103+
const response = await request(params).catch(e => e);
104+
expect(response.status).toBe(400);
105+
const text = JSON.parse(response.text);
106+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
107+
expect(text.error).toBe(
108+
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
109+
);
110+
});
111+
112+
it('allows BSON type code data in write request with custom denylist', async () => {
113+
await reconfigureServer({
114+
requestKeywordDenylist: [],
115+
});
116+
const headers = {
117+
'Content-Type': 'application/json',
118+
'X-Parse-Application-Id': 'test',
119+
'X-Parse-REST-API-Key': 'rest',
120+
};
121+
const params = {
122+
headers: headers,
123+
method: 'POST',
124+
url: 'http://localhost:8378/1/classes/RCE',
125+
body: JSON.stringify({
126+
obj: {
127+
_bsontype: 'Code',
128+
code: 'delete Object.prototype.evalFunctions',
129+
},
130+
}),
131+
};
132+
const response = await request(params).catch(e => e);
133+
expect(response.status).toBe(201);
134+
const text = JSON.parse(response.text);
135+
expect(text.objectId).toBeDefined();
136+
});
137+
138+
it('denies write request with custom denylist of key/value', async () => {
139+
await reconfigureServer({
140+
requestKeywordDenylist: [{ key: 'a[K]ey', value: 'aValue[123]*' }],
141+
});
142+
const headers = {
143+
'Content-Type': 'application/json',
144+
'X-Parse-Application-Id': 'test',
145+
'X-Parse-REST-API-Key': 'rest',
146+
};
147+
const params = {
148+
headers: headers,
149+
method: 'POST',
150+
url: 'http://localhost:8378/1/classes/RCE',
151+
body: JSON.stringify({
152+
obj: {
153+
aKey: 'aValue321',
154+
code: 'delete Object.prototype.evalFunctions',
155+
},
156+
}),
157+
};
158+
const response = await request(params).catch(e => e);
159+
expect(response.status).toBe(400);
160+
const text = JSON.parse(response.text);
161+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
162+
expect(text.error).toBe(
163+
'Prohibited keyword in request data: {"key":"a[K]ey","value":"aValue[123]*"}.'
164+
);
165+
});
166+
167+
it('denies write request with custom denylist of nested key/value', async () => {
168+
await reconfigureServer({
169+
requestKeywordDenylist: [{ key: 'a[K]ey', value: 'aValue[123]*' }],
170+
});
171+
const headers = {
172+
'Content-Type': 'application/json',
173+
'X-Parse-Application-Id': 'test',
174+
'X-Parse-REST-API-Key': 'rest',
175+
};
176+
const params = {
177+
headers: headers,
178+
method: 'POST',
179+
url: 'http://localhost:8378/1/classes/RCE',
180+
body: JSON.stringify({
181+
obj: {
182+
nested: {
183+
aKey: 'aValue321',
184+
code: 'delete Object.prototype.evalFunctions',
185+
},
186+
},
187+
}),
188+
};
189+
const response = await request(params).catch(e => e);
190+
expect(response.status).toBe(400);
191+
const text = JSON.parse(response.text);
192+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
193+
expect(text.error).toBe(
194+
'Prohibited keyword in request data: {"key":"a[K]ey","value":"aValue[123]*"}.'
195+
);
196+
});
197+
198+
it('denies write request with custom denylist of key/value in array', async () => {
199+
await reconfigureServer({
200+
requestKeywordDenylist: [{ key: 'a[K]ey', value: 'aValue[123]*' }],
201+
});
202+
const headers = {
203+
'Content-Type': 'application/json',
204+
'X-Parse-Application-Id': 'test',
205+
'X-Parse-REST-API-Key': 'rest',
206+
};
207+
const params = {
208+
headers: headers,
209+
method: 'POST',
210+
url: 'http://localhost:8378/1/classes/RCE',
211+
body: JSON.stringify({
212+
obj: [
213+
{
214+
aKey: 'aValue321',
215+
code: 'delete Object.prototype.evalFunctions',
216+
},
217+
],
218+
}),
219+
};
220+
const response = await request(params).catch(e => e);
221+
expect(response.status).toBe(400);
222+
const text = JSON.parse(response.text);
223+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
224+
expect(text.error).toBe(
225+
'Prohibited keyword in request data: {"key":"a[K]ey","value":"aValue[123]*"}.'
226+
);
227+
});
228+
229+
it('denies write request with custom denylist of key', async () => {
230+
await reconfigureServer({
231+
requestKeywordDenylist: [{ key: 'a[K]ey' }],
232+
});
233+
const headers = {
234+
'Content-Type': 'application/json',
235+
'X-Parse-Application-Id': 'test',
236+
'X-Parse-REST-API-Key': 'rest',
237+
};
238+
const params = {
239+
headers: headers,
240+
method: 'POST',
241+
url: 'http://localhost:8378/1/classes/RCE',
242+
body: JSON.stringify({
243+
obj: {
244+
aKey: 'aValue321',
245+
code: 'delete Object.prototype.evalFunctions',
246+
},
247+
}),
248+
};
249+
const response = await request(params).catch(e => e);
250+
expect(response.status).toBe(400);
251+
const text = JSON.parse(response.text);
252+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
253+
expect(text.error).toBe('Prohibited keyword in request data: {"key":"a[K]ey"}.');
254+
});
255+
256+
it('denies write request with custom denylist of value', async () => {
257+
await reconfigureServer({
258+
requestKeywordDenylist: [{ value: 'aValue[123]*' }],
259+
});
260+
const headers = {
261+
'Content-Type': 'application/json',
262+
'X-Parse-Application-Id': 'test',
263+
'X-Parse-REST-API-Key': 'rest',
264+
};
265+
const params = {
266+
headers: headers,
267+
method: 'POST',
268+
url: 'http://localhost:8378/1/classes/RCE',
269+
body: JSON.stringify({
270+
obj: {
271+
aKey: 'aValue321',
272+
code: 'delete Object.prototype.evalFunctions',
273+
},
274+
}),
275+
};
276+
const response = await request(params).catch(e => e);
277+
expect(response.status).toBe(400);
278+
const text = JSON.parse(response.text);
279+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
280+
expect(text.error).toBe('Prohibited keyword in request data: {"value":"aValue[123]*"}.');
281+
});
282+
});
283+
});

src/Config.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class Config {
3535
config.applicationId = applicationId;
3636
Object.keys(cacheInfo).forEach(key => {
3737
if (key == 'databaseController') {
38-
config.database = new DatabaseController(cacheInfo.databaseController.adapter);
38+
config.database = new DatabaseController(cacheInfo.databaseController.adapter, config);
3939
} else {
4040
config[key] = cacheInfo[key];
4141
}
@@ -78,6 +78,7 @@ export class Config {
7878
security,
7979
enforcePrivateUsers,
8080
schema,
81+
requestKeywordDenylist,
8182
}) {
8283
if (masterKey === readOnlyMasterKey) {
8384
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -116,6 +117,15 @@ export class Config {
116117
this.validateSecurityOptions(security);
117118
this.validateSchemaOptions(schema);
118119
this.validateEnforcePrivateUsers(enforcePrivateUsers);
120+
this.validateRequestKeywordDenylist(requestKeywordDenylist);
121+
}
122+
123+
static validateRequestKeywordDenylist(requestKeywordDenylist) {
124+
if (requestKeywordDenylist === undefined) {
125+
requestKeywordDenylist = requestKeywordDenylist.default;
126+
} else if (!Array.isArray(requestKeywordDenylist)) {
127+
throw 'Parse Server option requestKeywordDenylist must be an array.';
128+
}
119129
}
120130

121131
static validateEnforcePrivateUsers(enforcePrivateUsers) {

0 commit comments

Comments
 (0)