Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DEPRECATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h
| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | deprecated | - |
| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | deprecated | - |
| DEPPS12 | Database option `allowPublicExplain` will default to `true` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | deprecated | - |
| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 8.6.0 (2026) | 9.0.0 (2026) | deprecated | - |

[i_deprecation]: ## "The version and date of the deprecation."
[i_removal]: ## "The version and date of the planned removal."
Expand Down
127 changes: 127 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2987,4 +2987,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 identical', 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 identical authData
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', access_token: 'valid_token' } },
}),
});
// On update with allowExpiredAuthDataToken: true, identical data skips validation
expect(validatorSpy).not.toHaveBeenCalled();
});
});
});
5 changes: 5 additions & 0 deletions src/Deprecator/Deprecations.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ module.exports = [
{ optionKey: 'encodeParseObjectInCloudFunction', changeNewDefault: 'true' },
{ optionKey: 'enableInsecureAuthAdapters', changeNewDefault: 'false' },
{ optionKey: 'databaseOptions.allowPublicExplain', changeNewDefault: 'false' },
{
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.",
},
];
2 changes: 1 addition & 1 deletion src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,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`.',
'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,
},
Expand Down
2 changes: 1 addition & 1 deletion src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,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.
Expand Down
7 changes: 4 additions & 3 deletions src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -632,9 +632,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,
Expand Down
Loading