mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -6151,4 +6151,142 @@ describe('Vulnerabilities', () => {
|
|||||||
expect(req.info.clientSDK).toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -370,10 +370,21 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
);
|
);
|
||||||
filteredUser = filteredUserResponse.results?.[0];
|
filteredUser = filteredUserResponse.results?.[0];
|
||||||
} catch {
|
} 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) {
|
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);
|
UsersRouter.removeHiddenProperties(filteredUser);
|
||||||
filteredUser.sessionToken = user.sessionToken;
|
filteredUser.sessionToken = user.sessionToken;
|
||||||
@@ -472,10 +483,20 @@ export class UsersRouter extends ClassesRouter {
|
|||||||
);
|
);
|
||||||
filteredUser = filteredUserResponse.results?.[0];
|
filteredUser = filteredUserResponse.results?.[0];
|
||||||
} catch {
|
} 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) {
|
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);
|
UsersRouter.removeHiddenProperties(filteredUser);
|
||||||
return { response: filteredUser };
|
return { response: filteredUser };
|
||||||
|
|||||||
Reference in New Issue
Block a user