fix: Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) (#10246)

This commit is contained in:
Manuel
2026-03-19 18:55:08 +00:00
committed by GitHub
parent 859aa65f65
commit 98f4ba5bcf
7 changed files with 140 additions and 6 deletions
+1
View File
@@ -23,6 +23,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h
| DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - |
| 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 | - |
| DEPPS19 | Remove config option `enableProductPurchaseLegacyApi` | [#10228](https://github.com/parse-community/parse-server/pull/10228) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
[i_deprecation]: ## "The version and date of the deprecation."
[i_change]: ## "The version and date of the planned change."
+127
View File
@@ -3221,4 +3221,131 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t
obj.save({ publicField: 'changed' }, { useMasterKey: true }),
]);
});
describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => {
let validatorSpy;
const testAdapter = {
validateAppId: () => Promise.resolve(),
validateAuthData: () => Promise.resolve(),
};
beforeEach(async () => {
validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({});
await reconfigureServer({
auth: { testAdapter },
allowExpiredAuthDataToken: true,
});
});
it('validates authData on login when incoming data is a strict subset of stored data', async () => {
// Sign up a user with full authData (id + access_token)
const user = new Parse.User();
await user.save({
authData: { testAdapter: { id: 'user123', access_token: 'valid_token' } },
});
validatorSpy.calls.reset();
// Attempt to log in with only the id field (subset of stored data)
const res = await request({
method: 'POST',
url: 'http://localhost:8378/1/users',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: JSON.stringify({
authData: { testAdapter: { id: 'user123' } },
}),
});
expect(res.data.objectId).toBe(user.id);
// The adapter MUST be called to validate the login attempt
expect(validatorSpy).toHaveBeenCalled();
});
it('prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled', async () => {
// Sign up a user with full authData
const user = new Parse.User();
await user.save({
authData: { testAdapter: { id: 'victim123', access_token: 'secret_token' } },
});
validatorSpy.calls.reset();
// Simulate an attacker sending only the provider ID (no access_token)
// The adapter should reject this because the token is missing
validatorSpy.and.rejectWith(
new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid credentials')
);
const res = await request({
method: 'POST',
url: 'http://localhost:8378/1/users',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: JSON.stringify({
authData: { testAdapter: { id: 'victim123' } },
}),
}).catch(e => e);
// Login must be rejected — adapter validation must not be skipped
expect(res.status).toBe(400);
expect(validatorSpy).toHaveBeenCalled();
});
it('validates authData on login even when authData is identical', async () => {
// Sign up with full authData
const user = new Parse.User();
await user.save({
authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } },
});
validatorSpy.calls.reset();
// Log in with the exact same authData (all keys present, same values)
const res = await request({
method: 'POST',
url: 'http://localhost:8378/1/users',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
body: JSON.stringify({
authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } },
}),
});
expect(res.data.objectId).toBe(user.id);
// Auth providers are always validated on login regardless of allowExpiredAuthDataToken
expect(validatorSpy).toHaveBeenCalled();
});
it('skips validation on update when authData is a subset of stored data', async () => {
// Sign up with full authData
const user = new Parse.User();
await user.save({
authData: { testAdapter: { id: 'user789', access_token: 'valid_token' } },
});
validatorSpy.calls.reset();
// Update the user with a subset of authData (simulates afterFind stripping fields)
await request({
method: 'PUT',
url: `http://localhost:8378/1/users/${user.id}`,
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': user.getSessionToken(),
},
body: JSON.stringify({
authData: { testAdapter: { id: 'user789' } },
}),
});
// On update with allowExpiredAuthDataToken: true, subset data skips validation
expect(validatorSpy).not.toHaveBeenCalled();
});
});
});
+5
View File
@@ -76,4 +76,9 @@ module.exports = [
changeNewKey: '',
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.",
},
{
optionKey: 'allowExpiredAuthDataToken',
changeNewKey: '',
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.",
},
];
+1 -1
View File
@@ -72,7 +72,7 @@ module.exports.ParseServerOptions = {
},
allowExpiredAuthDataToken: {
env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN',
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`.',
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`.',
action: parsers.booleanParser,
default: false,
},
+1 -1
View File
@@ -15,7 +15,7 @@
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.<br><br>Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL.
* @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false
* @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId
* @property {Boolean} allowExpiredAuthDataToken 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`.
* @property {Boolean} allowExpiredAuthDataToken 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`.
* @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers
* @property {String|String[]} allowOrigin Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins.
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
+1 -1
View File
@@ -379,7 +379,7 @@ export interface ParseServerOptions {
/* Set to true if new users should be created without public read and write access.
:DEFAULT: true */
enforcePrivateUsers: ?boolean;
/* 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`.
/* 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`.
:DEFAULT: false */
allowExpiredAuthDataToken: ?boolean;
/* 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.
+4 -3
View File
@@ -639,9 +639,10 @@ RestWrite.prototype.handleAuthData = async function (authData) {
return;
}
// Force to validate all provided authData on login
// on update only validate mutated ones
if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
// Always validate all provided authData on login to prevent authentication
// bypass via partial authData (e.g. sending only the provider ID without
// an access token); on update only validate mutated ones
if (isLogin || hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) {
const res = await Auth.handleAuthDataValidation(
isLogin ? authData : mutatedAuthData,
this,