mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: Parse Server session token exfiltration via redirectClassNameForKey query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) (#10143)
This commit is contained in:
@@ -614,3 +614,104 @@ describe('RestQuery.each', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectClassNameForKey security', () => {
|
||||
let config;
|
||||
|
||||
beforeEach(() => {
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('should scope _Session results to the current user when redirected via redirectClassNameForKey', async () => {
|
||||
// Create two users with sessions (without logging out, to preserve sessions)
|
||||
const user1 = await Parse.User.signUp('user1', 'password1');
|
||||
const sessionToken1 = user1.getSessionToken();
|
||||
|
||||
// Sign up user2 via REST to avoid logging out user1
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/users',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { username: 'user2', password: 'password2' },
|
||||
});
|
||||
|
||||
// Create a public class with a relation field pointing to _Session
|
||||
// (using masterKey to create the object and relation schema)
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('pivot');
|
||||
// Add a fake pointer to _Session to establish the relation schema
|
||||
relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Authenticated user queries with redirectClassNameForKey
|
||||
const userAuth = await auth.getAuthForSessionToken({
|
||||
config,
|
||||
sessionToken: sessionToken1,
|
||||
});
|
||||
const result = await rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'pivot' });
|
||||
|
||||
// Should only see user1's own session, not user2's
|
||||
expect(result.results.length).toBe(1);
|
||||
expect(result.results[0].user.objectId).toBe(user1.id);
|
||||
});
|
||||
|
||||
it('should reject unauthenticated access to _Session via redirectClassNameForKey', async () => {
|
||||
// Create a user so a session exists
|
||||
await Parse.User.signUp('victim', 'password123');
|
||||
await Parse.User.logOut();
|
||||
|
||||
// Create a public class with a relation to _Session
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('pivot');
|
||||
relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Unauthenticated query with redirectClassNameForKey
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), 'PublicData', {}, { redirectClassNameForKey: 'pivot' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.INVALID_SESSION_TOKEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('should block redirectClassNameForKey to master-only classes', async () => {
|
||||
// Create a public class with a relation to _JobStatus (master-only)
|
||||
const obj = new Parse.Object('PublicData');
|
||||
const relation = obj.relation('jobPivot');
|
||||
relation.add(Parse.Object.fromJSON({ className: '_JobStatus', objectId: 'fakeId' }));
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
// Create a user for authenticated access
|
||||
const user = await Parse.User.signUp('attacker', 'password123');
|
||||
const sessionToken = user.getSessionToken();
|
||||
const userAuth = await auth.getAuthForSessionToken({ config, sessionToken });
|
||||
|
||||
// Authenticated query should be blocked
|
||||
await expectAsync(
|
||||
rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'jobPivot' })
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow redirectClassNameForKey between regular classes', async () => {
|
||||
// Create target class objects
|
||||
const wheel1 = new Parse.Object('Wheel');
|
||||
await wheel1.save();
|
||||
|
||||
// Create source class with relation to Wheel
|
||||
const car = new Parse.Object('Car');
|
||||
const relation = car.relation('wheels');
|
||||
relation.add(wheel1);
|
||||
await car.save();
|
||||
|
||||
// Query with redirectClassNameForKey should work normally
|
||||
const result = await rest.find(config, auth.nobody(config), 'Car', {}, { redirectClassNameForKey: 'wheels' });
|
||||
expect(result.results.length).toBe(1);
|
||||
expect(result.results[0].objectId).toBe(wheel1.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,6 +414,35 @@ _UnsafeRestQuery.prototype.redirectClassNameForKey = function () {
|
||||
.then(newClassName => {
|
||||
this.className = newClassName;
|
||||
this.redirectClassName = newClassName;
|
||||
|
||||
// Re-apply security checks for the redirected class name, since the
|
||||
// checks in the constructor and in rest.find ran against the original
|
||||
// class name before the redirect.
|
||||
if (!this.auth.isMaster) {
|
||||
enforceRoleSecurity('find', this.className, this.auth, this.config);
|
||||
|
||||
if (this.className === '_Session') {
|
||||
if (!this.auth.user) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.INVALID_SESSION_TOKEN,
|
||||
'Invalid session token',
|
||||
this.config
|
||||
);
|
||||
}
|
||||
this.restWhere = {
|
||||
$and: [
|
||||
this.restWhere,
|
||||
{
|
||||
user: {
|
||||
__type: 'Pointer',
|
||||
className: '_User',
|
||||
objectId: this.auth.user.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user