Skip to content

Commit bfe5ede

Browse files
committed
fix: Cloud Code trigger context prototype pollution hardening
1 parent e573cfa commit bfe5ede

2 files changed

Lines changed: 130 additions & 1 deletion

File tree

spec/vulnerabilities.spec.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5405,4 +5405,133 @@ describe('Vulnerabilities', () => {
54055405
});
54065406
});
54075407
});
5408+
5409+
describe('(GHSA-445j-ww4h-339m) Cloud Code trigger context prototype poisoning via X-Parse-Cloud-Context header', () => {
5410+
const headers = {
5411+
'Content-Type': 'application/json',
5412+
'X-Parse-Application-Id': 'test',
5413+
'X-Parse-REST-API-Key': 'rest',
5414+
};
5415+
5416+
it('accepts __proto__ in X-Parse-Cloud-Context header', async () => {
5417+
// Context is client-controlled metadata for Cloud Code triggers and is not subject
5418+
// to requestKeywordDenylist. The __proto__ key is allowed but must not cause
5419+
// prototype pollution (verified by separate tests below).
5420+
Parse.Cloud.beforeSave('ContextTest', () => {});
5421+
const response = await request({
5422+
headers: {
5423+
...headers,
5424+
'X-Parse-Cloud-Context': JSON.stringify(
5425+
JSON.parse('{"__proto__": {"isAdmin": true}}')
5426+
),
5427+
},
5428+
method: 'POST',
5429+
url: 'http://localhost:8378/1/classes/ContextTest',
5430+
body: JSON.stringify({ foo: 'bar' }),
5431+
}).catch(e => e);
5432+
expect(response.status).toBe(201);
5433+
});
5434+
5435+
it('accepts constructor in X-Parse-Cloud-Context header', async () => {
5436+
Parse.Cloud.beforeSave('ContextTest', () => {});
5437+
const response = await request({
5438+
headers: {
5439+
...headers,
5440+
'X-Parse-Cloud-Context': JSON.stringify({ constructor: { prototype: { dummy: 0 } } }),
5441+
},
5442+
method: 'POST',
5443+
url: 'http://localhost:8378/1/classes/ContextTest',
5444+
body: JSON.stringify({ foo: 'bar' }),
5445+
}).catch(e => e);
5446+
expect(response.status).toBe(201);
5447+
expect(Object.prototype.dummy).toBeUndefined();
5448+
});
5449+
5450+
it('accepts __proto__ in _context body field', async () => {
5451+
Parse.Cloud.beforeSave('ContextTest', () => {});
5452+
const response = await request({
5453+
method: 'POST',
5454+
url: 'http://localhost:8378/1/classes/ContextTest',
5455+
headers: {
5456+
'X-Parse-REST-API-Key': 'rest',
5457+
},
5458+
body: {
5459+
foo: 'bar',
5460+
_ApplicationId: 'test',
5461+
_context: JSON.stringify(JSON.parse('{"__proto__": {"isAdmin": true}}')),
5462+
},
5463+
}).catch(e => e);
5464+
expect(response.status).toBe(201);
5465+
});
5466+
5467+
it('does not pollute request.context prototype via X-Parse-Cloud-Context header', async () => {
5468+
let contextInTrigger;
5469+
Parse.Cloud.beforeSave('ContextTest', req => {
5470+
contextInTrigger = req.context;
5471+
});
5472+
await request({
5473+
headers: {
5474+
...headers,
5475+
'X-Parse-Cloud-Context': JSON.stringify(
5476+
JSON.parse('{"__proto__": {"isAdmin": true}}')
5477+
),
5478+
},
5479+
method: 'POST',
5480+
url: 'http://localhost:8378/1/classes/ContextTest',
5481+
body: JSON.stringify({ foo: 'bar' }),
5482+
}).catch(e => e);
5483+
// Verify prototype was not polluted
5484+
expect(contextInTrigger?.isAdmin).toBeUndefined();
5485+
expect(Object.getPrototypeOf(contextInTrigger || {})).not.toEqual(
5486+
jasmine.objectContaining({ isAdmin: true })
5487+
);
5488+
});
5489+
5490+
it('does not pollute request.context prototype via _context body field', async () => {
5491+
let contextInTrigger;
5492+
Parse.Cloud.beforeSave('ContextTest', req => {
5493+
contextInTrigger = req.context;
5494+
});
5495+
await request({
5496+
method: 'POST',
5497+
url: 'http://localhost:8378/1/classes/ContextTest',
5498+
headers: {
5499+
'X-Parse-REST-API-Key': 'rest',
5500+
},
5501+
body: {
5502+
foo: 'bar',
5503+
_ApplicationId: 'test',
5504+
_context: JSON.stringify(JSON.parse('{"__proto__": {"isAdmin": true}}')),
5505+
},
5506+
}).catch(e => e);
5507+
// Verify prototype was not polluted
5508+
expect(contextInTrigger?.isAdmin).toBeUndefined();
5509+
expect(Object.getPrototypeOf(contextInTrigger || {})).not.toEqual(
5510+
jasmine.objectContaining({ isAdmin: true })
5511+
);
5512+
});
5513+
5514+
it('does not allow prototype-polluted properties to survive deletion in trigger context', async () => {
5515+
// This test verifies that __proto__ pollution cannot bypass context property deletion.
5516+
// When a developer deletes a context property, prototype-polluted properties would
5517+
// survive the deletion (unlike directly set properties), creating a security gap.
5518+
let contextAfterDelete;
5519+
Parse.Cloud.beforeSave('ContextTest', req => {
5520+
delete req.context.isAdmin;
5521+
contextAfterDelete = { isAdmin: req.context.isAdmin };
5522+
});
5523+
await request({
5524+
headers: {
5525+
...headers,
5526+
'X-Parse-Cloud-Context': JSON.stringify(
5527+
JSON.parse('{"__proto__": {"isAdmin": true}}')
5528+
),
5529+
},
5530+
method: 'POST',
5531+
url: 'http://localhost:8378/1/classes/ContextTest',
5532+
body: JSON.stringify({ foo: 'bar' }),
5533+
}).catch(e => e);
5534+
expect(contextAfterDelete?.isAdmin).toBeUndefined();
5535+
});
5536+
});
54085537
});

src/triggers.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export function getRequestObject(
310310
triggerType === Types.afterFind
311311
) {
312312
// Set a copy of the context on the request object.
313-
request.context = Object.assign({}, context);
313+
request.context = Object.assign(Object.create(null), context);
314314
}
315315

316316
if (!auth) {

0 commit comments

Comments
 (0)