fix: Classes _GraphQLConfig and _Audience master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) (#10151)

This commit is contained in:
Manuel
2026-03-09 03:41:12 +00:00
committed by GitHub
parent 4625aefc38
commit 1de4e43ca2
4 changed files with 162 additions and 12 deletions
+12 -11
View File
@@ -36,9 +36,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -78,9 +78,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -119,9 +119,9 @@ describe('AudiencesRouter', () => {
Config.get('test');
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -159,8 +159,8 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.then(() => rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest))
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => rest.create(config, auth.master(config), '_Audience', iosAudienceRequest))
.then(() => router.handleFind(request))
.then(res => {
const response = res.response;
@@ -197,9 +197,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -421,6 +421,7 @@ describe('AudiencesRouter', () => {
await reconfigureServer({
appId: 'test',
restAPIKey: 'test',
masterKey: 'test',
publicServerURL: 'http://localhost:8378/1',
});
try {
@@ -430,7 +431,7 @@ describe('AudiencesRouter', () => {
body: { lorem: 'ipsum', _method: 'POST' },
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'test',
'X-Parse-Master-Key': 'test',
'Content-Type': 'application/json',
},
});
+1 -1
View File
@@ -1074,7 +1074,7 @@ describe('PushController', () => {
const audience = new Parse.Object('_Audience');
audience.set('name', 'testAudience');
audience.set('query', JSON.stringify(where));
await Parse.Object.saveAll(audience);
await audience.save(null, { useMasterKey: true });
await query.find({ useMasterKey: true }).then(parseResults);
const body = {
+147
View File
@@ -812,6 +812,153 @@ describe('rest create', () => {
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to perform the get operation on the _GlobalConfig collection."));
});
it('should require master key for all volatile classes', () => {
// This test guards against drift between volatileClasses (SchemaController.js)
// and classesWithMasterOnlyAccess (SharedRest.js). If a new volatile class is
// added, it must also be added to classesWithMasterOnlyAccess and this test.
const volatileClasses = [
'_JobStatus',
'_PushStatus',
'_Hooks',
'_GlobalConfig',
'_GraphQLConfig',
'_JobSchedule',
'_Audience',
'_Idempotency',
];
for (const className of volatileClasses) {
expect(() =>
rest.create(config, auth.nobody(config), className, {})
).toThrowMatching(
e => e.code === Parse.Error.OPERATION_FORBIDDEN,
`Expected ${className} to require master key`
);
}
});
it('cannot find objects in _GraphQLConfig without masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
await expectAsync(
rest.find(config, auth.nobody(config), '_GraphQLConfig', {})
).toBeRejectedWith(
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
);
});
it('cannot update object in _GraphQLConfig without masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
expect(() =>
rest.update(config, auth.nobody(config), '_GraphQLConfig', '1', {
config: { enabledForClasses: [] },
})
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('cannot delete object in _GraphQLConfig without masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
expect(() =>
rest.del(config, auth.nobody(config), '_GraphQLConfig', '1')
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('can perform operations on _GraphQLConfig with masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
const found = await rest.find(config, auth.master(config), '_GraphQLConfig', {});
expect(found.results.length).toBeGreaterThan(0);
await rest.del(config, auth.master(config), '_GraphQLConfig', '1');
const afterDelete = await rest.find(config, auth.master(config), '_GraphQLConfig', {});
expect(afterDelete.results.length).toBe(0);
});
it('cannot create object in _Audience without masterKey', () => {
expect(() =>
rest.create(config, auth.nobody(config), '_Audience', {
name: 'test',
query: '{}',
})
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('cannot find objects in _Audience without masterKey', async () => {
await expectAsync(
rest.find(config, auth.nobody(config), '_Audience', {})
).toBeRejectedWith(
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
);
});
it('cannot update object in _Audience without masterKey', async () => {
const obj = await rest.create(config, auth.master(config), '_Audience', {
name: 'test',
query: '{}',
});
expect(() =>
rest.update(config, auth.nobody(config), '_Audience', obj.response.objectId, {
name: 'updated',
})
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('cannot delete object in _Audience without masterKey', async () => {
const obj = await rest.create(config, auth.master(config), '_Audience', {
name: 'test',
query: '{}',
});
expect(() =>
rest.del(config, auth.nobody(config), '_Audience', obj.response.objectId)
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('can perform CRUD on _Audience with masterKey', async () => {
const obj = await rest.create(config, auth.master(config), '_Audience', {
name: 'test',
query: '{}',
});
expect(obj.response.objectId).toBeDefined();
const found = await rest.find(config, auth.master(config), '_Audience', {});
expect(found.results.length).toBeGreaterThan(0);
await rest.del(config, auth.master(config), '_Audience', obj.response.objectId);
const afterDelete = await rest.find(config, auth.master(config), '_Audience', {});
expect(afterDelete.results.length).toBe(0);
});
it('cannot access _GraphQLConfig via class route without masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
try {
await request({
url: 'http://localhost:8378/1/classes/_GraphQLConfig',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});
it('cannot access _Audience via class route without masterKey', async () => {
await rest.create(config, auth.master(config), '_Audience', {
name: 'test',
query: '{}',
});
try {
await request({
url: 'http://localhost:8378/1/classes/_Audience',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});
it('locks down session', done => {
let currentUser;
Parse.User.signUp('foo', 'bar')
+2
View File
@@ -3,7 +3,9 @@ const classesWithMasterOnlyAccess = [
'_PushStatus',
'_Hooks',
'_GlobalConfig',
'_GraphQLConfig',
'_JobSchedule',
'_Audience',
'_Idempotency',
];
const { createSanitizedError } = require('./Error');