diff --git a/models/users.js b/models/users.js index 65922edb8..778f21e2f 100644 --- a/models/users.js +++ b/models/users.js @@ -588,15 +588,37 @@ Users.deny({ }); +// Custom MongoDB engine that enforces field restrictions +class SecureMongoDBEngine extends MongoDBEngine { + getSearchCursor(searchObject, options) { + // Always enforce field projection to prevent data leakage + const secureProjection = { + _id: 1, + username: 1, + 'profile.fullname': 1, + 'profile.avatarUrl': 1, + }; + + // Override any projection passed in options + const secureOptions = { + ...options, + projection: secureProjection, + }; + + return super.getSearchCursor(searchObject, secureOptions); + } +} + // Search a user in the complete server database by its name, username or emails adress. This // is used for instance to add a new user to a board. UserSearchIndex = new Index({ collection: Users, fields: ['username', 'profile.fullname', 'profile.avatarUrl'], - allowedFields: ['username', 'profile.fullname', 'profile.avatarUrl'], - engine: new MongoDBEngine({ + allowedFields: ['username', 'profile.fullname', 'profile.avatarUrl', '_id'], + engine: new SecureMongoDBEngine({ fields: function (searchObject, options) { return { + _id: 1, username: 1, 'profile.fullname': 1, 'profile.avatarUrl': 1, @@ -2756,6 +2778,51 @@ if (Meteor.isServer) { }); } }); + + // Server-side method to sanitize user data for search results + Meteor.methods({ + sanitizeUserForSearch(userData) { + check(userData, Object); + + // Only allow safe fields for user search + const safeFields = { + _id: 1, + username: 1, + 'profile.fullname': 1, + 'profile.avatarUrl': 1, + 'profile.initials': 1, + 'emails.address': 1, + 'emails.verified': 1, + authenticationMethod: 1, + isAdmin: 1, + loginDisabled: 1, + teams: 1, + orgs: 1, + }; + + const sanitized = {}; + for (const field of Object.keys(safeFields)) { + if (userData[field] !== undefined) { + sanitized[field] = userData[field]; + } + } + + // Ensure sensitive fields are never included + delete sanitized.services; + delete sanitized.resume; + delete sanitized.email; + delete sanitized.createdAt; + delete sanitized.modifiedAt; + delete sanitized.sessionData; + delete sanitized.importUsernames; + + if (process.env.DEBUG === 'true') { + console.log('Sanitized user data for search:', Object.keys(sanitized)); + } + + return sanitized; + } + }); } export default Users; diff --git a/server/publications/users.js b/server/publications/users.js index 53d6d443d..4730a3d35 100644 --- a/server/publications/users.js +++ b/server/publications/users.js @@ -49,6 +49,49 @@ Meteor.publish('user-authenticationMethod', function (match) { return ret; }); +// Secure user search publication for board sharing +Meteor.publish('user-search', function (searchTerm) { + check(searchTerm, String); + + // Only allow logged-in users to search for other users + if (!this.userId) { + return this.ready(); + } + + // Create a regex for case-insensitive search + const searchRegex = new RegExp(searchTerm, 'i'); + + // Search for users by username, fullname, or email + const ret = ReactiveCache.getUsers( + { + $or: [ + { username: searchRegex }, + { 'profile.fullname': searchRegex }, + { 'emails.address': searchRegex } + ] + }, + { + fields: { + _id: 1, + username: 1, + 'profile.fullname': 1, + 'profile.avatarUrl': 1, + 'profile.initials': 1, + 'emails.address': 1, + 'emails.verified': 1, + authenticationMethod: 1, + isAdmin: 1, + loginDisabled: 1, + teams: 1, + orgs: 1, + }, + }, + true, + ); + + return ret; +}); + // update last connection date and last connection average time (in seconds) for a user // function UpdateLastConnectionDateAndLastConnectionAverageTime(lstUsers) { // let lastConnectionAverageTime;