Skip to content

Commit 72f1bcd

Browse files
authored
feat: sanitise form fields (#1623)
* sanitise form fields * rm package lock * replace lockfile * lint * run tests * tweaks * fix test * fix video test
1 parent 611b138 commit 72f1bcd

30 files changed

+1682
-1421
lines changed

.github/workflows/cypress-release-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Cypress release tests
22

33
on:
44
push:
5-
branches: [develop, dependabot/**, '*test*']
5+
branches: [develop, dependabot/**, '*test*', 'sanitize-form-fields']
66

77
jobs:
88
# vercel will redeploy the develop/staging app on creating a PR to main
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use client';
2+
3+
import { Box, TextField, TextFieldProps, Typography } from '@mui/material';
4+
import DOMPurify from 'dompurify';
5+
import { useTranslations } from 'next-intl';
6+
import { useMemo } from 'react';
7+
8+
// Field validation rules based on backend DTOs
9+
const FIELD_VALIDATION_RULES = {
10+
name: { maxLength: 50 },
11+
email: { maxLength: 255 },
12+
password: { maxLength: 128 },
13+
partnerAccessCode: { maxLength: 6 },
14+
mfaVerificationCode: { maxLength: 6 },
15+
accessCode: { maxLength: 6 },
16+
partnerId: { maxLength: 36 },
17+
signUpLanguage: { maxLength: 10 },
18+
feedbackDescription: { maxLength: 5000 },
19+
hopes: { maxLength: 5000 },
20+
raceEthnNatn: { maxLength: 500 },
21+
resourceId: { maxLength: 36 },
22+
sessionId: { maxLength: 36 },
23+
default: { maxLength: 500 },
24+
} as const;
25+
26+
interface SanitizedTextFieldProps extends Omit<
27+
TextFieldProps,
28+
'onChange' | 'inputProps' | 'value' | 'defaultValue'
29+
> {
30+
onChange?: (value: string) => void;
31+
allowedTags?: string[];
32+
allowedAttributes?: string[];
33+
maxLength?: number;
34+
showCharacterCount?: boolean;
35+
inputProps?: TextFieldProps['inputProps'];
36+
value?: string | null;
37+
defaultValue?: string | null;
38+
}
39+
40+
const SanitizedTextField = ({
41+
onChange,
42+
allowedTags = [],
43+
allowedAttributes = [],
44+
maxLength,
45+
showCharacterCount = false,
46+
id,
47+
value,
48+
defaultValue,
49+
inputProps,
50+
...restProps
51+
}: SanitizedTextFieldProps) => {
52+
const t = useTranslations('Shared');
53+
const fieldMaxLength = useMemo(() => {
54+
if (maxLength) return maxLength;
55+
if (id && id in FIELD_VALIDATION_RULES) {
56+
return FIELD_VALIDATION_RULES[id as keyof typeof FIELD_VALIDATION_RULES].maxLength;
57+
}
58+
return FIELD_VALIDATION_RULES.default.maxLength;
59+
}, [maxLength, id]);
60+
61+
const purifyConfig = useMemo(
62+
() => ({
63+
ALLOWED_TAGS: allowedTags,
64+
ALLOWED_ATTR: allowedAttributes,
65+
KEEP_CONTENT: false,
66+
}),
67+
[allowedTags, allowedAttributes],
68+
);
69+
70+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
71+
const inputValue =
72+
e.target.value.length > fieldMaxLength
73+
? e.target.value.substring(0, fieldMaxLength)
74+
: e.target.value;
75+
76+
const sanitized = DOMPurify.sanitize(inputValue, purifyConfig);
77+
onChange?.(sanitized);
78+
};
79+
80+
const effectiveValue = value ?? defaultValue ?? '';
81+
const currentLength = effectiveValue.toString().length;
82+
const shouldShowCounter =
83+
showCharacterCount || (restProps.multiline && currentLength > fieldMaxLength * 0.8);
84+
85+
const textFieldProps = {
86+
...restProps,
87+
id,
88+
onChange: handleChange,
89+
inputProps: { maxLength: fieldMaxLength, ...inputProps },
90+
...(value !== undefined ? { value } : { defaultValue }),
91+
};
92+
93+
const characterCountStyle = {
94+
display: 'block',
95+
textAlign: 'right',
96+
mt: -2.475,
97+
...(currentLength > fieldMaxLength && { color: 'error.main' }),
98+
};
99+
100+
return (
101+
<Box mb={0}>
102+
<TextField {...textFieldProps} />
103+
{shouldShowCounter && (
104+
<Typography variant="caption" sx={characterCountStyle}>
105+
{t('characterCount', { current: currentLength, max: fieldMaxLength })}
106+
</Typography>
107+
)}
108+
</Box>
109+
);
110+
};
111+
112+
export default SanitizedTextField;

components/forms/AboutYouDemographicForm.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import SanitizedTextField from '@/components/common/SanitizedTextField';
34
import { usePathname, useRouter } from '@/i18n/routing';
45
import countries from '@/lib/constants/countries';
56
import { LANGUAGES } from '@/lib/constants/enums';
@@ -26,7 +27,6 @@ import {
2627
FormLabel,
2728
Radio,
2829
RadioGroup,
29-
TextField,
3030
Typography,
3131
} from '@mui/material';
3232
import { useRollbar } from '@rollbar/react';
@@ -207,7 +207,7 @@ const AboutYouDemographicForm = () => {
207207
}
208208
fullWidth
209209
renderInput={(params) => (
210-
<TextField
210+
<SanitizedTextField
211211
{...params}
212212
InputLabelProps={{ shrink: true }}
213213
sx={staticFieldLabelStyle}
@@ -330,7 +330,7 @@ const AboutYouDemographicForm = () => {
330330
label={t('neurodivergentLabels.4')}
331331
/>
332332
</Box>
333-
<FormHelperText sx={{ m: 0, mt: '0 !important' }}>
333+
<FormHelperText sx={{ m: 0, mt: '0.5rem !important' }}>
334334
{t('neurodivergentHelpText')}
335335
</FormHelperText>
336336
</FormControl>
@@ -339,9 +339,9 @@ const AboutYouDemographicForm = () => {
339339
<FormLabel component="legend" sx={{ mb: 2 }}>
340340
{t.rich('raceEthnNatnLabel')}
341341
</FormLabel>
342-
<TextField
342+
<SanitizedTextField
343343
id="raceEthnNatn"
344-
onChange={(e) => setRaceEthnNatn(e.target.value)}
344+
onChange={setRaceEthnNatn}
345345
value={raceEthnNatn}
346346
variant="standard"
347347
fullWidth
@@ -374,7 +374,7 @@ const AboutYouDemographicForm = () => {
374374
}}
375375
popupIcon={<KeyboardArrowDown />}
376376
renderInput={(params) => (
377-
<TextField
377+
<SanitizedTextField
378378
{...params}
379379
required
380380
variant="standard"

components/forms/AboutYouSetAForm.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import SanitizedTextField from '@/components/common/SanitizedTextField';
34
import { usePathname, useRouter } from '@/i18n/routing';
45
import { useUpdateUserMutation } from '@/lib/api';
56
import { EMAIL_REMINDERS_FREQUENCY } from '@/lib/constants/enums';
@@ -19,7 +20,7 @@ import { ScaleFieldItem } from '@/lib/utils/interfaces';
1920
import logEvent from '@/lib/utils/logEvent';
2021
import { rowStyle, scaleTitleStyle, staticFieldLabelStyle } from '@/styles/common';
2122
import LoadingButton from '@mui/lab/LoadingButton';
22-
import { Box, FormControl, Slider, TextField, Typography } from '@mui/material';
23+
import { Box, FormControl, Slider, Typography } from '@mui/material';
2324
import { useRollbar } from '@rollbar/react';
2425
import axios from 'axios';
2526
import { useTranslations } from 'next-intl';
@@ -144,10 +145,10 @@ const AboutYouSetAForm = () => {
144145
return (
145146
<Box mt={3}>
146147
<form autoComplete="off" onSubmit={submitHandler}>
147-
<TextField
148+
<SanitizedTextField
148149
id="hopes"
149150
label={t.rich('hopesLabel')}
150-
onChange={(e) => setHopesInput(e.target.value)}
151+
onChange={setHopesInput}
151152
value={hopesInput}
152153
variant="standard"
153154
fullWidth

components/forms/ApplyCodeForm.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import SanitizedTextField from '@/components/common/SanitizedTextField';
34
import { useAssignPartnerAccessMutation } from '@/lib/api';
45
import { FEEDBACK_FORM_URL } from '@/lib/constants/common';
56
import { PARTNER_ACCESS_CODE_STATUS } from '@/lib/constants/enums';
@@ -11,7 +12,7 @@ import {
1112
} from '@/lib/constants/events';
1213
import { PartnerAccess } from '@/lib/store/partnerAccessSlice';
1314
import LoadingButton from '@mui/lab/LoadingButton';
14-
import { Box, Link, List, ListItem, TextField, Typography } from '@mui/material';
15+
import { Box, Link, List, ListItem, Typography } from '@mui/material';
1516
import { useTranslations } from 'next-intl';
1617
import { useState } from 'react';
1718

@@ -135,9 +136,9 @@ const ApplyCodeForm = () => {
135136
<Typography mb={2}>{t('formIntroduction')}</Typography>
136137

137138
<form autoComplete="off" onSubmit={submitHandler}>
138-
<TextField
139+
<SanitizedTextField
139140
id="accessCode"
140-
onChange={(e) => setCodeInput(e.target.value)}
141+
onChange={setCodeInput}
141142
label={t.rich('form.codeLabel')}
142143
variant="standard"
143144
fullWidth

components/forms/BaseRegisterForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export const useRegisterFormLogic = (redirectPath: string = defaultSignupRedirec
116116
} else if (errorMessage === CREATE_USER_INVALID_EMAIL) {
117117
setFormError(t('firebase.invalidEmail'));
118118
} else {
119-
logEvent(REGISTER_ERROR, { message: errorMessage });
119+
logEvent(REGISTER_ERROR, { message: String(errorMessage) });
120120
rollbar.error('User register create user error', error);
121121
setFormError(
122122
t.rich('createUserError', {

components/forms/CreatePartnerAdminForm.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import SanitizedTextField from '@/components/common/SanitizedTextField';
34
import { useAddPartnerAdminMutation, useGetPartnersQuery } from '@/lib/api';
45
import {
56
CREATE_PARTNER_ADMIN_ERROR,
@@ -10,7 +11,7 @@ import { useTypedSelector } from '@/lib/hooks/store';
1011
import { getErrorMessage } from '@/lib/utils/errorMessage';
1112
import logEvent from '@/lib/utils/logEvent';
1213
import LoadingButton from '@mui/lab/LoadingButton';
13-
import { Box, Button, MenuItem, TextField, Typography } from '@mui/material';
14+
import { Box, Button, MenuItem, Typography } from '@mui/material';
1415
import { useRollbar } from '@rollbar/react';
1516
import { useTranslations } from 'next-intl';
1617
import * as React from 'react';
@@ -102,14 +103,14 @@ const CreatePartnerAdminForm = () => {
102103

103104
return (
104105
<form autoComplete="off" onSubmit={submitHandler}>
105-
<TextField
106+
<SanitizedTextField
106107
id="selectPartner"
107108
name="selectPartner"
108109
key="select-partner"
109110
fullWidth
110111
select
111112
label={t('partnerNameLabel')}
112-
onChange={(e) => setSelectedPartner(e.target.value)}
113+
onChange={setSelectedPartner}
113114
variant="standard"
114115
required
115116
value={selectedPartner}
@@ -119,25 +120,25 @@ const CreatePartnerAdminForm = () => {
119120
{option.name}
120121
</MenuItem>
121122
))}
122-
</TextField>
123+
</SanitizedTextField>
123124

124-
<TextField
125+
<SanitizedTextField
125126
id="email"
126127
name="email"
127128
key="email-input"
128-
onChange={(e) => setEmail(e.target.value)}
129+
onChange={setEmail}
129130
label={t('emailAddressLabel')}
130131
variant="standard"
131132
type="email"
132133
fullWidth
133134
required
134135
value={email}
135136
/>
136-
<TextField
137+
<SanitizedTextField
137138
id="name"
138139
name="name"
139140
key="name-input"
140-
onChange={(e) => setName(e.target.value)}
141+
onChange={setName}
141142
label={t('nameLabel')}
142143
variant="standard"
143144
type="text"

components/forms/EmailSettingsForm.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ const EmailSettingsForm = () => {
2525
const contactPermission = useTypedSelector((state) => state.user.contactPermission);
2626
const serviceEmailsPermission = useTypedSelector((state) => state.user.serviceEmailsPermission);
2727

28+
const [contactPermissionValue, setContactPermissionValue] = useState(contactPermission);
29+
const [serviceEmailsPermissionValue, setServiceEmailsPermissionValue] =
30+
useState(serviceEmailsPermission);
31+
2832
const onSubmit = useCallback(
2933
async (ev: React.FormEvent<HTMLFormElement>) => {
30-
const formData = new FormData(ev.currentTarget);
3134
ev.preventDefault();
3235

33-
const contactPermissionValue = formData.get('contactPermission') === 'on';
34-
const serviceEmailsPermissionValue = formData.get('serviceEmailsPermission') === 'on';
3536
const payload = {
3637
contactPermission: contactPermissionValue,
3738
serviceEmailsPermission: serviceEmailsPermissionValue,
@@ -53,7 +54,7 @@ const EmailSettingsForm = () => {
5354
);
5455
}
5556
},
56-
[updateUser, t],
57+
[updateUser, t, contactPermissionValue, serviceEmailsPermissionValue],
5758
);
5859

5960
return (
@@ -63,23 +64,27 @@ const EmailSettingsForm = () => {
6364
label={t('serviceEmailsPermissionLabel')}
6465
control={
6566
<Checkbox
66-
name="serviceEmailsPermission"
67+
checked={serviceEmailsPermissionValue}
68+
onChange={(e) => {
69+
setServiceEmailsPermissionValue(e.target.checked);
70+
setIsSuccess(false);
71+
}}
6772
aria-label={t('serviceEmailsPermissionLabel')}
68-
defaultChecked={serviceEmailsPermission}
6973
/>
7074
}
71-
onInput={() => setIsSuccess(false)}
7275
/>
7376
<FormControlLabel
7477
label={t('contactPermissionLabel')}
7578
control={
7679
<Checkbox
77-
name="contactPermission"
80+
checked={contactPermissionValue}
81+
onChange={(e) => {
82+
setContactPermissionValue(e.target.checked);
83+
setIsSuccess(false);
84+
}}
7885
aria-label={t('contactPermissionLabel')}
79-
defaultChecked={contactPermission}
8086
/>
8187
}
82-
onInput={() => setIsSuccess(false)}
8388
/>
8489
{error && <Typography color="error.main">{error}</Typography>}
8590
</FormControl>

0 commit comments

Comments
 (0)