Skip to content

Commit 146c5d5

Browse files
committed
fix: Auth provider validation bypass on login via partial authData (GHSA-pfj7-wv7c-22pr)
1 parent 859aa65 commit 146c5d5

File tree

4 files changed

+137
-3
lines changed

4 files changed

+137
-3
lines changed

DEPRECATIONS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h
2323
| DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - |
2424
| DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
2525
| DEPPS19 | Remove config option `enableProductPurchaseLegacyApi` | [#10228](https://github.com/parse-community/parse-server/pull/10228) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
26+
| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
2627

2728
[i_deprecation]: ## "The version and date of the deprecation."
2829
[i_change]: ## "The version and date of the planned change."

spec/vulnerabilities.spec.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3221,4 +3221,131 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t
32213221
obj.save({ publicField: 'changed' }, { useMasterKey: true }),
32223222
]);
32233223
});
3224+
3225+
describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => {
3226+
let validatorSpy;
3227+
3228+
const testAdapter = {
3229+
validateAppId: () => Promise.resolve(),
3230+
validateAuthData: () => Promise.resolve(),
3231+
};
3232+
3233+
beforeEach(async () => {
3234+
validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({});
3235+
await reconfigureServer({
3236+
auth: { testAdapter },
3237+
allowExpiredAuthDataToken: true,
3238+
});
3239+
});
3240+
3241+
it('validates authData on login when incoming data is a strict subset of stored data', async () => {
3242+
// Sign up a user with full authData (id + access_token)
3243+
const user = new Parse.User();
3244+
await user.save({
3245+
authData: { testAdapter: { id: 'user123', access_token: 'valid_token' } },
3246+
});
3247+
validatorSpy.calls.reset();
3248+
3249+
// Attempt to log in with only the id field (subset of stored data)
3250+
const res = await request({
3251+
method: 'POST',
3252+
url: 'http://localhost:8378/1/users',
3253+
headers: {
3254+
'Content-Type': 'application/json',
3255+
'X-Parse-Application-Id': 'test',
3256+
'X-Parse-REST-API-Key': 'rest',
3257+
},
3258+
body: JSON.stringify({
3259+
authData: { testAdapter: { id: 'user123' } },
3260+
}),
3261+
});
3262+
expect(res.data.objectId).toBe(user.id);
3263+
// The adapter MUST be called to validate the login attempt
3264+
expect(validatorSpy).toHaveBeenCalled();
3265+
});
3266+
3267+
it('prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled', async () => {
3268+
// Sign up a user with full authData
3269+
const user = new Parse.User();
3270+
await user.save({
3271+
authData: { testAdapter: { id: 'victim123', access_token: 'secret_token' } },
3272+
});
3273+
validatorSpy.calls.reset();
3274+
3275+
// Simulate an attacker sending only the provider ID (no access_token)
3276+
// The adapter should reject this because the token is missing
3277+
validatorSpy.and.rejectWith(
3278+
new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid credentials')
3279+
);
3280+
3281+
const res = await request({
3282+
method: 'POST',
3283+
url: 'http://localhost:8378/1/users',
3284+
headers: {
3285+
'Content-Type': 'application/json',
3286+
'X-Parse-Application-Id': 'test',
3287+
'X-Parse-REST-API-Key': 'rest',
3288+
},
3289+
body: JSON.stringify({
3290+
authData: { testAdapter: { id: 'victim123' } },
3291+
}),
3292+
}).catch(e => e);
3293+
3294+
// Login must be rejected — adapter validation must not be skipped
3295+
expect(res.status).toBe(400);
3296+
expect(validatorSpy).toHaveBeenCalled();
3297+
});
3298+
3299+
it('validates authData on login even when authData is identical', async () => {
3300+
// Sign up with full authData
3301+
const user = new Parse.User();
3302+
await user.save({
3303+
authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } },
3304+
});
3305+
validatorSpy.calls.reset();
3306+
3307+
// Log in with the exact same authData (all keys present, same values)
3308+
const res = await request({
3309+
method: 'POST',
3310+
url: 'http://localhost:8378/1/users',
3311+
headers: {
3312+
'Content-Type': 'application/json',
3313+
'X-Parse-Application-Id': 'test',
3314+
'X-Parse-REST-API-Key': 'rest',
3315+
},
3316+
body: JSON.stringify({
3317+
authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } },
3318+
}),
3319+
});
3320+
expect(res.data.objectId).toBe(user.id);
3321+
// Auth providers are always validated on login regardless of allowExpiredAuthDataToken
3322+
expect(validatorSpy).toHaveBeenCalled();
3323+
});
3324+
3325+
it('skips validation on update when authData is a subset of stored data', async () => {
3326+
// Sign up with full authData
3327+
const user = new Parse.User();
3328+
await user.save({
3329+
authData: { testAdapter: { id: 'user789', access_token: 'valid_token' } },
3330+
});
3331+
validatorSpy.calls.reset();
3332+
3333+
// Update the user with a subset of authData (simulates afterFind stripping fields)
3334+
await request({
3335+
method: 'PUT',
3336+
url: `http://localhost:8378/1/users/${user.id}`,
3337+
headers: {
3338+
'Content-Type': 'application/json',
3339+
'X-Parse-Application-Id': 'test',
3340+
'X-Parse-REST-API-Key': 'rest',
3341+
'X-Parse-Session-Token': user.getSessionToken(),
3342+
},
3343+
body: JSON.stringify({
3344+
authData: { testAdapter: { id: 'user789' } },
3345+
}),
3346+
});
3347+
// On update with allowExpiredAuthDataToken: true, subset data skips validation
3348+
expect(validatorSpy).not.toHaveBeenCalled();
3349+
});
3350+
});
32243351
});

src/Deprecator/Deprecations.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,9 @@ module.exports = [
7676
changeNewKey: '',
7777
solution: "The product purchase API is an undocumented, unmaintained legacy feature that may not function as expected and will be removed in a future major version. We strongly advise against using it. Set 'enableProductPurchaseLegacyApi' to 'false' to disable it, or remove the option to accept the future removal.",
7878
},
79+
{
80+
optionKey: 'allowExpiredAuthDataToken',
81+
changeNewKey: '',
82+
solution: "This option has a limited effect since auth providers are now always validated on login regardless of this setting. Set 'allowExpiredAuthDataToken' to 'false' or remove the option to accept the future removal.",
83+
},
7984
];

src/RestWrite.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -639,9 +639,10 @@ RestWrite.prototype.handleAuthData = async function (authData) {
639639
return;
640640
}
641641

642-
// Force to validate all provided authData on login
643-
// on update only validate mutated ones
644-
if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
642+
// Always validate all provided authData on login to prevent authentication
643+
// bypass via partial authData (e.g. sending only the provider ID without
644+
// an access token); on update only validate mutated ones
645+
if (isLogin || hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
645646
const res = await Auth.handleAuthDataValidation(
646647
isLogin ? authData : mutatedAuthData,
647648
this,

0 commit comments

Comments
 (0)