diff --git a/lib/Controller/API/App/CreateProjectController.php b/lib/Controller/API/App/CreateProjectController.php index f348e35a77..c24b4b837b 100644 --- a/lib/Controller/API/App/CreateProjectController.php +++ b/lib/Controller/API/App/CreateProjectController.php @@ -457,6 +457,7 @@ private function validateMtEngine(?int $mt_engine = 0): array * @return array * @throws \DomainException * @throws \TypeError + * @throws InvalidArgumentException */ private static function sanitizeTmKeyArr(array $elem): array { diff --git a/lib/Controller/API/App/TMXFileController.php b/lib/Controller/API/App/TMXFileController.php index 5859480348..8b341b2a4f 100644 --- a/lib/Controller/API/App/TMXFileController.php +++ b/lib/Controller/API/App/TMXFileController.php @@ -12,6 +12,7 @@ use Model\TmKeyManagement\MemoryKeyStruct; use TypeError; use Utils\Registry\AppConfig; +use Utils\TmKeyManagement\TmKeyManager; use Utils\TmKeyManagement\TmKeyStruct; use Utils\TMS\TMSFile; use Utils\TMS\TMSService; @@ -69,8 +70,12 @@ public function import(): void $userMemoryKey = $mkDao->read($searchMemoryKey); if (!empty($userMemoryKey) && isset($userMemoryKey[0]->tm_key) && empty($userMemoryKey[0]->tm_key->name)) { - $userMemoryKey[0]->tm_key->name = $fileInfo->name; - $mkDao->atomicUpdate($userMemoryKey[0]); + try { + $userMemoryKey[0]->tm_key->name = TmKeyManager::validateName($fileInfo->name); + $mkDao->atomicUpdate($userMemoryKey[0]); + } catch (InvalidArgumentException) { + // the TMX filename is not a usable resource name: skip the optional rename + } } } } diff --git a/lib/Controller/API/App/TmKeyManagementController.php b/lib/Controller/API/App/TmKeyManagementController.php index 8b4e95d29b..78d399fae8 100644 --- a/lib/Controller/API/App/TmKeyManagementController.php +++ b/lib/Controller/API/App/TmKeyManagementController.php @@ -122,16 +122,6 @@ private function sortKeysInTheRightOrder(array $keys, array $jobKeyList): array } - if (!empty($sortedKeys)) { - $sortedKeys = array_map(function (ClientTmKeyStruct $jobKey) { - if ($jobKey->name !== null) { - $jobKey->name = html_entity_decode($jobKey->name); - } - - return $jobKey; - }, $sortedKeys); - } - return $sortedKeys; } diff --git a/lib/Controller/API/App/UserKeysController.php b/lib/Controller/API/App/UserKeysController.php index 423f90f369..9da30cec56 100644 --- a/lib/Controller/API/App/UserKeysController.php +++ b/lib/Controller/API/App/UserKeysController.php @@ -166,7 +166,7 @@ private function validateTheRequest(): array { $key = filter_var($this->request->param('key'), FILTER_SANITIZE_SPECIAL_CHARS, ['flags' => FILTER_FLAG_STRIP_LOW]); $emails = filter_var($this->request->param('emails'), FILTER_SANITIZE_SPECIAL_CHARS, ['flags' => FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH]); - $description = filter_var($this->request->param('description'), FILTER_SANITIZE_SPECIAL_CHARS, ['flags' => FILTER_FLAG_STRIP_LOW]); + $description = $this->request->param('description'); $remove_from = filter_var($this->request->param('remove_from'), FILTER_SANITIZE_FULL_SPECIAL_CHARS, ['flags' => FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH]); // check for eventual errors on the input passed @@ -174,24 +174,10 @@ private function validateTheRequest(): array throw new InvalidArgumentException("Key missing", -2); } - // Prevent XSS attack - // =========================== - // POC. Try to add this string in the input: - //
- // in this case, an error MUST be thrown - if ($this->request->param('description') and $this->request->param('description') !== $description) { - throw new InvalidArgumentException( - "Resource names cannot contain the following characters:" - . "", - -3 - ); - } + // Names are stored raw: TmKeyManager::validateName() enforces semantics + // (string type, valid UTF-8, no invisible characters) and rejects bad + // input with code -3; HTML/XML escaping happens at each output sink. + $description = TmKeyManager::validateName($description); return [ 'key' => $key, diff --git a/lib/Controller/API/V1/NewController.php b/lib/Controller/API/V1/NewController.php index 501296f150..c1bdff4a75 100644 --- a/lib/Controller/API/V1/NewController.php +++ b/lib/Controller/API/V1/NewController.php @@ -971,6 +971,7 @@ function ($item) { * @return array * @throws \DomainException * @throws \TypeError + * @throws InvalidArgumentException */ private static function sanitizeTmKeyArr(array $elem): array { diff --git a/lib/Controller/API/V3/DownloadQRController.php b/lib/Controller/API/V3/DownloadQRController.php index 71e5663978..4606a5965e 100644 --- a/lib/Controller/API/V3/DownloadQRController.php +++ b/lib/Controller/API/V3/DownloadQRController.php @@ -396,7 +396,7 @@ private function createXMLFile(array $data, array $categoryIssues = []): false|s $xml .= '' . $datum[10] . ''; $xml .= '' . $datum[11] . ''; $xml .= '' . $datum[12] . ''; - $xml .= '' . $datum[13] . ''; + $xml .= '' . htmlspecialchars($datum[13] ?? '', ENT_QUOTES | ENT_XML1, 'UTF-8') . ''; $xml .= '' . $datum[14] . ''; $xml .= '' . $datum[15] . ''; $xml .= '' . $datum[16] . ''; diff --git a/lib/Utils/TMS/TMSService.php b/lib/Utils/TMS/TMSService.php index 7f26bfd170..2cb95dddbe 100644 --- a/lib/Utils/TMS/TMSService.php +++ b/lib/Utils/TMS/TMSService.php @@ -574,7 +574,7 @@ private function buildTmOriginProp(array $row, ?int $uid): string $suggestionsArray = json_decode($row['suggestions_array'], true); $suggestionOrigin = Utils::changeMemorySuggestionSource($suggestionsArray[0], $row['tm_keys'], $this->database, $uid); - $tmOrigin = '' . $suggestionOrigin . ""; + $tmOrigin = '' . htmlspecialchars($suggestionOrigin, ENT_QUOTES | ENT_XML1, 'UTF-8') . ""; if (preg_match("/[a-f0-9]{8,}/", $suggestionsArray[0]['memory_key'])) { $tmOrigin .= "\n " . $suggestionsArray[0]['memory_key'] . ""; } diff --git a/lib/Utils/TmKeyManagement/TmKeyManager.php b/lib/Utils/TmKeyManagement/TmKeyManager.php index 5f98043d7c..1b25ae4636 100644 --- a/lib/Utils/TmKeyManagement/TmKeyManager.php +++ b/lib/Utils/TmKeyManagement/TmKeyManager.php @@ -4,7 +4,9 @@ use DomainException; use Exception; +use InvalidArgumentException; use Model\DataAccess\Database; +use Normalizer; use Model\DataAccess\IDatabase; use Model\TmKeyManagement\MemoryKeyDao; use Model\TmKeyManagement\MemoryKeyStruct; @@ -196,6 +198,47 @@ public static function isValidStructure(array $arr): TmKeyStruct|bool return $myObj; } + /** + * Validates and normalizes a user-provided resource name. + * + * Names are stored raw: no HTML escaping happens here, encoding for a + * specific destination (HTML, XML, email template) is the output layer's + * job. This method only enforces semantics: a name must be a UTF-8 string + * without invisible characters. + * + * @param mixed $name + * + * @return string|null + * + * @throws InvalidArgumentException + */ + public static function validateName(mixed $name): ?string + { + if (is_null($name)) { + return null; + } + + if (!is_string($name)) { + throw new InvalidArgumentException("Resource name must be a string", -3); + } + + if (!mb_check_encoding($name, 'UTF-8')) { + throw new InvalidArgumentException("Resource name is not valid UTF-8", -3); + } + + // Cc covers NUL/ESC/newlines, Cf covers zero-width and bidi overrides + // used to spoof how a name reads. U+200D (ZWJ) is excluded so emoji + // sequences survive. + $name = preg_replace('/(?![\x{200D}])[\p{Cc}\p{Cf}]/u', '', $name) ?? ''; + $name = Normalizer::normalize(trim($name), Normalizer::FORM_C); + + if ($name === false) { + throw new InvalidArgumentException("Resource name is not valid UTF-8", -3); + } + + return mb_substr($name, 0, 255); + } + /** * This method sanitize fields received with struct * @@ -205,6 +248,7 @@ public static function isValidStructure(array $arr): TmKeyStruct|bool * * @throws \TypeError * @throws DomainException + * @throws InvalidArgumentException */ public static function sanitize(TmKeyStruct $obj): TmKeyStruct { @@ -230,11 +274,7 @@ public static function sanitize(TmKeyStruct $obj): TmKeyStruct $obj->uid_rev = $sanitized !== false ? (int)$sanitized : null; } - if (!is_null($obj->name)) { - $obj->name = preg_replace('/[^.\-_\p{L}\p{N}\s{}]+/u', '', $obj->name); - $sanitized = filter_var($obj->name, FILTER_SANITIZE_SPECIAL_CHARS, ['flags' => FILTER_FLAG_STRIP_LOW]); - $obj->name = $sanitized !== false ? $sanitized : null; - } + $obj->name = self::validateName($obj->name); if (!is_null($obj->key)) { $sanitized = filter_var($obj->key, FILTER_SANITIZE_SPECIAL_CHARS, ['flags' => FILTER_FLAG_STRIP_LOW]); diff --git a/lib/View/Emails/ShareKey/message_content.html b/lib/View/Emails/ShareKey/message_content.html index a8effba9ce..e75e6360e2 100644 --- a/lib/View/Emails/ShareKey/message_content.html +++ b/lib/View/Emails/ShareKey/message_content.html @@ -1,15 +1,15 @@

Hello,
- () shared the following language resource with you. + () shared the following language resource with you.

- Description: + Description:
Private key:

- If you have an account on Matecat registered to , you will find the language resource in the 'Translation Memory and Glossary' tab of the settings panel. + If you have an account on Matecat registered to , you will find the language resource in the 'Translation Memory and Glossary' tab of the settings panel.

Otherwise:

    diff --git a/package.json b/package.json index aa42c7e44d..5c9f555cab 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "classnames": "^2.2.6", "crypto-js": "^4.1.1", "diff-match-patch": "^1.0.5", + "dompurify": "^3.4.12", "draft-js": "^0.11.4", "events": "^3.3.0", "file-saver": "^2.0.5", diff --git a/public/js/actions/CatToolActions.js b/public/js/actions/CatToolActions.js index 95d5dbedc7..d53fc6ffb2 100644 --- a/public/js/actions/CatToolActions.js +++ b/public/js/actions/CatToolActions.js @@ -209,7 +209,9 @@ let CatToolActions = { * tc (top center), br (bottom right), bl (bottom left), bc (bottom center) * closeCallback (Function) A callback function that will be called when the notification is about to be removed. * openCallback (Function) A callback function that will be called when the notification is successfully added. - * allowHtml: (Boolean, Default false) Set to true if the text contains HTML, like buttons + * allowHtml: (Boolean, Default false) Reserved for operator-authored broadcasts (SSE global messages). + * The HTML is sanitized before rendering. For anything interpolating user data, + * pass a ReactNode as text instead. * autoDismiss: (Boolean, Default true) Set if notification is dismissible by the user. * */ diff --git a/public/js/actions/ManageActions.js b/public/js/actions/ManageActions.js index 7cc3bff9ea..f63fe03454 100644 --- a/public/js/actions/ManageActions.js +++ b/public/js/actions/ManageActions.js @@ -197,7 +197,6 @@ let ManageActions = { text: 'Something went wrong, the project has been assigned to another member or moved to another team.', type: 'warning', position: 'bl', - allowHtml: true, autoDismiss: false, } CatToolActions.addNotification(notification) @@ -234,7 +233,6 @@ let ManageActions = { name, type: 'success', position: 'bl', - allowHtml: true, timer: 3000, } CatToolActions.addNotification(notification) @@ -283,7 +281,6 @@ let ManageActions = { text: `The selected projects have been successfully assigned to ${user.first_name} ${user.last_name}.`, type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(notification) @@ -293,7 +290,6 @@ let ManageActions = { text: 'Some projects failed', type: 'error', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(errorNotification) @@ -366,7 +362,6 @@ let ManageActions = { ' Team', type: 'success', position: 'bl', - allowHtml: true, timer: 3000, } CatToolActions.addNotification(notification) @@ -457,7 +452,6 @@ let ManageActions = { text: `The selected projects have been successfully moved to the ${team.name} team.`, type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(notification) @@ -467,7 +461,6 @@ let ManageActions = { text: 'Some projects failed', type: 'error', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(errorNotification) diff --git a/public/js/actions/SegmentActions.js b/public/js/actions/SegmentActions.js index bbcd7b1b2e..a1e43fa34b 100644 --- a/public/js/actions/SegmentActions.js +++ b/public/js/actions/SegmentActions.js @@ -603,13 +603,24 @@ const SegmentActions = { !config.isReview && config.job_completion_current_phase == 'revise' if (projectCompletionCheck) { - let message = - 'All segments are in read-only mode because this job is under review.' + let message = ( + <> + All segments are in read-only mode because this job is under + review. + + ) if (config.chunk_completion_undoable && config.last_completion_event_id) { - message = - message + - '

    Re-Open Job

    ' + message = ( + <> + {message} +

    + + Re-Open Job + +

    + + ) } addNotification({ @@ -620,7 +631,6 @@ const SegmentActions = { text: message, title: 'Warning', type: 'warning', - allowHtml: true, }) } if (TextUtils.justSelecting('readonly')) return diff --git a/public/js/components/header/cattol/MarkAsCompleteButton.js b/public/js/components/header/cattol/MarkAsCompleteButton.js index 89d39ce40b..2d4d0b310c 100644 --- a/public/js/components/header/cattol/MarkAsCompleteButton.js +++ b/public/js/components/header/cattol/MarkAsCompleteButton.js @@ -61,13 +61,24 @@ export const MarkAsCompleteButton = ({featureEnabled, isReview}) => { const showTranslateWarningMessage = () => { // if (!lastCompletionEventId) return - let message = - 'All segments are in read-only mode because this job is under review.' + let message = ( + <> + All segments are in read-only mode because this job is under + review. + + ) if (config.chunk_completion_undoable && config.last_completion_event_id) { - message = - message + - '

    Re-Open Job

    ' + message = ( + <> + {message} +

    + + Re-Open Job + +

    + + ) } CatToolActions.addNotification({ @@ -78,7 +89,6 @@ export const MarkAsCompleteButton = ({featureEnabled, isReview}) => { text: message, title: 'Warning', type: 'warning', - allowHtml: true, }) } diff --git a/public/js/components/header/cattol/segment_filter/segment_filter.js b/public/js/components/header/cattol/segment_filter/segment_filter.js index 59449e0b32..bf260b4c27 100644 --- a/public/js/components/header/cattol/segment_filter/segment_filter.js +++ b/public/js/components/header/cattol/segment_filter/segment_filter.js @@ -39,7 +39,6 @@ let SegmentFilterUtils = { text: text, title: title, type: 'warning', - allowHtml: true, }) })() }, diff --git a/public/js/components/modals/ShareTmModal.js b/public/js/components/modals/ShareTmModal.js index e86bfeae16..f9cda4fa95 100644 --- a/public/js/components/modals/ShareTmModal.js +++ b/public/js/components/modals/ShareTmModal.js @@ -52,7 +52,6 @@ class ShareTmModal extends React.Component { type: 'success', text: `The resource has been shared.`, position: 'br', - allowHtml: true, timer: 5000, }) callback.call() diff --git a/public/js/components/notificationsComponent/NotificationBox.js b/public/js/components/notificationsComponent/NotificationBox.js index c6584d94d3..acf1439bac 100644 --- a/public/js/components/notificationsComponent/NotificationBox.js +++ b/public/js/components/notificationsComponent/NotificationBox.js @@ -10,7 +10,9 @@ * position: (String, Default "bl") Position of the notification. Available: tr (top right), tl (top left), * tc (top center), br (bottom right), bl (bottom left), bc (bottom center) * autoDismiss: (Boolean, Default true) Set if notification is dismissible by the user. - * allowHtml: (Boolean, Default false) Set to true if the text contains HTML, like buttons + * allowHtml: (Boolean, Default false) Reserved for operator-authored broadcasts (SSE global messages). + * The HTML is sanitized before rendering. For anything interpolating user data, + * pass a ReactNode as text instead. * closeCallback (Function) A callback function that will be called when the notification is about to be removed. * openCallback (Function) A callback function that will be called when the notification is successfully added. * dismissable (Boolean, Default true) If show or not the button to close the notification diff --git a/public/js/components/notificationsComponent/NotificationItem.js b/public/js/components/notificationsComponent/NotificationItem.js index 91cba7dffd..d67dc318d7 100644 --- a/public/js/components/notificationsComponent/NotificationItem.js +++ b/public/js/components/notificationsComponent/NotificationItem.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types' import React, {useState, useRef, useEffect} from 'react' +import TEXT_UTILS from '../../utils/textUtils' const NotificationItem = ({ uid, @@ -66,9 +67,10 @@ const NotificationItem = ({ } }, [remove]) - const allowHTML = (string) => { - return {__html: string} - } + // allowHtml is reserved for operator-authored broadcasts (SSE global + // messages): the markup is sanitized before injection. Any notification + // interpolating user data must pass a ReactNode as text instead. + const allowHTML = (string) => TEXT_UTILS.sanitizedHTML(string) const getCssPropertyByPosition = () => { let css = {} @@ -154,7 +156,7 @@ const NotificationItem = ({ NotificationItem.propTypes = { position: PropTypes.string, - title: PropTypes.string.isRequired, + title: PropTypes.node.isRequired, text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, type: PropTypes.string, autoDismiss: PropTypes.bool, diff --git a/public/js/components/notificationsComponent/NotificationItem.test.js b/public/js/components/notificationsComponent/NotificationItem.test.js new file mode 100644 index 0000000000..d1ee2fc2cd --- /dev/null +++ b/public/js/components/notificationsComponent/NotificationItem.test.js @@ -0,0 +1,62 @@ +import React from 'react' +import {render, screen} from '@testing-library/react' +import NotificationItem from './NotificationItem' + +const baseProps = { + uid: 'test-notification', + type: 'success', + position: 'br', + autoDismiss: false, + onRemove: () => {}, +} + +test('renders a ReactNode text with user data as plain text', () => { + const name = ' R&D' + render( + + The resource {name} has been shared. + + } + />, + ) + + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByText(name)).toBeInTheDocument() + expect(document.querySelector('.notification-message b')).not.toBeNull() + expect(window.__xss).toBeUndefined() +}) + +test('sanitizes markup rendered through allowHtml', () => { + render( + , + ) + + const message = document.querySelector('.notification-message') + expect(message.querySelector('b')).not.toBeNull() + const img = message.querySelector('img') + if (img) { + expect(img.getAttribute('onerror')).toBeNull() + } + expect(window.__xss).toBeUndefined() +}) + +test('renders a plain string text as text', () => { + render( + , + ) + + expect(screen.getByText('')).toBeInTheDocument() +}) diff --git a/public/js/components/outsource/AssignToTranslator.js b/public/js/components/outsource/AssignToTranslator.js index 20490fe22e..45c507c5c4 100644 --- a/public/js/components/outsource/AssignToTranslator.js +++ b/public/js/components/outsource/AssignToTranslator.js @@ -82,7 +82,6 @@ class AssignToTranslator extends React.Component { text: message.text, type: 'success', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(notification) @@ -101,49 +100,97 @@ class AssignToTranslator extends React.Component { } shareToTranslatorMailChangeNotification(mail, job) { return { - title: - 'Job sent with
    new password
    ', - text: - '
    To: ' + - mail + - ' ' + - '
    ' + - '
    (' + - job.id + - ')
    ' + - '
    ' + - '
    ' + - job.sourceTxt + - '
    ' + - '
    ' + - '
    ' + - job.targetTxt + - '
    ', + title: ( + <> + Job sent with{' '} +
    + new password{' '} +
    + + ), + text: ( +
    + To: {mail}{' '} +
    + {' '} +
    + ({job.id}) +
    {' '} +
    + {' '} +
    + {job.sourceTxt} +
    {' '} +
    + {' '} + {' '} +
    {' '} +
    + {job.targetTxt} +
    {' '} +
    {' '} +
    +
    + ), } } shareToTranslatorNotification(mail, job) { return { title: 'Job sent', - text: - '
    To: ' + - mail + - ' ' + - '
    ' + - '
    ' + - job.id + - '
    ' + - '
    ' + - '
    ' + - job.sourceTxt + - '
    ' + - '
    ' + - '
    ' + - job.targetTxt + - '
    ', + text: ( +
    + To: {mail}{' '} +
    + {' '} +
    + {job.id}{' '} +
    {' '} +
    + {' '} +
    + {job.sourceTxt} +
    {' '} +
    + {' '} + {' '} +
    {' '} +
    + {job.targetTxt} +
    {' '} +
    {' '} +
    +
    + ), } } shareToTranslatorDateChangeNotification(email, oldDate, newDate) { @@ -151,52 +198,83 @@ class AssignToTranslator extends React.Component { oldDate = CommonUtils.getGMTDate(oldDate) newDate = CommonUtils.formatDate(newDate, 'yyyy-MM-d hh:mm a') newDate = CommonUtils.getGMTDate(newDate) + const dateBlock = (date, notUsed) => ( +
    + {' '} +
    + {date.day} +
    {' '} +
    + {date.month} +
    {' '} +
    + {date.time} +
    {' '} +
    + ({date.gmt}) +
    {' '} + {notUsed && ( +
    + )} +
    + ) return { title: 'Job delivery update', - text: - '
    To: ' + - '
    ' + - '
    ' + - newDate.day + - '
    ' + - '
    ' + - newDate.month + - '
    ' + - '
    ' + - newDate.time + - '
    ' + - '
    (' + - newDate.gmt + - ')
    ' + - '
    ' + - '
    ' + - oldDate.day + - '
    ' + - '
    ' + - oldDate.month + - '
    ' + - '
    ' + - oldDate.time + - '
    ' + - '
    (' + - oldDate.gmt + - ')
    ' + - '
    ' + - '
    Translator: ' + - email + - '
    ', + text: ( +
    +
    + {' '} + To: {dateBlock(newDate, false)} {dateBlock(oldDate, true)} + Translator: {email}{' '} +
    +
    + ), } } showShareTranslatorError() { ModalsActions.onCloseModal() const notification = { title: 'Problems sending the job', - text: 'Please try later or contact support@matecat.com', + text: ( + <> + Please try later or contact{' '} + support@matecat.com + + ), type: 'error', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(notification) diff --git a/public/js/components/projects/JobContainer.js b/public/js/components/projects/JobContainer.js index 0df1bc65f1..7f7297790e 100644 --- a/public/js/components/projects/JobContainer.js +++ b/public/js/components/projects/JobContainer.js @@ -151,21 +151,58 @@ class JobContainer extends React.Component { this.oldPassword, revision_number, ).then(function (data) { + let translator = self.props.job.get('translator') + const undoChangePassword = function () { + CatToolActions.removeNotification(notification) + changeJobPassword( + self.props.job.toJS(), + data.new_pwd, + revision_number, + 1, + self.oldPassword, + ).then(function (data) { + const restoreNotification = { + title: 'Change job password', + text: 'The previous password has been restored.', + type: 'warning', + position: 'bl', + timer: 7000, + } + CatToolActions.addNotification(restoreNotification) + ManageActions.changeJobPassword( + self.props.project, + self.props.job, + data.new_pwd, + data.old_pwd, + revision_number, + translator, + ) + }) + } const notification = { uid: 'change-password', title: revision_number ? `${revision_number === 1 ? 'Revise' : 'Revise 2'} password changed` : 'Translate password changed', - text: revision_number - ? `The ${revision_number === 1 ? 'Revise' : 'Revise 2'} password has been changed. Undo` - : 'The Translate password has been changed. Undo', + text: ( + <> + The{' '} + {revision_number + ? revision_number === 1 + ? 'Revise' + : 'Revise 2' + : 'Translate'}{' '} + password has been changed.{' '} + + Undo + + + ), type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(notification) - let translator = self.props.job.get('translator') ManageActions.changeJobPassword( self.props.project, self.props.job, @@ -173,55 +210,59 @@ class JobContainer extends React.Component { data.old_pwd, revision_number, ) - setTimeout(function () { - $('.undo-password').off('click') - $('.undo-password').on('click', function () { + }) + } + + removeTranslator() { + let self = this + this.oldPassword = this.props.job.get('password') + changeJobPassword(this.props.job.toJS(), this.oldPassword).then( + function (data) { + let translator = self.props.job.get('translator') + const undoRemoveTranslator = function () { CatToolActions.removeNotification(notification) changeJobPassword( self.props.job.toJS(), data.new_pwd, - revision_number, + null, 1, self.oldPassword, ).then(function (data) { - const restoreNotification = { + const passwordNotification = { + uid: 'change-password', title: 'Change job password', text: 'The previous password has been restored.', type: 'warning', position: 'bl', timer: 7000, } - CatToolActions.addNotification(restoreNotification) + CatToolActions.addNotification(passwordNotification) ManageActions.changeJobPassword( self.props.project, self.props.job, data.new_pwd, data.old_pwd, - revision_number, + null, translator, ) }) - }) - }, 500) - }) - } - - removeTranslator() { - let self = this - this.oldPassword = this.props.job.get('password') - changeJobPassword(this.props.job.toJS(), this.oldPassword).then( - function (data) { + } const notification = { uid: 'remove-translator', title: 'Job unassigned', - text: 'The translator has been removed and the password changed. Undo', + text: ( + <> + The translator has been removed and the password changed.{' '} + + Undo + + + ), type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(notification) - let translator = self.props.job.get('translator') ManageActions.changeJobPassword( self.props.project, self.props.job, @@ -230,37 +271,6 @@ class JobContainer extends React.Component { null, null, ) - setTimeout(function () { - $('.undo-password').off('click') - $('.undo-password').on('click', function () { - CatToolActions.removeNotification(notification) - changeJobPassword( - self.props.job.toJS(), - data.new_pwd, - null, - 1, - self.oldPassword, - ).then(function (data) { - const passwordNotification = { - uid: 'change-password', - title: 'Change job password', - text: 'The previous password has been restored.', - type: 'warning', - position: 'bl', - timer: 7000, - } - CatToolActions.addNotification(passwordNotification) - ManageActions.changeJobPassword( - self.props.project, - self.props.job, - data.new_pwd, - data.old_pwd, - null, - translator, - ) - }) - }) - }, 500) }, ) } @@ -273,7 +283,6 @@ class JobContainer extends React.Component { text: `The selected jobs has been successfully archived.`, type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, }) } @@ -287,7 +296,6 @@ class JobContainer extends React.Component { text: `The selected jobs has been successfully canceled.`, type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, }) } @@ -301,7 +309,6 @@ class JobContainer extends React.Component { text: `The selected jobs has been successfully unarchived.`, type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, }) } @@ -325,7 +332,6 @@ class JobContainer extends React.Component { text: `The selected jobs has been successfully deleted permanently.`, type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, }) } diff --git a/public/js/components/projects/ProjectsBulkActions/ProjectsBulkActions.js b/public/js/components/projects/ProjectsBulkActions/ProjectsBulkActions.js index 8879887aab..b457c0d82d 100644 --- a/public/js/components/projects/ProjectsBulkActions/ProjectsBulkActions.js +++ b/public/js/components/projects/ProjectsBulkActions/ProjectsBulkActions.js @@ -384,7 +384,6 @@ export const ProjectsBulkActions = ({ text: `The selected jobs have been successfully ${id === JOBS_ACTIONS.ARCHIVE.id ? 'archived' : id === JOBS_ACTIONS.UNARCHIVE.id ? 'unarchived' : id === JOBS_ACTIONS.CANCEL.id ? 'canceled' : id === JOBS_ACTIONS.RESUME.id ? 'resumed' : 'deleted permanently'}.`, type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, }) break @@ -410,7 +409,6 @@ export const ProjectsBulkActions = ({ text: 'The Revise 2 links for the selected jobs have been generated successfully.', type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, }) break @@ -449,7 +447,6 @@ export const ProjectsBulkActions = ({ : 'The Translate passwords for the selected jobs have been changed successfully', type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(notification) @@ -459,7 +456,6 @@ export const ProjectsBulkActions = ({ text: 'Some jobs failed', type: 'error', position: 'bl', - allowHtml: true, timer: 10000, } CatToolActions.addNotification(errorNotification) diff --git a/public/js/components/quality_report/SegmentQR.js b/public/js/components/quality_report/SegmentQR.js index f9db22211c..8f10c71bd1 100644 --- a/public/js/components/quality_report/SegmentQR.js +++ b/public/js/components/quality_report/SegmentQR.js @@ -327,7 +327,7 @@ class SegmentQR extends React.Component { return text } allowHTML(string) { - return {__html: string} + return TextUtils.sanitizedHTML(string) } componentDidUpdate(prevProps) { if (prevProps.revisionToShow !== this.props.revisionToShow) { diff --git a/public/js/components/quality_report/SegmentQRLine.js b/public/js/components/quality_report/SegmentQRLine.js index 70deb4b321..40f4ff54c9 100644 --- a/public/js/components/quality_report/SegmentQRLine.js +++ b/public/js/components/quality_report/SegmentQRLine.js @@ -1,4 +1,5 @@ import React, {useRef} from 'react' +import TEXT_UTILS from '../../utils/textUtils' const SegmentQRLine = ({ showSuggestionSource = false, segment, @@ -16,9 +17,7 @@ const SegmentQRLine = ({ rev, }) => { const textRef = useRef() - const allowHTML = (string) => { - return {__html: string} - } + const allowHTML = (string) => TEXT_UTILS.sanitizedHTML(string) const getTimeToEdit = (tte) => { let str_pad_left = function (string, pad, length) { return (new Array(length + 1).join(pad) + string).slice(-length) diff --git a/public/js/components/review_extended/ReviewExtendedIssue.js b/public/js/components/review_extended/ReviewExtendedIssue.js index 89e60cfb35..a4be4381f8 100644 --- a/public/js/components/review_extended/ReviewExtendedIssue.js +++ b/public/js/components/review_extended/ReviewExtendedIssue.js @@ -75,15 +75,32 @@ export const ReviewExtendedIssue = ({ if (issue.id === issueEditing?.id) setIssueEditing(undefined) CatToolActions.removeAllNotifications() + const undoDeleteIssue = function () { + setVisible(true) + changeVisibility(issue.id, true) + CatToolActions.removeAllNotifications() + const restoreNotification = { + title: 'Issue deleted', + text: 'The issue has been restored.', + type: 'warning', + position: 'bl', + timer: 5000, + } + CatToolActions.addNotification(restoreNotification) + window.onbeforeunload = null + } const notification = { title: 'Issue deleted', - text: - 'The selected issue has been deleted. Undo', + text: ( + <> + The selected issue has been deleted.{' '} + + Undo + + + ), type: 'warning', position: 'bl', - allowHtml: true, timer: 10000, closeCallback: function () { SegmentActions.deleteIssue(issue, sid) @@ -93,24 +110,6 @@ export const ReviewExtendedIssue = ({ window.onbeforeunload = function () { SegmentActions.deleteIssue(issue, sid) } - setTimeout(function () { - const $button = $('.undo-issue-deleted-' + issue.id) - $button.off('click') - $button.on('click', function () { - setVisible(true) - changeVisibility(issue.id, true) - CatToolActions.removeAllNotifications() - const notification = { - title: 'Issue deleted', - text: 'The issue has been restored.', - type: 'warning', - position: 'bl', - timer: 5000, - } - CatToolActions.addNotification(notification) - window.onbeforeunload = null - }) - }, 500) } const setCommentViewCallback = (event) => { diff --git a/public/js/components/segments/SegmentFooterTabAiAlternatives.js b/public/js/components/segments/SegmentFooterTabAiAlternatives.js index 057d25ec39..bdaf79101f 100644 --- a/public/js/components/segments/SegmentFooterTabAiAlternatives.js +++ b/public/js/components/segments/SegmentFooterTabAiAlternatives.js @@ -1,6 +1,7 @@ import React, {createRef, useEffect, useRef, useState} from 'react' import PropTypes from 'prop-types' import SegmentStore from '../../stores/SegmentStore' +import TEXT_UTILS from '../../utils/textUtils' import SegmentConstants from '../../constants/SegmentConstants' import {Button, BUTTON_MODE, BUTTON_TYPE} from '../common/Button/Button' import DraftMatecatUtils from './utils/DraftMatecatUtils' @@ -338,9 +339,7 @@ export const SegmentFooterTabAiAlternatives = ({ } }, [segment]) - const allowHTML = (string) => { - return {__html: string} - } + const allowHTML = (string) => TEXT_UTILS.sanitizedHTML(string) return (
    { - return {__html: string} - } + const allowHTML = (string) => TEXT_UTILS.sanitizedHTML(string) return (
    { @@ -73,9 +72,13 @@ export const DeepLGlossary = ({id, setGlossaries, isCattoolPage = false}) => { CatToolActions.addNotification({ title: 'Glossary deleted', type: 'success', - text: `The glossary (${glossary.name}) has been successfully deleted`, + text: ( + <> + The glossary ({glossary.name}) has been successfully + deleted + + ), position: 'br', - allowHtml: true, timer: 5000, }) CreateProjectActions.updateProjectTemplates({ @@ -92,7 +95,6 @@ export const DeepLGlossary = ({id, setGlossaries, isCattoolPage = false}) => { type: 'error', text: 'Error deleting glossary', position: 'br', - allowHtml: true, timer: 5000, }) }) diff --git a/public/js/components/settingsPanel/Contents/MachineTranslationTab/DeepLGlossary/DeepLGlossaryCreateRow.js b/public/js/components/settingsPanel/Contents/MachineTranslationTab/DeepLGlossary/DeepLGlossaryCreateRow.js index 55805e930c..f7411ab7af 100644 --- a/public/js/components/settingsPanel/Contents/MachineTranslationTab/DeepLGlossary/DeepLGlossaryCreateRow.js +++ b/public/js/components/settingsPanel/Contents/MachineTranslationTab/DeepLGlossary/DeepLGlossaryCreateRow.js @@ -68,7 +68,6 @@ export const DeepLGlossaryCreateRow = ({engineId, row, setRows}) => { type: 'error', text: !name ? 'Name mandatory' : 'File mandatory', position: 'br', - allowHtml: true, timer: 5000, }) return false @@ -89,7 +88,6 @@ export const DeepLGlossaryCreateRow = ({engineId, row, setRows}) => { type: 'success', text: 'Glossary created successfully', position: 'br', - allowHtml: true, timer: 5000, }) setIsWaitingResult(false) @@ -100,7 +98,6 @@ export const DeepLGlossaryCreateRow = ({engineId, row, setRows}) => { type: 'error', text: 'Error creating glossary', position: 'br', - allowHtml: true, timer: 5000, }) setIsWaitingResult(false) diff --git a/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossary.js b/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossary.js index f2747c3e89..e501e05c9a 100644 --- a/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossary.js +++ b/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossary.js @@ -111,9 +111,13 @@ export const MTGlossary = ({id, setGlossaries, isCattoolPage = false}) => { CatToolActions.addNotification({ title: 'Glossary deleted', type: 'success', - text: `The glossary (${glossary.name}) has been successfully deleted`, + text: ( + <> + The glossary ({glossary.name}) has been successfully + deleted + + ), position: 'br', - allowHtml: true, timer: 5000, }) CreateProjectActions.updateProjectTemplates({ @@ -130,7 +134,6 @@ export const MTGlossary = ({id, setGlossaries, isCattoolPage = false}) => { type: 'error', text: 'Error deleting glossary', position: 'br', - allowHtml: true, timer: 5000, }) }) diff --git a/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossaryCreateRow.js b/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossaryCreateRow.js index b48203bf47..be1d6d9d89 100644 --- a/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossaryCreateRow.js +++ b/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossaryCreateRow.js @@ -3,7 +3,10 @@ import PropTypes from 'prop-types' import Upload from '../../../../../../img/icons/Upload' import Checkmark from '../../../../../../img/icons/Checkmark' import Close from '../../../../../../img/icons/Close' -import {MTGlossaryStatus, MT_GLOSSARY_CREATE_ROW_ID} from './MTGlossaryConstants' +import { + MTGlossaryStatus, + MT_GLOSSARY_CREATE_ROW_ID, +} from './MTGlossaryConstants' import {createMemoryAndImportGlossary} from '../../../../../api/createMemoryAndImportGlossary/createMemoryAndImportGlossary' import LabelWithTooltip from '../../../../common/LabelWithTooltip' import CatToolActions from '../../../../../actions/CatToolActions' @@ -87,7 +90,6 @@ export const MTGlossaryCreateRow = ({engineId, row, setRows}) => { type: 'error', text: !name ? 'Name mandatory' : 'File mandatory', position: 'br', - allowHtml: true, timer: 5000, }) return false @@ -108,7 +110,6 @@ export const MTGlossaryCreateRow = ({engineId, row, setRows}) => { type: 'success', text: 'Glossary created successfully', position: 'br', - allowHtml: true, timer: 5000, }) setIsWaitingResult(false) @@ -119,7 +120,6 @@ export const MTGlossaryCreateRow = ({engineId, row, setRows}) => { type: 'error', text: 'Error creating glossary', position: 'br', - allowHtml: true, timer: 5000, }) setIsWaitingResult(false) diff --git a/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossaryRow.js b/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossaryRow.js index 0fa6418b1f..eaad5c45ff 100644 --- a/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossaryRow.js +++ b/public/js/components/settingsPanel/Contents/MachineTranslationTab/MTGlossary/MTGlossaryRow.js @@ -51,7 +51,6 @@ export const MTGlossaryRow = ({ type: 'success', text: `Glossary file ${file.name} imported successfully`, position: 'br', - allowHtml: true, timer: 5000, }) setIsWaitingResult(false) @@ -62,7 +61,6 @@ export const MTGlossaryRow = ({ type: 'error', text: `Glossary file ${file.name} import error`, position: 'br', - allowHtml: true, timer: 5000, }) setIsWaitingResult(false) diff --git a/public/js/components/settingsPanel/Contents/MachineTranslationTab/MachineTranslationTab.js b/public/js/components/settingsPanel/Contents/MachineTranslationTab/MachineTranslationTab.js index 2723f68e7b..abb1fdd0d6 100644 --- a/public/js/components/settingsPanel/Contents/MachineTranslationTab/MachineTranslationTab.js +++ b/public/js/components/settingsPanel/Contents/MachineTranslationTab/MachineTranslationTab.js @@ -190,9 +190,12 @@ export const MachineTranslationTab = () => { CatToolActions.addNotification({ title: 'MT deleted', type: 'success', - text: `The MT (${mtToDelete.name}) has been successfully deleted`, + text: ( + <> + The MT ({mtToDelete.name}) has been successfully deleted + + ), position: 'br', - allowHtml: true, timer: 5000, }) }) diff --git a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ExportGlossary.js b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ExportGlossary.js index bf8e6377c7..a91ed97182 100644 --- a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ExportGlossary.js +++ b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ExportGlossary.js @@ -25,7 +25,6 @@ export const ExportGlossary = ({row, onClose}) => { text: `You will receive the link at ${email}`, type: 'success', position: 'br', - allowHtml: true, timer: 5000, } CatToolActions.addNotification(notification) diff --git a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ExportTMX.js b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ExportTMX.js index 4a95113edf..bc2a4db1cc 100644 --- a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ExportTMX.js +++ b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ExportTMX.js @@ -26,7 +26,6 @@ export const ExportTMX = ({row, onClose}) => { text: `You will receive the link at ${email}`, type: 'success', position: 'br', - allowHtml: true, timer: 5000, } CatToolActions.addNotification(notification) diff --git a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ShareResource.js b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ShareResource.js index 10a899c131..c2b5808ec5 100644 --- a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ShareResource.js +++ b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/ShareResource.js @@ -35,7 +35,6 @@ export const ShareResource = ({row, onClose, onShare}) => { type: 'error', text: errorsObject.message, position: 'br', - allowHtml: true, timer: 5000, }) setStatus({errors: errorsObject}) @@ -50,9 +49,12 @@ export const ShareResource = ({row, onClose, onShare}) => { CatToolActions.addNotification({ title: 'Resource shared', type: 'success', - text: `The resource ${row.name} has been shared.`, + text: ( + <> + The resource {row.name} has been shared. + + ), position: 'br', - allowHtml: true, timer: 5000, }) setStatus({successfull: true}) diff --git a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TMCreateResourceRow.js b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TMCreateResourceRow.js index a3b066ad7e..87b094649f 100644 --- a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TMCreateResourceRow.js +++ b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TMCreateResourceRow.js @@ -95,7 +95,6 @@ export const TMCreateResourceRow = ({row}) => { CatToolActions.addNotification({ ...message, position: 'br', - allowHtml: true, timer: 5000, }) return @@ -169,9 +168,12 @@ export const TMCreateResourceRow = ({row}) => { CatToolActions.addNotification({ title: 'Resource created ', type: 'success', - text: `Resource ${name} created successfully`, + text: ( + <> + Resource {name} created successfully + + ), position: 'br', - allowHtml: true, timer: 5000, }) }) @@ -186,7 +188,6 @@ export const TMCreateResourceRow = ({row}) => { type: 'error', text: errMessage, position: 'br', - allowHtml: true, timer: 5000, }) }) @@ -218,7 +219,6 @@ export const TMCreateResourceRow = ({row}) => { type: 'error', text: errMessage, position: 'br', - allowHtml: true, timer: 5000, }) }) @@ -254,7 +254,6 @@ export const TMCreateResourceRow = ({row}) => { title: 'Invalid key', type: 'error', text: errMessage, - allowHtml: true, position: 'br', timer: 5000, }) @@ -295,7 +294,6 @@ export const TMCreateResourceRow = ({row}) => { title: 'Invalid key', type: 'error', text: message, - allowHtml: true, position: 'br', timer: 5000, }) diff --git a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TMKeyRow.js b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TMKeyRow.js index 348749982f..2574545b56 100644 --- a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TMKeyRow.js +++ b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TMKeyRow.js @@ -87,7 +87,6 @@ export const TMKeyRow = ({row, onExpandRow}) => { type: 'error', text: 'You can activate up to 10 resources per project.', position: 'br', - allowHtml: true, timer: 5000, }) setIsLookup(false) @@ -179,7 +178,6 @@ export const TMKeyRow = ({row, onExpandRow}) => { type: 'error', text: 'This name is already in use, please choose a different one', position: 'br', - allowHtml: true, timer: 5000, }) @@ -214,7 +212,6 @@ export const TMKeyRow = ({row, onExpandRow}) => { type: 'error', text: errMessage, position: 'br', - allowHtml: true, timer: 5000, }) setTmKeys((prevState) => @@ -238,7 +235,6 @@ export const TMKeyRow = ({row, onExpandRow}) => { type: 'error', text: 'Resource name cannot be empty. Please provide a valid name.', position: 'br', - allowHtml: true, timer: 5000, }) setTmKeys((prevState) => @@ -324,10 +320,13 @@ export const TMKeyRow = ({row, onExpandRow}) => { } const notification = { title: 'Resource deleted', - text: `The resource (${row.name}) has been successfully deleted`, + text: ( + <> + The resource ({row.name}) has been successfully deleted + + ), type: 'success', position: 'br', - allowHtml: true, timer: 5000, } CatToolActions.addNotification(notification) @@ -338,7 +337,6 @@ export const TMKeyRow = ({row, onExpandRow}) => { type: 'error', text: 'There was an error saving your data. Please retry!', position: 'br', - allowHtml: true, timer: 5000, }) onExpandRow({row, shouldExpand: false}) diff --git a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TranslationMemoryGlossaryTab.test.js b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TranslationMemoryGlossaryTab.test.js index c29d91d1f4..33236ae026 100644 --- a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TranslationMemoryGlossaryTab.test.js +++ b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/TranslationMemoryGlossaryTab.test.js @@ -571,3 +571,23 @@ test('Modal delete tmkeys used in other templates', async () => { 'The memory key you are about to delete is used in the following project creation template(s):', ) }) + +test('Key names containing markup render as plain text', async () => { + const xssName = ' R&D' + const {projectTemplates, ...rest} = contextMockValues({ + tmKeysMockArray: tmKeysMock.tm_keys + .filter(({key}) => key === '74b6c82408a028b6f020') + .map((key) => ({...key, name: xssName})), + }) + const contextValues = { + ...rest, + projectTemplates, + currentProjectTemplate: projectTemplates[0], + } + + render() + + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByDisplayValue(xssName)).toBeInTheDocument() + expect(window.__xss).toBeUndefined() +}) diff --git a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/hooks/useExport.js b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/hooks/useExport.js index 633aa59773..5d4158a4f3 100644 --- a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/hooks/useExport.js +++ b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/hooks/useExport.js @@ -40,7 +40,6 @@ function useExport({type, row, onClose}) { type: 'error', text: errorsObject.message, position: 'br', - allowHtml: true, timer: 5000, }) setStatus({errors: errorsObject}) diff --git a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/hooks/useImport.js b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/hooks/useImport.js index f1a3d458e2..534617a5d4 100644 --- a/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/hooks/useImport.js +++ b/public/js/components/settingsPanel/Contents/TranslationMemoryGlossaryTab/hooks/useImport.js @@ -73,7 +73,6 @@ function useImport({type, row, onClose}) { type: 'error', text: message, position: 'br', - allowHtml: true, timer: 5000, }) }) @@ -176,7 +175,6 @@ function useImport({type, row, onClose}) { type: 'error', text: error?.errors?.[0]?.message ?? 'Error', position: 'br', - allowHtml: true, timer: 5000, }) setStatus([{errors: error.errors}]) diff --git a/public/js/setTranslationUtil.js b/public/js/setTranslationUtil.js index 68e8b70de1..3d12497b7a 100644 --- a/public/js/setTranslationUtil.js +++ b/public/js/setTranslationUtil.js @@ -1,3 +1,4 @@ +import React from 'react' import SegmentUtils from './utils/segmentUtils' import {SEGMENTS_STATUS} from './constants/Constants' import {isUndefined} from 'lodash' @@ -272,25 +273,41 @@ const checkSegmentsPropagation = (item, propagationData) => { ) } if (!autoPropagate && propagationData.segments_for_propagation) { - let text = - 'The segment translation has been propagated to the other repetitions.' + let text = ( + <> + The segment translation has been propagated to the other repetitions. + + ) if ( propagationData.segments_for_propagation.not_propagated && propagationData.segments_for_propagation.not_propagated.ice.id && propagationData.segments_for_propagation.not_propagated.ice.id.length > 0 ) { - text = - 'The segment translation has been propagated to the other repetitions.
    Repetitions in locked segments have been excluded from the propagation.' + text = ( + <> + The segment translation has been{' '} + propagated to the other repetitions. +
    Repetitions in locked segments have been excluded from + the propagation. + + ) } else if ( propagationData.segments_for_propagation.not_propagated && propagationData.segments_for_propagation.not_propagated.not_ice.id && propagationData.segments_for_propagation.not_propagated.not_ice.id .length > 0 ) { - text = - 'The segment translation has been propagated to the other repetitions in locked segments.
    Repetitions in non-locked segments have been excluded from the' + - ' propagation.' + text = ( + <> + The segment translation has been{' '} + propagated to the other repetitions in locked segments. +
    Repetitions in + non-locked segments have been excluded + {' '} + from the propagation. + + ) } const notification = { @@ -299,7 +316,6 @@ const checkSegmentsPropagation = (item, propagationData) => { type: 'info', autoDismiss: true, timer: 5000, - allowHtml: true, position: 'bl', } CatToolActions.removeAllNotifications() diff --git a/public/js/sse/SocketListener.js b/public/js/sse/SocketListener.js index bae3851e94..e26eecd837 100644 --- a/public/js/sse/SocketListener.js +++ b/public/js/sse/SocketListener.js @@ -1,4 +1,4 @@ -import {useContext, useEffect} from 'react' +import React, {useContext, useEffect} from 'react' import useSocketLayer, {ConnectionStates} from '../hooks/useSocketLayer' import CatToolActions from '../actions/CatToolActions' import SegmentActions from '../actions/SegmentActions' @@ -25,13 +25,22 @@ const SocketListener = ({isAuthenticated, userId}) => { if (serverVersion !== config.build_number) { const notification = { title: 'New update available!', - text: - 'We’ve just released an update with improvements and bug fixes.
    ' + - 'To ensure all changes are applied correctly, we recommend refreshing the page.

    ' + - 'Click Refresh or press Ctrl+R (Windows) / Cmd+R (Mac).

    ' + - 'Thank you for using Matecat!', + text: ( + <> + We’ve just released an update with improvements and bug fixes. +
    + To ensure all changes are applied correctly, we recommend + refreshing the page. +
    +
    + Click Refresh or press Ctrl+R (Windows) /{' '} + Cmd+R (Mac). +
    +
    + Thank you for using Matecat! + + ), type: 'warning', - allowHtml: true, } CatToolActions.addNotification(notification) } diff --git a/public/js/utils/offlineUtils.js b/public/js/utils/offlineUtils.js index 36158dc177..a448388ab3 100644 --- a/public/js/utils/offlineUtils.js +++ b/public/js/utils/offlineUtils.js @@ -1,3 +1,4 @@ +import React from 'react' import { addClassToSegment, removeClassToSegment, @@ -35,7 +36,6 @@ const OfflineUtils = { type: 'warning', position: 'bl', autoDismiss: false, - allowHtml: true, timer: 7000, } addNotification(notification) @@ -97,16 +97,27 @@ const OfflineUtils = { if (this.offline) { const notification = { uid: 'offline-counter', - title: - '
    No connection available', - text: - 'You can still translate ' + - --this.offlineCacheRemaining + - ' segments in offline mode. Do not refresh or you lose the segments!', + title: ( + <> +
    + + +
    + No connection available + + ), + text: ( + <> + You can still translate{' '} + + {--this.offlineCacheRemaining} + {' '} + segments in offline mode. Do not refresh or you lose the segments! + + ), type: 'warning', position: 'bl', autoDismiss: false, - allowHtml: true, timer: 7000, } addNotification(notification) diff --git a/public/js/utils/textUtils.js b/public/js/utils/textUtils.js index d7f6b86a2b..fe42dc92c5 100644 --- a/public/js/utils/textUtils.js +++ b/public/js/utils/textUtils.js @@ -1,4 +1,5 @@ import {isUndefined} from 'lodash' +import DOMPurify from 'dompurify' import $ from 'jquery' import {regexWordDelimiter} from '../components/segments/utils/DraftMatecatUtils/textConstants' import CommonUtils from './commonUtils' @@ -7,6 +8,15 @@ import {tagSignatures} from '../components/segments/utils/DraftMatecatUtils/tagM const TEXT_UTILS = { diffMatchPatch: new diff_match_patch(), + /** + * The only sanctioned way to feed a string into dangerouslySetInnerHTML: + * the markup is sanitized with DOMPurify before being injected. + */ + sanitizedHTML: (html) => ({ + // contenteditable="false" is part of the tag-pill markup emitted by + // transformTagsToHtml and is safe to keep + __html: DOMPurify.sanitize(html, {ADD_ATTR: ['contenteditable']}), + }), getDiffHtml: function (source, target) { let dmp = new diff_match_patch() /* diff --git a/public/js/utils/textUtilsSanitizedHTML.test.js b/public/js/utils/textUtilsSanitizedHTML.test.js new file mode 100644 index 0000000000..e4b68723c7 --- /dev/null +++ b/public/js/utils/textUtilsSanitizedHTML.test.js @@ -0,0 +1,26 @@ +import TEXT_UTILS from './textUtils' + +describe('sanitizedHTML', () => { + test('returns the __html shape for dangerouslySetInnerHTML', () => { + expect(TEXT_UTILS.sanitizedHTML('hi')).toEqual({ + __html: 'hi', + }) + }) + + test('strips script tags and event handlers', () => { + const {__html} = TEXT_UTILS.sanitizedHTML( + 'ok', + ) + expect(__html).not.toContain('onerror') + expect(__html).not.toContain('ok') + }) + + test('preserves the tag-pill markup emitted by transformTagsToHtml', () => { + const pill = + '<br/>' + const {__html} = TEXT_UTILS.sanitizedHTML(pill) + expect(__html).toContain('class="tag small"') + expect(__html).toContain('contenteditable="false"') + }) +}) diff --git a/tests/unit/Core/Controllers/TmKeyManagementAppControllerTest.php b/tests/unit/Core/Controllers/TmKeyManagementAppControllerTest.php index 4e20c69108..e318d5c053 100644 --- a/tests/unit/Core/Controllers/TmKeyManagementAppControllerTest.php +++ b/tests/unit/Core/Controllers/TmKeyManagementAppControllerTest.php @@ -245,15 +245,17 @@ public function sortKeysInTheRightOrder_skips_user_key_with_null_key_value(): vo * @throws \Throwable */ #[Test] - public function sortKeysInTheRightOrder_html_decodes_matched_key_name(): void + public function sortKeysInTheRightOrder_keeps_matched_key_name_untouched(): void { + // names are stored raw, so the read path must not decode (or otherwise + // rewrite) them: a literal "&" typed by the user stays "&" $key = new ClientTmKeyStruct(['key' => 'ddddddddddd44444', 'name' => 'Foo & Bar']); $jobKeyList = [['key' => 'ddddddddddd44444']]; $result = $this->invokePrivate('sortKeysInTheRightOrder', [[$key], $jobKeyList]); $this->assertCount(1, $result); - $this->assertSame('Foo & Bar', $result[0]->name); + $this->assertSame('Foo & Bar', $result[0]->name); } // ─── getByJob (public action) ─── diff --git a/tests/unit/Core/Controllers/UserKeysControllerTest.php b/tests/unit/Core/Controllers/UserKeysControllerTest.php index 407fa8d896..185055888c 100644 --- a/tests/unit/Core/Controllers/UserKeysControllerTest.php +++ b/tests/unit/Core/Controllers/UserKeysControllerTest.php @@ -354,67 +354,104 @@ public function validateTheRequest_throws_minus_two_when_key_missing(): void } /** + * Descriptions are stored raw: HTML-escaping happens at each output sink, + * never at the input boundary. + * * @throws Throwable */ #[Test] - public function validateTheRequest_throws_minus_three_on_xss_description(): void + #[DataProvider('rawDescriptionProvider')] + public function validateTheRequest_stores_description_byte_identical(string $description): void { $this->setRequestParams([ 'key' => 'abcdef1234567890', - 'description' => '', + 'description' => $description, ]); - try { - $this->invokePrivate('validateTheRequest'); - $this->fail('Expected InvalidArgumentException was not thrown'); - } catch (InvalidArgumentException $e) { - $this->assertSame(-3, $e->getCode()); - $this->assertStringContainsString('<', $e->getMessage()); - $this->assertStringContainsString('>', $e->getMessage()); - $this->assertStringContainsString('&', $e->getMessage()); - $this->assertStringContainsString('"', $e->getMessage()); - $this->assertStringContainsString(''', $e->getMessage()); - $this->assertStringNotContainsString( - 'gist.github.com', - $e->getMessage(), - 'the gist link about non-printable characters was intentionally dropped from the message' - ); - } + $result = $this->invokePrivate('validateTheRequest'); + + $this->assertSame($description, $result['description']); + } + + /** + * @return array + */ + public static function rawDescriptionProvider(): array + { + return [ + 'script tag' => [''], + 'ampersand' => ['R&D — Client (2024)'], + 'double-quote' => ['The "official" glossary'], + 'single-quote' => ["L'été"], + 'accents' => ['Memoria è rotta'], + 'emoji' => ['Fruit glossary 🍎'], + ]; } /** * @throws Throwable */ #[Test] - #[DataProvider('forbiddenDescriptionCharacterProvider')] - public function validateTheRequest_throws_minus_three_for_each_forbidden_character(string $char): void + public function validateTheRequest_never_stores_html_entities(): void { $this->setRequestParams([ 'key' => 'abcdef1234567890', - 'description' => "Glossary {$char} name", + 'description' => 'R&D "quoted"', ]); - try { - $this->invokePrivate('validateTheRequest'); - $this->fail('Expected InvalidArgumentException was not thrown'); - } catch (InvalidArgumentException $e) { - $this->assertSame(-3, $e->getCode()); - $this->assertStringContainsString('Resource names cannot contain', $e->getMessage()); - } + $result = $this->invokePrivate('validateTheRequest'); + + $this->assertDoesNotMatchRegularExpression('/&(lt|gt|amp|quot|#0?39);/', $result['description']); } /** - * @return array + * @throws Throwable */ - public static function forbiddenDescriptionCharacterProvider(): array + #[Test] + public function validateTheRequest_throws_minus_three_on_invalid_utf8_description(): void { - return [ - 'less-than' => ['<'], - 'greater-than' => ['>'], - 'ampersand' => ['&'], - 'double-quote' => ['"'], - 'single-quote' => ["'"], - ]; + $this->setRequestParams([ + 'key' => 'abcdef1234567890', + 'description' => "Memoria \xC3 rotta", + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(-3); + + $this->invokePrivate('validateTheRequest'); + } + + /** + * @throws Throwable + */ + #[Test] + public function validateTheRequest_throws_minus_three_on_array_description(): void + { + $this->setRequestParams([ + 'key' => 'abcdef1234567890', + 'description' => ['x'], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionCode(-3); + + $this->invokePrivate('validateTheRequest'); + } + + /** + * @throws Throwable + */ + #[Test] + public function validateTheRequest_strips_control_and_invisible_characters(): void + { + $this->setRequestParams([ + 'key' => 'abcdef1234567890', + 'description' => "a\x00b\x1bc\nd\u{200B}\u{202E}e", + ]); + + $result = $this->invokePrivate('validateTheRequest'); + + $this->assertSame('abcde', $result['description']); } // ─── getMkDao ─── diff --git a/tests/unit/Core/TmKeyManagement/TmKeyManagerTest.php b/tests/unit/Core/TmKeyManagement/TmKeyManagerTest.php index 644148673f..4cf2f6546c 100644 --- a/tests/unit/Core/TmKeyManagement/TmKeyManagerTest.php +++ b/tests/unit/Core/TmKeyManagement/TmKeyManagerTest.php @@ -2,10 +2,12 @@ namespace Matecat\Core\TmKeyManagement; +use InvalidArgumentException; use Matecat\TestHelpers\AbstractTest; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use Utils\TmKeyManagement\TmKeyManager; use Utils\TmKeyManagement\TmKeyStruct; -use PHPUnit\Framework\Attributes\Test; class TmKeyManagerTest extends AbstractTest { @@ -14,26 +16,132 @@ public function testSanitizePreservesCurlyBraces() { $obj = new TmKeyStruct(); $obj->name = 'New resource created for project {{pid}}'; - + TmKeyManager::sanitize($obj); - + $this->assertEquals('New resource created for project {{pid}}', $obj->name); } + /** + * Names are stored raw: escaping for HTML/XML/email is the output layer's + * job, so sanitize() must keep every printable character byte-identical. + * + * @return array + */ + public static function rawNameProvider(): array + { + return [ + 'script tag' => ['Resource with and {{pid}}'], + 'ampersand' => ['R&D — Client (2024)'], + 'double quote' => ['The "official" glossary'], + 'single quote' => ["L'été"], + 'accents' => ['Memoria è rotta'], + 'emoji' => ['Fruit glossary 🍎'], + 'emoji zwj' => ["Family \u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F466} glossary"], + ]; + } + + /** + * @throws InvalidArgumentException + */ + #[Test] + #[DataProvider('rawNameProvider')] + public function testSanitizeStoresNamesByteIdentical(string $name) + { + $obj = new TmKeyStruct(); + $obj->name = $name; + + TmKeyManager::sanitize($obj); + + $this->assertSame($name, $obj->name); + } + + /** + * Guards the raw-storage invariant: no HTML entity may ever be produced + * by the input layer. + * + * @throws InvalidArgumentException + */ #[Test] - public function testSanitizeRemovesOtherSpecialChars() + public function testSanitizeNeverProducesHtmlEntities() { $obj = new TmKeyStruct(); - $obj->name = 'Resource with and {{pid}}'; - + $obj->name = 'R&D "quoted" \'single\''; + TmKeyManager::sanitize($obj); - - // < and > are removed or encoded by filter_var depending on implementation, - // but preg_replace should strip them if they are not in the allowed list. - // In the updated code: [^.\-_\p{L}\p{N}\s{}]+ - // <, >, (, ) are NOT in the allowed list. - - $this->assertStringContainsString('{{pid}}', $obj->name); - $this->assertStringNotContainsString('