Skip to content

Commit 70b7b07

Browse files
authored
fix: Parse Server session token exfiltration via redirectClassNameForKey query parameter ([GHSA-6r2j-cxgf-495f](GHSA-6r2j-cxgf-495f)) (#10143)
1 parent c2ca77a commit 70b7b07

File tree

2 files changed

+130
-0
lines changed

2 files changed

+130
-0
lines changed

spec/RestQuery.spec.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,3 +614,104 @@ describe('RestQuery.each', () => {
614614
]);
615615
});
616616
});
617+
618+
describe('redirectClassNameForKey security', () => {
619+
let config;
620+
621+
beforeEach(() => {
622+
config = Config.get('test');
623+
});
624+
625+
it('should scope _Session results to the current user when redirected via redirectClassNameForKey', async () => {
626+
// Create two users with sessions (without logging out, to preserve sessions)
627+
const user1 = await Parse.User.signUp('user1', 'password1');
628+
const sessionToken1 = user1.getSessionToken();
629+
630+
// Sign up user2 via REST to avoid logging out user1
631+
await request({
632+
method: 'POST',
633+
url: Parse.serverURL + '/users',
634+
headers: {
635+
'X-Parse-Application-Id': Parse.applicationId,
636+
'X-Parse-REST-API-Key': 'rest',
637+
'Content-Type': 'application/json',
638+
},
639+
body: { username: 'user2', password: 'password2' },
640+
});
641+
642+
// Create a public class with a relation field pointing to _Session
643+
// (using masterKey to create the object and relation schema)
644+
const obj = new Parse.Object('PublicData');
645+
const relation = obj.relation('pivot');
646+
// Add a fake pointer to _Session to establish the relation schema
647+
relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' }));
648+
await obj.save(null, { useMasterKey: true });
649+
650+
// Authenticated user queries with redirectClassNameForKey
651+
const userAuth = await auth.getAuthForSessionToken({
652+
config,
653+
sessionToken: sessionToken1,
654+
});
655+
const result = await rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'pivot' });
656+
657+
// Should only see user1's own session, not user2's
658+
expect(result.results.length).toBe(1);
659+
expect(result.results[0].user.objectId).toBe(user1.id);
660+
});
661+
662+
it('should reject unauthenticated access to _Session via redirectClassNameForKey', async () => {
663+
// Create a user so a session exists
664+
await Parse.User.signUp('victim', 'password123');
665+
await Parse.User.logOut();
666+
667+
// Create a public class with a relation to _Session
668+
const obj = new Parse.Object('PublicData');
669+
const relation = obj.relation('pivot');
670+
relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' }));
671+
await obj.save(null, { useMasterKey: true });
672+
673+
// Unauthenticated query with redirectClassNameForKey
674+
await expectAsync(
675+
rest.find(config, auth.nobody(config), 'PublicData', {}, { redirectClassNameForKey: 'pivot' })
676+
).toBeRejectedWith(
677+
jasmine.objectContaining({ code: Parse.Error.INVALID_SESSION_TOKEN })
678+
);
679+
});
680+
681+
it('should block redirectClassNameForKey to master-only classes', async () => {
682+
// Create a public class with a relation to _JobStatus (master-only)
683+
const obj = new Parse.Object('PublicData');
684+
const relation = obj.relation('jobPivot');
685+
relation.add(Parse.Object.fromJSON({ className: '_JobStatus', objectId: 'fakeId' }));
686+
await obj.save(null, { useMasterKey: true });
687+
688+
// Create a user for authenticated access
689+
const user = await Parse.User.signUp('attacker', 'password123');
690+
const sessionToken = user.getSessionToken();
691+
const userAuth = await auth.getAuthForSessionToken({ config, sessionToken });
692+
693+
// Authenticated query should be blocked
694+
await expectAsync(
695+
rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'jobPivot' })
696+
).toBeRejectedWith(
697+
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
698+
);
699+
});
700+
701+
it('should allow redirectClassNameForKey between regular classes', async () => {
702+
// Create target class objects
703+
const wheel1 = new Parse.Object('Wheel');
704+
await wheel1.save();
705+
706+
// Create source class with relation to Wheel
707+
const car = new Parse.Object('Car');
708+
const relation = car.relation('wheels');
709+
relation.add(wheel1);
710+
await car.save();
711+
712+
// Query with redirectClassNameForKey should work normally
713+
const result = await rest.find(config, auth.nobody(config), 'Car', {}, { redirectClassNameForKey: 'wheels' });
714+
expect(result.results.length).toBe(1);
715+
expect(result.results[0].objectId).toBe(wheel1.id);
716+
});
717+
});

src/RestQuery.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,35 @@ _UnsafeRestQuery.prototype.redirectClassNameForKey = function () {
414414
.then(newClassName => {
415415
this.className = newClassName;
416416
this.redirectClassName = newClassName;
417+
418+
// Re-apply security checks for the redirected class name, since the
419+
// checks in the constructor and in rest.find ran against the original
420+
// class name before the redirect.
421+
if (!this.auth.isMaster) {
422+
enforceRoleSecurity('find', this.className, this.auth, this.config);
423+
424+
if (this.className === '_Session') {
425+
if (!this.auth.user) {
426+
throw createSanitizedError(
427+
Parse.Error.INVALID_SESSION_TOKEN,
428+
'Invalid session token',
429+
this.config
430+
);
431+
}
432+
this.restWhere = {
433+
$and: [
434+
this.restWhere,
435+
{
436+
user: {
437+
__type: 'Pointer',
438+
className: '_User',
439+
objectId: this.auth.user.id,
440+
},
441+
},
442+
],
443+
};
444+
}
445+
}
417446
});
418447
};
419448

0 commit comments

Comments
 (0)