From 1947db0720477e15ee4d4512d175d368ba8b57d9 Mon Sep 17 00:00:00 2001 From: Robert Tran Date: Wed, 8 Mar 2023 15:28:49 +0100 Subject: [PATCH] fix(eslint): check for improper sanitize function usage --- .eslintrc.cjs | 1 + docs/translations.example.js | 7 +- .../i18n-always-template-literal-sanitize.js | 67 +++++++++++++++++++ eslint-rules/i18n-shared.js | 25 +++++++ src/translations/translations.en.js | 2 +- src/translations/translations.fr.js | 2 +- 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 eslint-rules/i18n-always-template-literal-sanitize.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 382e104da..a11890ea5 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -15,6 +15,7 @@ module.exports = { // custom rules 'i18n-always-arrow-with-sanitize': ['error'], 'i18n-always-sanitize-with-html': ['error'], + 'i18n-always-template-literal-sanitize': ['error'], 'i18n-no-paramless-arrow': ['error'], 'i18n-no-sanitize-without-html': ['error'], 'i18n-order': ['error'], diff --git a/docs/translations.example.js b/docs/translations.example.js index 2420739e7..fcf693d65 100644 --- a/docs/translations.example.js +++ b/docs/translations.example.js @@ -102,7 +102,7 @@ export const translations = { return sanitize`good ${foo}`; }, - // The sanitize tag function is must always be used when the translation contains HTML! + // The sanitize tag function must always be used when the translation contains HTML! // ESlint "translations-always-sanitize-with-html" 'cc-bad.html-no-sanitize': () => sanitize`bad`, 'cc-bad.html-no-sanitize-params': ({ foo }) => sanitize`bad ${foo}`, @@ -125,4 +125,9 @@ export const translations = { // The sanitize tag function must always be used in an arrow function! // Enforced by ESlint rule "i18n-always-arrow-with-sanitize" 'cc-bad.sanitize-without-arrow': () => sanitize`bad`, + + // The sanitize tag function must also be used as a template literal and not + // a regular function that returns a template literal (for security reasons). + // Enforced by ESlint rule "i18n-always-template-literal-sanitize" + 'cc-bad.sanitize-without-template-literal': () => sanitize(`bad`), }; diff --git a/eslint-rules/i18n-always-template-literal-sanitize.js b/eslint-rules/i18n-always-template-literal-sanitize.js new file mode 100644 index 000000000..c7ca53164 --- /dev/null +++ b/eslint-rules/i18n-always-template-literal-sanitize.js @@ -0,0 +1,67 @@ +/** + * Rule to enforce using `sanitize` function as a template literal and not as a regular function. + * Check the `docs/translations.example.js` file for more details. + * + * Note: this rule only applies in translation files. + * + * Limitations: the automatic fix script only affects `sanitize()` call with a single parameter, + * which must be a template literal (`TemplateLiteral` AST type). + */ + +'use strict'; + +const { + getClosestParentFromType, + isTranslationFile, +} = require('./i18n-shared.js'); + +function report (context, key, callExpressionNode) { + context.report({ + node: callExpressionNode, + messageId: 'sanitizeAlwaysTemplateLiteral', + data: { key }, + fix: (fixer) => { + if (callExpressionNode.arguments?.length !== 1) { + return; + } + + const argument = callExpressionNode.arguments[0]; + if (argument.type === 'TemplateLiteral') { + const contents = context.getSourceCode().text.substring(argument.start, argument.end); + return fixer.replaceText(callExpressionNode, `sanitize${contents}`); + } + }, + }); +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'enforce template literal when using the sanitize function', + category: 'Translation files', + }, + fixable: 'code', + messages: { + sanitizeAlwaysTemplateLiteral: 'Missing template literal usage with sanitize function: {{key}}', + }, + }, + create: function (context) { + + // Early return for non translation files + if (!isTranslationFile(context)) { + return {}; + } + + return { + CallExpression (node) { + if (node.callee.name === 'sanitize') { + const parentProperty = getClosestParentFromType(node, 'Property'); + if (parentProperty != null) { + report(context, parentProperty.key.value, node); + } + } + }, + }; + }, +}; diff --git a/eslint-rules/i18n-shared.js b/eslint-rules/i18n-shared.js index 6cbefc9d2..7b741750c 100644 --- a/eslint-rules/i18n-shared.js +++ b/eslint-rules/i18n-shared.js @@ -21,6 +21,30 @@ function isLanguageTranslation (node) { && (node.name === 'LANGUAGE'); } +/** + * Returns the closest parent from a node that matches the specified type. + * If no type is specified, returns the direct parent. + * If no node is found, returns the root node (matching the 'Program' type). + * @param node + * @param {String|null} type + * @returns {*|null} + */ +function getClosestParentFromType (node, type) { + if (node == null) { + return null; + } + if (type == null) { + return node.parent; + } + + let directParent = node; + do { + directParent = directParent.parent; + } while (directParent.parent?.type !== type && directParent.type !== 'Program'); + + return directParent.parent; +} + function getTranslationProperties (node) { return node.declaration.declarations[0].init.properties .filter((node) => !isLanguageTranslation(node.key)); @@ -47,6 +71,7 @@ function isSanitizeTagFunction (node) { module.exports = { isTranslationFile, isMainTranslationNode, + getClosestParentFromType, getTranslationProperties, parseTemplate, isSanitizeTagFunction, diff --git a/src/translations/translations.en.js b/src/translations/translations.en.js index 8167ab7da..9d6a8a0a7 100644 --- a/src/translations/translations.en.js +++ b/src/translations/translations.en.js @@ -206,7 +206,7 @@ export const translations = { 'cc-datetime-relative.title': ({ date }) => formatDate(lang, date), //#endregion //#region cc-doc-card - 'cc-doc-card.link': ({ link, product }) => sanitize(`Read the documentation`), + 'cc-doc-card.link': ({ link, product }) => sanitize`Read the documentation`, 'cc-doc-card.skeleton-link-title': `Read the documentation`, //#endregion //#region cc-doc-list diff --git a/src/translations/translations.fr.js b/src/translations/translations.fr.js index 6db21dd02..43a5c4524 100644 --- a/src/translations/translations.fr.js +++ b/src/translations/translations.fr.js @@ -219,7 +219,7 @@ export const translations = { 'cc-datetime-relative.title': ({ date }) => formatDate(lang, date), //#endregion //#region cc-doc-card - 'cc-doc-card.link': ({ link, product }) => sanitize(`Lire la documentation`), + 'cc-doc-card.link': ({ link, product }) => sanitize`Lire la documentation`, 'cc-doc-card.skeleton-link-title': `Lire la documentation`, //#endregion //#region cc-doc-list