diff --git a/decls/i18n.js b/decls/i18n.js index 16fa8e4de..78b918e2e 100644 --- a/decls/i18n.js +++ b/decls/i18n.js @@ -92,6 +92,7 @@ declare type I18nOptions = { sharedMessages?: LocaleMessage, postTranslation?: PostTranslationHandler, componentInstanceCreatedListener?: ComponentInstanceCreatedListener, + escapeParameterHtml?: boolean, }; declare type IntlAvailability = { diff --git a/examples/formatting/escape-parameter-html/index.html b/examples/formatting/escape-parameter-html/index.html new file mode 100644 index 000000000..a3642646c --- /dev/null +++ b/examples/formatting/escape-parameter-html/index.html @@ -0,0 +1,34 @@ + + + + + Escape parameter HTML example + + + + +
+

+
+ + + \ No newline at end of file diff --git a/src/index.js b/src/index.js index c8c878c54..8a3b1dd98 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,8 @@ import { remove, includes, merge, - numberFormatKeys + numberFormatKeys, + escapeParams } from './util' import BaseFormatter from './format' import I18nPath from './path' @@ -59,6 +60,7 @@ export default class VueI18n { _componentInstanceCreatedListener: ?ComponentInstanceCreatedListener _preserveDirectiveContent: boolean _warnHtmlInMessage: WarnHtmlInMessageLevel + _escapeParameterHtml: boolean _postTranslation: ?PostTranslationHandler pluralizationRules: { [lang: string]: (choice: number, choicesLength: number) => number @@ -111,6 +113,7 @@ export default class VueI18n { this.pluralizationRules = options.pluralizationRules || {} this._warnHtmlInMessage = options.warnHtmlInMessage || 'off' this._postTranslation = options.postTranslation || null + this._escapeParameterHtml = options.escapeParameterHtml || false /** * @param choice {number} a choice index given by the input to $tc: `$tc('path.to.rule', choiceIndex)` @@ -650,6 +653,10 @@ export default class VueI18n { if (!key) { return '' } const parsedArgs = parseArgs(...values) + if(this._escapeParameterHtml) { + parsedArgs.params = escapeParams(parsedArgs.params) + } + const locale: Locale = parsedArgs.locale || _locale let ret: any = this._translate( diff --git a/src/util.js b/src/util.js index e5106d406..dbcdecd26 100644 --- a/src/util.js +++ b/src/util.js @@ -167,3 +167,36 @@ export function looseEqual (a: any, b: any): boolean { return false } } + +/** + * Sanitizes html special characters from input strings. For mitigating risk of XSS attacks. + * @param rawText The raw input from the user that should be escaped. + */ +function escapeHtml(rawText: string): string { + return rawText + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * Escapes html tags and special symbols from all provided params which were returned from parseArgs().params. + * This method performs an in-place operation on the params object. + * + * @param {any} params Parameters as provided from `parseArgs().params`. + * May be either an array of strings or a string->any map. + * + * @returns The manipulated `params` object. + */ +export function escapeParams(params: any): any { + if(params != null) { + Object.keys(params).forEach(key => { + if(typeof(params[key]) == 'string') { + params[key] = escapeHtml(params[key]) + } + }) + } + return params +} \ No newline at end of file diff --git a/test/unit/escape_parameter_html.test.js b/test/unit/escape_parameter_html.test.js new file mode 100644 index 000000000..8a07b5571 --- /dev/null +++ b/test/unit/escape_parameter_html.test.js @@ -0,0 +1,29 @@ +const messages = { + en: { + listformat: '{0}', + nameformat: '{key}', + } +} + +describe('escapeParameterHtml', () => { + it('Replacement parameters are escaped when escapeParameterHtml: true.', () => { + const i18n = new VueI18n({ + locale: 'en', + messages, + escapeParameterHtml: true + }) + assert(i18n.t('nameformat', { key: '<&"\'>' }) === '<&"'>') + assert(i18n.t('listformat', ['<&"\'>']) === '<&"'>') + assert(i18n.tc('nameformat', 1, { key: '<&"\'>' }).toString() === '<&"'>') + assert(i18n.tc('listformat', 1, ['<&"\'>']).toString() === '<&"'>') + }) + it('Replacement parameters are not escaped when escapeParameterHtml: undefined.', () => { + const i18n = new VueI18n({ + locale: 'en', + messages, + }) + assert(i18n.t('nameformat', { key: '<&"\'>' }) === '<&"\'>') + assert(i18n.t('listformat', ['<&"\'>']) === '<&"\'>') + + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 7f7327ec6..43619c5f2 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -120,6 +120,7 @@ declare namespace VueI18n { sharedMessages?: LocaleMessages; postTranslation?: PostTranslationHandler; componentInstanceCreatedListener?: ComponentInstanceCreatedListener; + escapeParameterHtml?: boolean; } } diff --git a/vuepress/api/README.md b/vuepress/api/README.md index 36c61bf03..805662dd6 100644 --- a/vuepress/api/README.md +++ b/vuepress/api/README.md @@ -373,6 +373,23 @@ A handler for getting notified when component-local instance was created. The ha This handler is useful when extending the root VueI18n instance and wanting to also apply those extensions to component-local instance. +#### espaceParameterHtml + +> 8.22+ + + * **Type:** `Boolean` + + * **Default:** `false` + +If `escapeParameterHtml` is configured as true then interpolation parameters are escaped before the message is translated. +This is useful when translation output is used in `v-html` and the translation resource contains html markup (e.g. `` +around a user provided value). This usage pattern mostly occurs when passing precomputed text strings into UI compontents. + +The escape process involves replacing the following symbols with their respective HTML character entities: `<`, `>`, `"`, `'`, `&`. + +Setting `escapeParameterHtml` as true should not break existing functionality but provides a safeguard against a subtle +type of XSS attack vectors. + ### Properties #### locale