diff --git a/.circleci/config.yml b/.circleci/config.yml index 4379e6e..b8fe8cf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -67,7 +67,7 @@ workflows: branches: only: - develop - - PM-4482 + - PM-4975 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/src/common/helper.js b/src/common/helper.js index 90cf857..f343a80 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -630,7 +630,20 @@ function convertBigIntDeep (value) { } return value } - +/** + * Validate that a member handle contains only permitted characters. + * Handles with special chars like <, >, ?, spaces, or raw Unicode + * that were created via legacy systems should return a clean 404. + * @param {String} handle the member handle + * @throws {NotFoundError} if handle contains invalid characters + */ +function validateHandle (handle) { + // Topcoder handles: 2–50 chars, letters, digits, hyphens, underscores, periods only + const VALID_HANDLE_RE = /^[a-zA-Z0-9\-_.]{2,50}$/ + if (!VALID_HANDLE_RE.test(handle)) { + throw new errors.NotFoundError(`Member with handle: "${handle}" doesn't exist`) + } +} module.exports = { wrapExpress, autoWrapExpress, @@ -661,5 +674,6 @@ module.exports = { secureMemberAddressData, truncateLastName, bigIntToNumber, - convertBigIntDeep + convertBigIntDeep, + validateHandle } diff --git a/src/controllers/MemberController.js b/src/controllers/MemberController.js index a772468..a18e67c 100644 --- a/src/controllers/MemberController.js +++ b/src/controllers/MemberController.js @@ -9,6 +9,7 @@ const service = require('../services/MemberService') * @param {Object} res the response */ async function getMember (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.getMember(req.authUser, req.params.handle, req.query) res.send(result) } @@ -18,6 +19,7 @@ async function getMember (req, res) { * @param {Object} res the response */ async function getProfileCompleteness (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.getProfileCompleteness(req.authUser, req.params.handle, req.query) res.send(result) } @@ -38,6 +40,7 @@ async function getMemberUserIdSignature (req, res) { * @param {Object} res the response */ async function getMemberSkill (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.getMemberSkill(req.authUser, req.params.handle, req.params.skillid) res.send(result) } @@ -48,6 +51,7 @@ async function getMemberSkill (req, res) { * @param {Object} res the response */ async function updateMember (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.updateMember(req.authUser, req.params.handle, req.query, req.body) res.send(result) } @@ -58,6 +62,7 @@ async function updateMember (req, res) { * @param {Object} res the response */ async function updateHandle (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.updateHandle(req.authUser, req.params.handle, req.query, req.body) res.send(result) } @@ -68,6 +73,7 @@ async function updateHandle (req, res) { * @param {Object} res the response */ async function verifyEmail (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.verifyEmail(req.authUser, req.params.handle, req.query) res.send(result) } @@ -78,6 +84,7 @@ async function verifyEmail (req, res) { * @param {Object} res the response */ async function uploadPhoto (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.uploadPhoto(req.authUser, req.params.handle, req.files) res.send(result) } @@ -88,6 +95,7 @@ async function uploadPhoto (req, res) { * @param {Object} res the response */ async function deleteMember (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.deleteMember(req.authUser, req.params.handle, req.body) res.send(result) } @@ -98,6 +106,7 @@ async function deleteMember (req, res) { * @param {Object} res the response */ async function confirmProfileData (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.confirmProfileData(req.authUser, req.params.handle) res.send(result) } @@ -108,6 +117,7 @@ async function confirmProfileData (req, res) { * @param {Object} res the response */ async function downloadProfile (req, res) { + const handle = decodeURIComponent(req.params.handle) const pdfStream = await service.downloadProfile(req.authUser, req.params.handle) res.setHeader('Content-Type', 'application/pdf') res.setHeader('Content-Disposition', `attachment; filename="topcoder-profile-${req.params.handle}.pdf"`) @@ -120,6 +130,7 @@ async function downloadProfile (req, res) { * @param {Object} res the response */ async function getMemberSendgridEmails (req, res) { + const handle = decodeURIComponent(req.params.handle) const result = await service.getMemberSendgridEmails(req.authUser, req.params.handle) res.send(result) } diff --git a/src/services/MemberService.js b/src/services/MemberService.js index 83910b8..3aa9621 100644 --- a/src/services/MemberService.js +++ b/src/services/MemberService.js @@ -101,7 +101,7 @@ function omitMemberAttributes (currentUser, mb) { const hasSensitiveDataRole = helper.hasSensitiveDataRole(currentUser) const isM2M = currentUser && currentUser.isMachine const isSelf = currentUser && currentUser.handle && mb.handleLower && - currentUser.handle.trim().toLowerCase() === mb.handleLower.trim().toLowerCase() + currentUser.handle.toLowerCase() === mb.handleLower.toLowerCase() const canSeeIdentityVerified = isM2M || hasSensitiveDataRole || isSelf const canSeeRecentActivity = isM2M || hasSensitiveDataRole || isSelf const canSeeFullAddress = canManageMember || hasSensitiveDataRole @@ -202,6 +202,7 @@ function getCountryNameFromCode (isoCode3) { * @returns {Object} the member profile data */ async function getMemberData (handle, query, allowedFields = MEMBER_FIELDS) { + helper.validateHandle(handle) // validate and parse query parameter const selectFields = helper.parseCommaSeparatedString(query.fields, allowedFields) || allowedFields @@ -251,7 +252,7 @@ async function getMember (currentUser, handle, query) { const hasSensitiveDataRole = helper.hasSensitiveDataRole(currentUser) const isM2M = currentUser && currentUser.isMachine const isSelf = currentUser && currentUser.handle && - currentUser.handle.trim().toLowerCase() === handle.trim().toLowerCase() + currentUser.handle.toLowerCase() === handle.toLowerCase() const canSeePhones = isM2M || hasSensitiveDataRole || isSelf const canSeeRecentActivity = isM2M || hasSensitiveDataRole || isSelf @@ -345,7 +346,7 @@ getMember.schema = { handle: Joi.string().required(), query: Joi.object().keys({ fields: Joi.string() - }) + }).unknown(true) } /** @@ -369,6 +370,7 @@ function buildSendgridEmailActivityQuery (email, startTime, endTime) { * @returns {Array} up to the most recent SendGrid message activity records */ async function getMemberSendgridEmails (currentUser, handle) { + helper.validateHandle(handle) if (!currentUser || (!currentUser.isMachine && !helper.hasAdminRole(currentUser))) { throw new errors.ForbiddenError('You are not allowed to view SendGrid email activity.') } @@ -586,7 +588,7 @@ getProfileCompleteness.schema = { query: Joi.object().keys({ fields: Joi.string(), toast: Joi.string() - }) + }).unknown(true) } /** @@ -610,7 +612,7 @@ getMemberUserIdSignature.schema = { currentUser: Joi.any(), query: Joi.object().keys({ type: Joi.string().valid('userflow').required() - }).required() + }).unknown(true).required() } /** @@ -622,6 +624,7 @@ getMemberUserIdSignature.schema = { * @returns {Object} the updated member data */ async function updateMember (currentUser, handle, query, data) { + helper.validateHandle(handle) const operatorId = currentUser.userId || currentUser.sub const member = await helper.getMemberByHandle(handle) // check authorization @@ -778,7 +781,7 @@ updateMember.schema = { handle: Joi.string().required(), query: Joi.object().keys({ fields: Joi.string() - }), + }).unknown(true), data: Joi.object().keys({ handle: Joi.forbidden(), handleLower: Joi.forbidden(), @@ -820,6 +823,7 @@ updateMember.schema = { * @returns {Object} the updated member data */ async function updateHandle (currentUser, handle, query, data) { + helper.validateHandle(handle) const operatorId = currentUser.userId || currentUser.sub const member = await helper.getMemberByHandle(handle) @@ -925,7 +929,7 @@ updateHandle.schema = { handle: Joi.string().required(), query: Joi.object().keys({ fields: Joi.string() - }), + }).unknown(true), data: Joi.object().keys({ newHandle: Joi.string().required() }).required() @@ -988,7 +992,7 @@ verifyEmail.schema = { handle: Joi.string().required(), query: Joi.object().keys({ token: Joi.string().required() - }).required() + }).unknown(true).required() } /** @@ -1070,6 +1074,7 @@ async function uploadPhoto (currentUser, handle, files) { * @returns {Object} the deletion result */ async function deleteMember (currentUser, handle, data) { + helper.validateHandle(handle) if (!currentUser || (!currentUser.isMachine && !helper.hasAdminRole(currentUser))) { throw new errors.ForbiddenError('You are not allowed to delete the member.') } @@ -1939,6 +1944,7 @@ async function aggregatePDFData (currentUser, handle) { * @returns {Stream} PDF stream */ async function downloadProfile (currentUser, handle) { + helper.validateHandle(handle) // Validate handle exists const member = await helper.getMemberByHandle(handle) diff --git a/src/services/MemberTraitService.js b/src/services/MemberTraitService.js index bfaa0f2..1b99f96 100644 --- a/src/services/MemberTraitService.js +++ b/src/services/MemberTraitService.js @@ -206,7 +206,7 @@ async function getTraits (currentUser, handle, query) { const hasSensitiveDataRole = helper.hasSensitiveDataRole(currentUser) const isM2M = currentUser && currentUser.isMachine const isSelf = currentUser && currentUser.handle && - currentUser.handle.trim().toLowerCase() === handle.trim().toLowerCase() + currentUser.handle.toLowerCase() === handle.toLowerCase() // can read private personalisation info on a member const canReadPrivate = isM2M || hasSensitiveDataRole || isSelf @@ -284,7 +284,7 @@ getTraits.schema = { query: Joi.object().keys({ traitIds: Joi.string(), fields: Joi.string() - }) + }).unknown(true) } /** @@ -659,7 +659,7 @@ removeTraits.schema = { handle: Joi.string().required(), query: Joi.object().keys({ traitIds: Joi.string() // if not provided, then all member traits are removed - }) + }).unknown(true) } /** * This function is used to calculate a deduction to the skill score used in the talent search