Skip to content
Open
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
282 changes: 240 additions & 42 deletions spec/CloudCode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3307,19 +3307,19 @@ describe('afterFind hooks', () => {
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
Parse.Cloud.beforeLogin('SomeClass', () => { });
}).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogin(() => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogin('_User', () => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogin(Parse.User, () => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogin('SomeClass', () => { });
}).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogout(() => { });
}).not.toThrow();
Expand Down Expand Up @@ -3777,60 +3777,258 @@ describe('beforeLogin hook', () => {
await Parse.User.logIn('tupac', 'shakur');
done();
});
});

describe('beforePasswordResetRequest hook', () => {
it('should run beforePasswordResetRequest with valid user', async done => {
let hit = 0;
let sendPasswordResetEmailCalled = false;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {
sendPasswordResetEmailCalled = true;
},
sendMail: () => {},
};

it('afterFind should not be triggered when saving an object', async () => {
let beforeSaves = 0;
Parse.Cloud.beforeSave('SavingTest', () => {
beforeSaves++;
await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

let afterSaves = 0;
Parse.Cloud.afterSave('SavingTest', () => {
afterSaves++;
Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
expect(req.object).toBeDefined();
expect(req.object.get('email')).toEqual('[email protected]');
expect(req.object.get('username')).toEqual('testuser');
});

let beforeFinds = 0;
Parse.Cloud.beforeFind('SavingTest', () => {
beforeFinds++;
const user = new Parse.User();
user.setUsername('testuser');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();

await Parse.User.requestPasswordReset('[email protected]');
expect(hit).toBe(1);
expect(sendPasswordResetEmailCalled).toBe(true);
done();
});

it('should be able to block password reset request if an error is thrown', async done => {
let hit = 0;
let sendPasswordResetEmailCalled = false;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {
sendPasswordResetEmailCalled = true;
},
sendMail: () => {},
};

await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

let afterFinds = 0;
Parse.Cloud.afterFind('SavingTest', () => {
afterFinds++;
Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
if (req.object.get('isBanned')) {
throw new Error('banned account');
}
});

const obj = new Parse.Object('SavingTest');
obj.set('someField', 'some value 1');
await obj.save();
const user = new Parse.User();
user.setUsername('banneduser');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();
await user.save({ isBanned: true });

expect(beforeSaves).toEqual(1);
expect(afterSaves).toEqual(1);
expect(beforeFinds).toEqual(0);
expect(afterFinds).toEqual(0);
try {
await Parse.User.requestPasswordReset('[email protected]');
throw new Error('should not have sent password reset email.');
} catch (e) {
expect(e.message).toBe('banned account');
}
expect(hit).toBe(1);
expect(sendPasswordResetEmailCalled).toBe(false);
done();
});

obj.set('someField', 'some value 2');
await obj.save();
it('should be able to block password reset request if an error is thrown even if the user has an attached file', async done => {
let hit = 0;
let sendPasswordResetEmailCalled = false;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {
sendPasswordResetEmailCalled = true;
},
sendMail: () => {},
};

expect(beforeSaves).toEqual(2);
expect(afterSaves).toEqual(2);
expect(beforeFinds).toEqual(0);
expect(afterFinds).toEqual(0);
await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

await obj.fetch();
Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
if (req.object.get('isBanned')) {
throw new Error('banned account');
}
});

expect(beforeSaves).toEqual(2);
expect(afterSaves).toEqual(2);
expect(beforeFinds).toEqual(1);
expect(afterFinds).toEqual(1);
const user = new Parse.User();
user.setUsername('banneduser2');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();
const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=';
const file = new Parse.File('myfile.txt', { base64 });
await file.save();
await user.save({ isBanned: true, file });

obj.set('someField', 'some value 3');
await obj.save();
try {
await Parse.User.requestPasswordReset('[email protected]');
throw new Error('should not have sent password reset email.');
} catch (e) {
expect(e.message).toBe('banned account');
}
expect(hit).toBe(1);
expect(sendPasswordResetEmailCalled).toBe(false);
done();
});

it('should not run beforePasswordResetRequest if email does not exist', async done => {
let hit = 0;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {},
sendMail: () => {},
};

await reconfigureServer({
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
});

try {
await Parse.User.requestPasswordReset('[email protected]');
} catch (e) {
// May or may not throw depending on passwordPolicy.resetPasswordSuccessOnInvalidEmail
}
expect(hit).toBe(0);
done();
});

it('should have expected data in request in beforePasswordResetRequest', async done => {
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {},
sendMail: () => {},
};

await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

Parse.Cloud.beforePasswordResetRequest(req => {
expect(req.object).toBeDefined();
expect(req.object.get('email')).toBeDefined();
expect(req.headers).toBeDefined();
expect(req.ip).toBeDefined();
expect(req.installationId).toBeDefined();
expect(req.context).toBeDefined();
expect(req.config).toBeDefined();
});

const user = new Parse.User();
user.setUsername('testuser2');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();
await Parse.User.requestPasswordReset('[email protected]');
done();
});

it('should validate that only _User class is allowed for beforePasswordResetRequest', () => {
expect(() => {
Parse.Cloud.beforePasswordResetRequest('SomeClass', () => { });
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.beforePasswordResetRequest(() => { });
}).not.toThrow();
expect(() => {
Parse.Cloud.beforePasswordResetRequest('_User', () => { });
}).not.toThrow();
expect(() => {
Parse.Cloud.beforePasswordResetRequest(Parse.User, () => { });
}).not.toThrow();
});
});

