Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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."
Expand Down
127 changes: 127 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 5 additions & 0 deletions src/Deprecator/Deprecations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "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.",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];
7 changes: 4 additions & 3 deletions src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading