fix: NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) (#10129)

This commit is contained in:
Manuel
2026-03-07 19:16:26 +00:00
committed by GitHub
parent f376abb60e
commit 88eed83ff8
4 changed files with 154 additions and 1 deletions
+1
View File
@@ -692,4 +692,5 @@ describe('relativeTimeToDate', () => {
});
});
});
});
+147
View File
@@ -125,6 +125,153 @@ describe('Regex Vulnerabilities', () => {
});
});
describe('on password reset request via token (handleResetRequest)', () => {
beforeEach(async () => {
user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword');
// Trigger a password reset to generate a _perishable_token
await request({
url: `${serverURL}/requestPasswordReset`,
method: 'POST',
headers,
body: JSON.stringify({
...keys,
_method: 'POST',
email: 'someemail@somedomain.com',
}),
});
// Expire the token so the handleResetRequest token-lookup branch matches
await Parse.Server.database.update(
'_User',
{ objectId: user.id },
{
_perishable_token_expires_at: new Date(Date.now() - 10000),
}
);
});
it('should not allow $ne operator to match user via token injection', async () => {
// Without the fix, {$ne: null} matches any user with a non-null expired token,
// causing a password reset email to be sent — a boolean oracle for token extraction.
try {
await request({
url: `${serverURL}/requestPasswordReset`,
method: 'POST',
headers,
body: JSON.stringify({
...keys,
token: { $ne: null },
}),
});
fail('should not succeed with $ne token');
} catch (e) {
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
}
});
it('should not allow $regex operator to extract token via injection', async () => {
try {
await request({
url: `${serverURL}/requestPasswordReset`,
method: 'POST',
headers,
body: JSON.stringify({
...keys,
token: { $regex: '^.' },
}),
});
fail('should not succeed with $regex token');
} catch (e) {
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
}
});
it('should not allow $exists operator for token injection', async () => {
try {
await request({
url: `${serverURL}/requestPasswordReset`,
method: 'POST',
headers,
body: JSON.stringify({
...keys,
token: { $exists: true },
}),
});
fail('should not succeed with $exists token');
} catch (e) {
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
}
});
it('should not allow $gt operator for token injection', async () => {
try {
await request({
url: `${serverURL}/requestPasswordReset`,
method: 'POST',
headers,
body: JSON.stringify({
...keys,
token: { $gt: '' },
}),
});
fail('should not succeed with $gt token');
} catch (e) {
expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE);
}
});
});
describe('on resend verification email', () => {
// The PagesRouter uses express.urlencoded({ extended: false }) which does not parse
// nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection.
// The toString() guard in resendVerificationEmail() is defense-in-depth in case the
// body parser configuration changes. These tests verify the guard works correctly
// by directly testing the PagesRouter method.
it('should sanitize non-string token to string via toString()', async () => {
const { PagesRouter } = require('../lib/Routers/PagesRouter');
const router = new PagesRouter();
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
const resendSpy = jasmine
.createSpy('resendVerificationEmail')
.and.returnValue(Promise.resolve());
const req = {
config: {
userController: { resendVerificationEmail: resendSpy },
},
body: {
username: 'testuser',
token: { $regex: '^.' },
},
};
await router.resendVerificationEmail(req);
// The token passed to userController.resendVerificationEmail should be a string
const passedToken = resendSpy.calls.first().args[2];
expect(typeof passedToken).toEqual('string');
expect(passedToken).toEqual('[object Object]');
});
it('should pass through valid string token unchanged', async () => {
const { PagesRouter } = require('../lib/Routers/PagesRouter');
const router = new PagesRouter();
spyOn(router, 'goToPage').and.returnValue(Promise.resolve());
const resendSpy = jasmine
.createSpy('resendVerificationEmail')
.and.returnValue(Promise.resolve());
const req = {
config: {
userController: { resendVerificationEmail: resendSpy },
},
body: {
username: 'testuser',
token: 'validtoken123',
},
};
await router.resendVerificationEmail(req);
const passedToken = resendSpy.calls.first().args[2];
expect(typeof passedToken).toEqual('string');
expect(passedToken).toEqual('validtoken123');
});
});
describe('on password reset', () => {
beforeEach(async () => {
user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword');
+2 -1
View File
@@ -108,7 +108,8 @@ export class PagesRouter extends PromiseRouter {
resendVerificationEmail(req) {
const config = req.config;
const username = req.body?.username;
const token = req.body?.token;
const rawToken = req.body?.token;
const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;
if (!config) {
this.invalidRequest();
+4
View File
@@ -458,6 +458,10 @@ export class UsersRouter extends ClassesRouter {
throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email');
}
if (token && typeof token !== 'string') {
throw new Parse.Error(Parse.Error.INVALID_VALUE, 'token must be a string');
}
let userResults = null;
let userData = null;