Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5405,4 +5405,137 @@ describe('Vulnerabilities', () => {
});
});
});

describe('(GHSA-445j-ww4h-339m) Cloud Code trigger context prototype poisoning via X-Parse-Cloud-Context header', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};

it('accepts __proto__ in X-Parse-Cloud-Context header', async () => {
// Context is client-controlled metadata for Cloud Code triggers and is not subject
// to requestKeywordDenylist. The __proto__ key is allowed but must not cause
// prototype pollution (verified by separate tests below).
Parse.Cloud.beforeSave('ContextTest', () => {});
const response = await request({
headers: {
...headers,
'X-Parse-Cloud-Context': JSON.stringify(
JSON.parse('{"__proto__": {"isAdmin": true}}')
),
},
method: 'POST',
url: 'http://localhost:8378/1/classes/ContextTest',
body: JSON.stringify({ foo: 'bar' }),
}).catch(e => e);
expect(response.status).toBe(201);
});

it('accepts constructor in X-Parse-Cloud-Context header', async () => {
Parse.Cloud.beforeSave('ContextTest', () => {});
const response = await request({
headers: {
...headers,
'X-Parse-Cloud-Context': JSON.stringify({ constructor: { prototype: { dummy: 0 } } }),
},
method: 'POST',
url: 'http://localhost:8378/1/classes/ContextTest',
body: JSON.stringify({ foo: 'bar' }),
}).catch(e => e);
expect(response.status).toBe(201);
expect(Object.prototype.dummy).toBeUndefined();
});

it('accepts __proto__ in _context body field', async () => {
Parse.Cloud.beforeSave('ContextTest', () => {});
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/classes/ContextTest',
headers: {
'X-Parse-REST-API-Key': 'rest',
},
body: {
foo: 'bar',
_ApplicationId: 'test',
_context: JSON.stringify(JSON.parse('{"__proto__": {"isAdmin": true}}')),
},
}).catch(e => e);
expect(response.status).toBe(201);
});

it('does not pollute request.context prototype via X-Parse-Cloud-Context header', async () => {
let contextInTrigger;
Parse.Cloud.beforeSave('ContextTest', req => {
contextInTrigger = req.context;
});
const response = await request({
headers: {
...headers,
'X-Parse-Cloud-Context': JSON.stringify(
JSON.parse('{"__proto__": {"isAdmin": true}}')
),
},
method: 'POST',
url: 'http://localhost:8378/1/classes/ContextTest',
body: JSON.stringify({ foo: 'bar' }),
}).catch(e => e);
expect(response.status).toBe(201);
expect(contextInTrigger).toBeDefined();
expect(contextInTrigger.isAdmin).toBeUndefined();
expect(Object.getPrototypeOf(contextInTrigger)).not.toEqual(
jasmine.objectContaining({ isAdmin: true })
);
});

it('does not pollute request.context prototype via _context body field', async () => {
let contextInTrigger;
Parse.Cloud.beforeSave('ContextTest', req => {
contextInTrigger = req.context;
});
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/classes/ContextTest',
headers: {
'X-Parse-REST-API-Key': 'rest',
},
body: {
foo: 'bar',
_ApplicationId: 'test',
_context: JSON.stringify(JSON.parse('{"__proto__": {"isAdmin": true}}')),
},
}).catch(e => e);
expect(response.status).toBe(201);
expect(contextInTrigger).toBeDefined();
expect(contextInTrigger.isAdmin).toBeUndefined();
expect(Object.getPrototypeOf(contextInTrigger)).not.toEqual(
jasmine.objectContaining({ isAdmin: true })
);
});

it('does not allow prototype-polluted properties to survive deletion in trigger context', async () => {
// This test verifies that __proto__ pollution cannot bypass context property deletion.
// When a developer deletes a context property, prototype-polluted properties would
// survive the deletion (unlike directly set properties), creating a security gap.
let contextAfterDelete;
Parse.Cloud.beforeSave('ContextTest', req => {
delete req.context.isAdmin;
contextAfterDelete = { isAdmin: req.context.isAdmin };
});
const response = await request({
headers: {
...headers,
'X-Parse-Cloud-Context': JSON.stringify(
JSON.parse('{"__proto__": {"isAdmin": true}}')
),
},
method: 'POST',
url: 'http://localhost:8378/1/classes/ContextTest',
body: JSON.stringify({ foo: 'bar' }),
}).catch(e => e);
expect(response.status).toBe(201);
expect(contextAfterDelete).toBeDefined();
expect(contextAfterDelete.isAdmin).toBeUndefined();
});
});
});
2 changes: 1 addition & 1 deletion src/triggers.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export function getRequestObject(
triggerType === Types.afterFind
) {
// Set a copy of the context on the request object.
request.context = Object.assign({}, context);
request.context = Object.assign(Object.create(null), context);
}

if (!auth) {
Expand Down
Loading