mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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."
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user