diff --git a/designer/server/src/routes/forms/contact/email.js b/designer/server/src/routes/forms/contact/email.js index c3ae981794..973bb158c9 100644 --- a/designer/server/src/routes/forms/contact/email.js +++ b/designer/server/src/routes/forms/contact/email.js @@ -1,6 +1,7 @@ import { Scopes, - emailAddressSchema, + UNICODE_EMAIL_ERROR_MESSAGE, + emailAddressNoUnicodeSchema, emailResponseTimeSchema } from '@defra/forms-model' import { StatusCodes } from 'http-status-codes' @@ -20,13 +21,14 @@ export const ROUTE_PATH_EDIT_EMAIL_CONTACT = '/library/{slug}/edit/contact/email' export const emailContactSchema = Joi.object().keys({ - address: emailAddressSchema + address: emailAddressNoUnicodeSchema .required() .when('_delete', { is: true, then: Joi.allow('') }) .messages({ 'string.empty': 'Enter an email address for dedicated support', 'string.email': - 'Enter an email address for dedicated support in the correct format' + 'Enter an email address for dedicated support in the correct format', + 'string.unicode': UNICODE_EMAIL_ERROR_MESSAGE }), responseTime: emailResponseTimeSchema .required() diff --git a/designer/server/src/routes/forms/create.js b/designer/server/src/routes/forms/create.js index 054ec9675b..75e1b667dc 100644 --- a/designer/server/src/routes/forms/create.js +++ b/designer/server/src/routes/forms/create.js @@ -1,5 +1,6 @@ import { Scopes, + UNICODE_EMAIL_ERROR_MESSAGE, getErrorMessage, organisationSchema, slugify, @@ -41,7 +42,8 @@ export const schema = Joi.object().keys({ }), teamEmail: teamEmailSchema.messages({ 'string.empty': 'Enter a shared team email address', - 'string.email': 'Enter a shared team email address in the correct format' + 'string.email': 'Enter a shared team email address in the correct format', + 'string.unicode': UNICODE_EMAIL_ERROR_MESSAGE }) }) diff --git a/designer/server/src/routes/forms/notification-email.js b/designer/server/src/routes/forms/notification-email.js index 71d8492805..b5529b24a0 100644 --- a/designer/server/src/routes/forms/notification-email.js +++ b/designer/server/src/routes/forms/notification-email.js @@ -1,4 +1,8 @@ -import { Scopes, notificationEmailAddressSchema } from '@defra/forms-model' +import { + Scopes, + UNICODE_EMAIL_ERROR_MESSAGE, + notificationEmailAddressSchema +} from '@defra/forms-model' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' @@ -21,7 +25,8 @@ export const schema = Joi.object().keys({ notificationEmail: notificationEmailAddressSchema.required().messages({ 'string.empty': EMPTY_MESSAGE, 'string.email': INCORRECT_FORMAT_MESSAGE, - 'string.pattern.base': INCORRECT_FORMAT_MESSAGE + 'string.pattern.base': INCORRECT_FORMAT_MESSAGE, + 'string.unicode': UNICODE_EMAIL_ERROR_MESSAGE }) }) diff --git a/designer/server/src/routes/manage/user.js b/designer/server/src/routes/manage/user.js index 2d8ec46591..3e622059da 100644 --- a/designer/server/src/routes/manage/user.js +++ b/designer/server/src/routes/manage/user.js @@ -1,4 +1,9 @@ -import { Roles, Scopes } from '@defra/forms-model' +import { + Roles, + Scopes, + UNICODE_EMAIL_ERROR_MESSAGE, + emailAddressNoUnicodeSchema +} from '@defra/forms-model' import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' @@ -15,11 +20,10 @@ import * as userService from '~/src/services/userService.js' const errorKey = sessionNames.validationFailure.manageUsers const addUserSchema = Joi.object({ - emailAddress: Joi.string() - .email() - .trim() + emailAddress: emailAddressNoUnicodeSchema .required() .messages({ + 'string.unicode': UNICODE_EMAIL_ERROR_MESSAGE, '*': 'Enter an email address in the correct format' }) .description('Email address of user'), diff --git a/model/src/form/form-audit/index.ts b/model/src/form/form-audit/index.ts index 8c9cd76bab..308b82a033 100644 --- a/model/src/form/form-audit/index.ts +++ b/model/src/form/form-audit/index.ts @@ -52,9 +52,9 @@ import { type FormUploadedMessageData, type FormsBackupRequestedMessageData } from '~/src/form/form-audit/types.js' +import { emailAddressNoUnicodeSchema } from '~/src/form/form-editor/index.js' import { contactSchema, - emailAddressSchema, emailResponseTimeSchema, idSchema, notificationEmailAddressSchema, @@ -181,7 +181,7 @@ export const formSupportOnlineChanges = Joi.object() export const formSupportEmailChanges = Joi.object() .keys({ - address: emailAddressSchema.optional(), + address: emailAddressNoUnicodeSchema.optional(), responseTime: emailResponseTimeSchema.optional() }) .description('Changes schema for FORM_SUPPORT_EMAIL_UPDATED event') @@ -238,7 +238,7 @@ export const entitlementMessageData = Joi.object().keys( { userId: Joi.string().required(), displayName: Joi.string().required(), - email: Joi.string().email().required(), + email: emailAddressNoUnicodeSchema.required(), roles: Joi.array().items(Joi.string()) } ) @@ -260,7 +260,7 @@ export const excelGenerationMessageData = .keys({ formId: Joi.string().required(), formName: Joi.string().required(), - notificationEmail: Joi.string().email().required() + notificationEmail: emailAddressNoUnicodeSchema.required() }) .required() .description( diff --git a/model/src/form/form-definition/constants.ts b/model/src/form/form-definition/constants.ts new file mode 100644 index 0000000000..b0be34eb6c --- /dev/null +++ b/model/src/form/form-definition/constants.ts @@ -0,0 +1,2 @@ +export const MIN_NUMBER_OF_REPEAT_ITEMS = 1 +export const MAX_NUMBER_OF_REPEAT_ITEMS = 200 diff --git a/model/src/form/form-definition/index.ts b/model/src/form/form-definition/index.ts index a780f60708..2a5cace512 100644 --- a/model/src/form/form-definition/index.ts +++ b/model/src/form/form-definition/index.ts @@ -25,6 +25,10 @@ import { type RelativeDateValueData, type RelativeDateValueDataV2 } from '~/src/conditions/types.js' +import { + MAX_NUMBER_OF_REPEAT_ITEMS, + MIN_NUMBER_OF_REPEAT_ITEMS +} from '~/src/form/form-definition/constants.js' import { isConditionListItemRefValueData, isFormDefinition @@ -47,6 +51,7 @@ import { type RepeatSchema, type Section } from '~/src/form/form-definition/types.js' +import { emailAddressNoUnicodeSchema } from '~/src/form/form-editor/index.js' import { checkErrors } from '~/src/form/form-manager/errors.js' import { FormDefinitionError, @@ -348,9 +353,6 @@ const conditionSchema = Joi.object() ) }) -export const MIN_NUMBER_OF_REPEAT_ITEMS = 1 -export const MAX_NUMBER_OF_REPEAT_ITEMS = 200 - export const conditionDataSchemaV2 = Joi.object() .description('Condition definition') .keys({ @@ -1041,13 +1043,7 @@ const feedbackSchema = Joi.object() .description( 'URL to an external feedback form when not using built-in feedback' ), - emailAddress: Joi.string() - .trim() - .email({ - tlds: { - allow: false - } - }) + emailAddress: emailAddressNoUnicodeSchema .optional() .description('Email address where feedback is sent') }) @@ -1155,8 +1151,7 @@ export const formDefinitionSchema = Joi.object() .optional() .description('Phase banner configuration'), options: optionsSchema.optional().description('Options for the form'), - outputEmail: Joi.string() - .trim() + outputEmail: emailAddressNoUnicodeSchema .email({ tlds: { allow: ['uk'] } }) .optional() .description('Email address where form submissions are sent'), @@ -1165,8 +1160,7 @@ export const formDefinitionSchema = Joi.object() .description('Configuration for submission output format'), outputs: Joi.array() .items({ - emailAddress: Joi.string() - .trim() + emailAddress: emailAddressNoUnicodeSchema .email({ tlds: { allow: ['uk'] } }) .description('Email address where form submissions are sent'), audience: Joi.string() @@ -1244,6 +1238,11 @@ export const formDefinitionV2Schema = formDefinitionSchema }) .description('Form definition schema for V2') +export { + MAX_NUMBER_OF_REPEAT_ITEMS, + MIN_NUMBER_OF_REPEAT_ITEMS +} from '~/src/form/form-definition/constants.js' + // Maintain compatibility with legacy named export // E.g. `import { Schema } from '@defra/forms-model'` export const Schema = formDefinitionSchema diff --git a/model/src/form/form-editor/index.ts b/model/src/form/form-editor/index.ts index 90ab711cdd..65d6480c76 100644 --- a/model/src/form/form-editor/index.ts +++ b/model/src/form/form-editor/index.ts @@ -5,7 +5,7 @@ import { ComponentType } from '~/src/components/enums.js' import { MAX_NUMBER_OF_REPEAT_ITEMS, MIN_NUMBER_OF_REPEAT_ITEMS -} from '~/src/form/form-definition/index.js' +} from '~/src/form/form-definition/constants.js' import { type FormEditorInputCheckAnswersSettings, type FormEditorInputPage, @@ -16,6 +16,16 @@ import { type GovukFieldUsePostcodeLookup, type GovukStringField } from '~/src/form/form-editor/types.js' +import { preventUnicodeInEmail } from '~/src/form/utils/prevent-unicode.js' + +export const emailAddressNoUnicodeSchema = Joi.string() + .trim() + .email() + .custom((value, helpers) => preventUnicodeInEmail(value, helpers)) + .description('Email address preventing unicode characters') + +export const UNICODE_EMAIL_ERROR_MESSAGE = + 'The email address you entered includes invalid characters, for example, long dashes' export enum QuestionTypeSubGroup { WrittenAnswerSubGroup = 'writtenAnswerSub', diff --git a/model/src/form/form-metadata/index.ts b/model/src/form/form-metadata/index.ts index d3e5e818d3..36182bcd6f 100644 --- a/model/src/form/form-metadata/index.ts +++ b/model/src/form/form-metadata/index.ts @@ -1,5 +1,6 @@ import Joi from 'joi' +import { emailAddressNoUnicodeSchema } from '~/src/form/form-editor/index.js' import { type FormMetadata, type FormMetadataAuthor, @@ -53,7 +54,7 @@ export const teamNameSchema = Joi.string() .required() .description('Name of the team responsible for the form') -export const teamEmailSchema = Joi.string() +export const teamEmailSchema = emailAddressNoUnicodeSchema .email({ tlds: { allow: ['uk'] } }) .trim() .required() @@ -63,9 +64,7 @@ export const phoneSchema = Joi.string() .trim() .description('Phone number for form-related inquiries') -export const emailAddressSchema = Joi.string() - .email() - .trim() +export const emailAddressSchema = emailAddressNoUnicodeSchema .required() .description('Email address for form-related inquiries') @@ -132,10 +131,10 @@ export const termsAndConditionsAgreedSchema = Joi.boolean().description( 'Whether the data protection terms and conditions have been agreed to' ) -export const notificationEmailAddressSchema = Joi.string() - .email() - .trim() - .description('Email address to receive form submission notifications') +export const notificationEmailAddressSchema = + emailAddressNoUnicodeSchema.description( + 'Email address to receive form submission notifications' + ) export const authoredAtSchema = Joi.date() .iso() diff --git a/model/src/form/utils/index.ts b/model/src/form/utils/index.ts index c95d0f404d..fac55e1deb 100644 --- a/model/src/form/utils/index.ts +++ b/model/src/form/utils/index.ts @@ -1,2 +1,3 @@ export { findStartPage } from '~/src/form/utils/find-start-page.js' export { findDefinitionListFromComponent } from '~/src/form/utils/list.js' +export { preventUnicodeInEmail } from '~/src/form/utils/prevent-unicode.js' diff --git a/model/src/form/utils/prevent-unicode.js b/model/src/form/utils/prevent-unicode.js new file mode 100644 index 0000000000..415f3ad694 --- /dev/null +++ b/model/src/form/utils/prevent-unicode.js @@ -0,0 +1,19 @@ +/** + * Custom Joi validator to prevent Unicode characters in say an email address. + * Although Joi has Joi.email({ allowUnicode: false }), we can't differentiate from a general + * email format error or a Unicode error - hance the custom validator to allow a different error message + * @param {unknown} value + * @param {CustomHelpers} helpers + */ +export function preventUnicodeInEmail(value, helpers) { + if (!value || typeof value !== 'string') { + return helpers.error('string.empty') + } + const invalidCharsRegex = /[^a-zA-Z0-9.!#$%&'*+/=?^_`{|}~@-]/ + const invalid = invalidCharsRegex.exec(value) + return invalid ? helpers.error('string.unicode') : value +} + +/** + * @import { type CustomHelpers } from 'joi' + */ diff --git a/model/src/form/utils/prevent-unicode.test.js b/model/src/form/utils/prevent-unicode.test.js new file mode 100644 index 0000000000..5ec0663151 --- /dev/null +++ b/model/src/form/utils/prevent-unicode.test.js @@ -0,0 +1,44 @@ +import { preventUnicodeInEmail } from '~/src/form/utils/prevent-unicode.js' + +describe('preventUnicodeInEmail', () => { + const mockHelpers = { + error: jest.fn() + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should handle undefined', () => { + // @ts-expect-error - partial helper mock + preventUnicodeInEmail(undefined, mockHelpers) + expect(mockHelpers.error).toHaveBeenCalledWith('string.empty') + }) + + it('should handle not a string', () => { + // @ts-expect-error - partial helper mock + preventUnicodeInEmail({ abc: 123 }, mockHelpers) + expect(mockHelpers.error).toHaveBeenCalledWith('string.empty') + }) + + it.each([ + 'first.last@domain.co.uk', + 'first-last@domain.uk', + 'only@domain.uk' + ])('handles a valid emails', (/** @type {string} */ email) => { + // @ts-expect-error - partial helper mock + expect(preventUnicodeInEmail(email, mockHelpers)).toBe(email) + expect(mockHelpers.error).not.toHaveBeenCalled() + }) + + const enDash = '\u2013' + const emDash = '\u2014' + it.each([`first${enDash}last@domain.co.uk`, `first${emDash}}last@domain.uk`])( + 'handles an invalid email', + (/** @type {string} */ email) => { + // @ts-expect-error - partial helper mock + preventUnicodeInEmail(email, mockHelpers) + expect(mockHelpers.error).toHaveBeenCalledWith('string.unicode') + } + ) +}) diff --git a/package-lock.json b/package-lock.json index 1c1450fb2f..d0495dd45e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29623,15 +29623,6 @@ "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", "license": "ISC" }, - "node_modules/preact": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-8.5.3.tgz", - "integrity": "sha512-O3kKP+1YdgqHOFsZF2a9JVdtqD+RPzCQc3rP+Ualf7V6rmRDchZ9MJbiGTT7LuyqFKZqlHSOyO/oMFmI2lVTsw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",