it('afterFind should not be triggered when saving an object', async () => {
let beforeSaves = 0;
Parse.Cloud.beforeSave('SavingTest', () => {
beforeSaves++;
});

let afterSaves = 0;
Parse.Cloud.afterSave('SavingTest', () => {
afterSaves++;
});

let beforeFinds = 0;
Parse.Cloud.beforeFind('SavingTest', () => {
beforeFinds++;
});

expect(beforeSaves).toEqual(3);
expect(afterSaves).toEqual(3);
expect(beforeFinds).toEqual(1);
expect(afterFinds).toEqual(1);
let afterFinds = 0;
Parse.Cloud.afterFind('SavingTest', () => {
afterFinds++;
});

const obj = new Parse.Object('SavingTest');
obj.set('someField', 'some value 1');
await obj.save();

expect(beforeSaves).toEqual(1);
expect(afterSaves).toEqual(1);
expect(beforeFinds).toEqual(0);
expect(afterFinds).toEqual(0);

obj.set('someField', 'some value 2');
await obj.save();

expect(beforeSaves).toEqual(2);
expect(afterSaves).toEqual(2);
expect(beforeFinds).toEqual(0);
expect(afterFinds).toEqual(0);

await obj.fetch();

expect(beforeSaves).toEqual(2);
expect(afterSaves).toEqual(2);
expect(beforeFinds).toEqual(1);
expect(afterFinds).toEqual(1);

obj.set('someField', 'some value 3');
await obj.save();

expect(beforeSaves).toEqual(3);
expect(afterSaves).toEqual(3);
expect(beforeFinds).toEqual(1);
expect(afterFinds).toEqual(1);
});

describe('afterLogin hook', () => {
Expand Down
44 changes: 41 additions & 3 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Types as TriggerTypes,
getRequestObject,
resolveError,
inflate,
} from '../triggers';
import { promiseEnsureIdempotency } from '../middlewares';
import RestWrite from '../RestWrite';
Expand Down Expand Up @@ -444,21 +445,58 @@ export class UsersRouter extends ClassesRouter {
if (!email && !token) {
throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email');
}

let userResults = null;
let userData = null;

// We can find the user using token
if (token) {
const results = await req.config.database.find('_User', {
userResults = await req.config.database.find('_User', {
_perishable_token: token,
_perishable_token_expires_at: { $lt: Parse._encode(new Date()) },
});
if (results && results[0] && results[0].email) {
email = results[0].email;
if (userResults && userResults.length > 0) {
userData = userResults[0];
if (userData.email) {
email = userData.email;
}
}
// Or using email if no token provided
} else if (typeof email === 'string') {
userResults = await req.config.database.find(
'_User',
{ $or: [{ email }, { username: email, email: { $exists: false } }] },
{ limit: 1 },
Auth.maintenance(req.config)
);
Comment on lines +454 to +471
Copy link

@coderabbitai coderabbitai bot Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Token lookup uses inverted expiry comparator and lacks Auth.maintenance/limit.

  • _perishable_token_expires_at should be greater than “now” for a valid token.
  • Use Auth.maintenance like the email path for consistency.
  • Limit results to 1.

Apply:

-      userResults = await req.config.database.find('_User', {
-        _perishable_token: token,
-        _perishable_token_expires_at: { $lt: Parse._encode(new Date()) },
-      });
+      userResults = await req.config.database.find(
+        '_User',
+        {
+          _perishable_token: token,
+          _perishable_token_expires_at: { $gt: Parse._encode(new Date()) },
+        },
+        { limit: 1 },
+        Auth.maintenance(req.config)
+      );
🤖 Prompt for AI Agents
In src/Routers/UsersRouter.js around lines 454 to 471, the token lookup query
uses the wrong expiry comparator and is missing the same options as the email
path; change the _perishable_token_expires_at check to $gt Parse._encode(new
Date()) so only unexpired tokens match, and call req.config.database.find with
the same options as the email branch: pass { limit: 1 } and
Auth.maintenance(req.config) as the query options so the lookup is limited to
one result and runs under maintenance context.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mtrezza an opininion since this code already exists before my modifcations ?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

if (userResults && userResults.length > 0) {
userData = userResults[0];
}
}

if (typeof email !== 'string') {
throw new Parse.Error(
Parse.Error.INVALID_EMAIL_ADDRESS,
'you must provide a valid email string'
);
}

if (userData) {
// Useful to get User attached files in the trigger (photo picture for example)
await req.config.filesController.expandFilesInObject(req.config, userData);

const user = inflate('_User', userData);

await maybeRunTrigger(
TriggerTypes.beforePasswordResetRequest,
req.auth,
user,
null,
req.config,
req.info.context
);
}

const userController = req.config.userController;
try {
await userController.sendPasswordResetEmail(email);
Expand Down
Loading
Loading