Skip to content

Commit 98f4ba5

Browse files
authored
fix: Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](GHSA-pfj7-wv7c-22pr)) (#10246)
1 parent 859aa65 commit 98f4ba5

File tree

7 files changed

+140
-6
lines changed

7 files changed

+140
-6
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: "Auth providers are always validated on login regardless of this setting. Set 'allowExpiredAuthDataToken' to 'false' or remove the option to accept the future removal.",
83+
},
7984
];

src/Options/Definitions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ module.exports.ParseServerOptions = {
7272
},
7373
allowExpiredAuthDataToken: {
7474
env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN',
75-
help: 'Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.',
75+
help: 'Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`.',
7676
action: parsers.booleanParser,
7777
default: false,
7878
},

src/Options/docs.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ export interface ParseServerOptions {
379379
/* Set to true if new users should be created without public read and write access.
380380
:DEFAULT: true */
381381
enforcePrivateUsers: ?boolean;
382-
/* Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.
382+
/* Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`.
383383
:DEFAULT: false */
384384
allowExpiredAuthDataToken: ?boolean;
385385
/* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.

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)