fix: Endpoints /login and /verifyPassword disclose MFA secrets and protected fields when _User get is denied ([GHSA-75v4-m273-5j49](https://github.com/parse-community/parse-server/security/advisories/GHSA-75v4-m273-5j49)) (#10492)

This commit is contained in:
Manuel
2026-06-03 16:41:16 +02:00
committed by GitHub
parent 2b9d93d224
commit 83e90edbe4
2 changed files with 163 additions and 4 deletions
+138
View File
@@ -6151,4 +6151,142 @@ describe('Vulnerabilities', () => {
expect(req.info.clientSDK).toBeUndefined();
});
});
describe('(GHSA-75v4-m273-5j49) _User CLP refetch fallback leaks raw MFA secrets and protected fields', () => {
const headers = {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
};
const denyGetCLP = {
get: {},
find: {},
create: { '*': true },
update: { '*': true },
delete: {},
};
const updateUserCLP = classLevelPermissions =>
request({
method: 'PUT',
url: Parse.serverURL + '/schemas/_User',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'Content-Type': 'application/json',
},
body: JSON.stringify({ classLevelPermissions }),
});
async function setupMfaUser() {
const OTPAuth = require('otpauth');
const user = await Parse.User.signUp('victim', 'password');
const sessionToken = user.getSessionToken();
user.set('phone', '555-1234');
await user.save(null, { sessionToken });
const secret = new OTPAuth.Secret();
const totp = new OTPAuth.TOTP({ algorithm: 'SHA1', digits: 6, period: 30, secret });
await user.save(
{ authData: { mfa: { secret: secret.base32, token: totp.generate() } } },
{ sessionToken }
);
return { user, totp, secret };
}
beforeEach(async () => {
await reconfigureServer({
auth: {
mfa: { enabled: true, options: ['TOTP'], algorithm: 'SHA1', digits: 6, period: 30 },
},
protectedFields: { _User: { '*': ['phone'] } },
protectedFieldsOwnerExempt: false,
});
});
it('does not leak raw MFA secrets or protected fields from /verifyPassword when _User get CLP denies the re-fetch', async () => {
await setupMfaUser();
await updateUserCLP(denyGetCLP);
const response = await request({
method: 'POST',
url: Parse.serverURL + '/verifyPassword',
headers,
body: JSON.stringify({ username: 'victim', password: 'password' }),
});
expect(response.status).toBe(200);
expect(response.data.objectId).toBeDefined();
// Access control denied the re-fetch, so no stored fields may be disclosed
expect(response.data.authData).toBeUndefined();
expect(response.data.phone).toBeUndefined();
});
it('does not leak raw MFA secrets or protected fields from /login when _User get CLP denies the re-fetch', async () => {
const { totp } = await setupMfaUser();
await updateUserCLP(denyGetCLP);
const response = await request({
method: 'POST',
url: Parse.serverURL + '/login',
headers,
body: JSON.stringify({
username: 'victim',
password: 'password',
authData: { mfa: { token: totp.generate() } },
}),
});
expect(response.status).toBe(200);
// Login still succeeds and issues a session for the authenticated user
expect(response.data.objectId).toBeDefined();
expect(response.data.sessionToken).toBeDefined();
// But discloses no stored fields the caller may not read
expect(response.data.authData).toBeUndefined();
expect(response.data.phone).toBeUndefined();
});
it('sanitizes MFA secrets and protected fields on /verifyPassword when get CLP permits the re-fetch', async () => {
await setupMfaUser();
const response = await request({
method: 'POST',
url: Parse.serverURL + '/verifyPassword',
headers,
body: JSON.stringify({ username: 'victim', password: 'password' }),
});
expect(response.status).toBe(200);
expect(response.data.objectId).toBeDefined();
// afterFind replaces raw MFA material with a status flag
expect(response.data.authData.mfa.status).toBe('enabled');
expect(response.data.authData.mfa.secret).toBeUndefined();
expect(response.data.authData.mfa.recovery).toBeUndefined();
// protectedFieldsOwnerExempt:false strips protected fields even for the owner
expect(response.data.phone).toBeUndefined();
});
it('returns the full user to a master-key /verifyPassword even when get CLP is denied', async () => {
await setupMfaUser();
await updateUserCLP(denyGetCLP);
const response = await request({
method: 'POST',
url: Parse.serverURL + '/verifyPassword',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'Content-Type': 'application/json',
},
body: JSON.stringify({ username: 'victim', password: 'password' }),
});
expect(response.status).toBe(200);
expect(response.data.objectId).toBeDefined();
// Master bypasses CLP and protectedFields by design, so it still receives
// the full record (auth hierarchy preserved); the minimal denied-path
// response only applies to non-master callers.
expect(response.data.phone).toBe('555-1234');
});
});
});
+25 -4
View File
@@ -370,10 +370,21 @@ export class UsersRouter extends ClassesRouter {
);
filteredUser = filteredUserResponse.results?.[0];
} catch {
// re-fetch may fail for legacy users without ACL; fall through
// The re-fetch enforces `_User` `get` CLP and may be denied by access
// control (e.g. CLP `get: {}` or an ACL that excludes the caller).
// Handled below; never fall back to the raw row.
}
if (!filteredUser) {
filteredUser = user;
// Master/maintenance callers bypass CLP, protectedFields, and authData
// afterFind, so for them an empty re-fetch is a genuine not-found edge, not
// an access-control denial; they are entitled to the full row. For every
// other caller, an empty/denied re-fetch means access control withheld the
// record, so disclose only the identity — never the raw row, which would
// leak fields hidden by `protectedFields` and raw `authData` (e.g. MFA
// secrets and recovery codes) that the sanitizing re-fetch would remove.
// The session token is still attached below so login succeeds.
filteredUser =
req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId };
}
UsersRouter.removeHiddenProperties(filteredUser);
filteredUser.sessionToken = user.sessionToken;
@@ -472,10 +483,20 @@ export class UsersRouter extends ClassesRouter {
);
filteredUser = filteredUserResponse.results?.[0];
} catch {
// re-fetch may fail for legacy users without ACL; fall through
// The re-fetch enforces `_User` `get` CLP and may be denied by access
// control (e.g. CLP `get: {}` or an ACL that excludes the caller).
// Handled below; never fall back to the raw row.
}
if (!filteredUser) {
filteredUser = user;
// See handleLogIn: master/maintenance callers bypass CLP,
// protectedFields, and authData afterFind, so an empty re-fetch is a
// genuine not-found edge for them and they are entitled to the full
// row. For all other callers, an empty/denied re-fetch means access
// control withheld the record, so disclose only the identity rather
// than the raw row, which would leak protectedFields and raw authData
// (e.g. MFA secrets and recovery codes).
filteredUser =
req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId };
}
UsersRouter.removeHiddenProperties(filteredUser);
return { response: filteredUser };