From 7775257019379364d2c9a04e5bb07627cb0dd4b7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sat, 4 May 2024 13:44:00 +0200 Subject: [PATCH] feat: Allow setting `escape` option per parameter replacing Allows to use HTML inside the parameters like the following example. This will still escape the user input but keep the HTML tags for `a` and `end_a`. ```js t( 'app', 'Click: {a}{userInput}{end_a}', { a: { value: '', escape: false, }, userInput, end_a: { value: '', escape: false, }, }, ) ``` Signed-off-by: Ferdinand Thiessen --- lib/translation.ts | 32 +++++++++++++++++++++++++----- tests/translation.test.ts | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/lib/translation.ts b/lib/translation.ts index 1a2d8e12..f8480eb9 100644 --- a/lib/translation.ts +++ b/lib/translation.ts @@ -19,6 +19,17 @@ interface TranslationOptions { sanitize?: boolean } +/** @notExported */ +interface TranslationVariableReplacementObject { + /** The value to use for the replacement */ + value: T + /** Overwrite the `escape` option just for this replacement */ + escape: boolean +} + +/** @notExported */ +type TranslationVariables = Record> + /** * Translate a string * @@ -27,37 +38,48 @@ interface TranslationOptions { * @param {object} vars map of placeholder key to value * @param {number} number to replace %n with * @param {object} [options] options object + * @param {boolean} options.escape enable/disable auto escape of placeholders (by default enabled) + * @param {boolean} options.sanitize enable/disable sanitization (by default enabled) + * * @return {string} */ export function translate( app: string, text: string, - vars?: Record, + vars?: TranslationVariables, number?: number, options?: TranslationOptions, ): string { - const defaultOptions = { + const allOptions = { + // defaults escape: true, sanitize: true, + // overwrite with user config + ...(options || {}), } - const allOptions = Object.assign({}, defaultOptions, options || {}) const identity = (value: T): T => value const optSanitize = allOptions.sanitize ? DOMPurify.sanitize : identity const optEscape = allOptions.escape ? escapeHTML : identity + const isValidReplacement = (value: unknown) => typeof value === 'string' || typeof value === 'number' + // TODO: cache this function to avoid inline recreation // of the same function over and over again in case // translate() is used in a loop - const _build = (text: string, vars?: Record, number?: number) => { + const _build = (text: string, vars?: TranslationVariables, number?: number) => { return text.replace(/%n/g, '' + number).replace(/{([^{}]*)}/g, (match, key) => { if (vars === undefined || !(key in vars)) { return optEscape(match) } const replacement = vars[key] - if (typeof replacement === 'string' || typeof replacement === 'number') { + if (isValidReplacement(replacement)) { return optEscape(`${replacement}`) + } else if (typeof replacement === 'object' && isValidReplacement(replacement.value)) { + // Replacement is an object so indiviual escape handling + const escape = replacement.escape !== false ? escapeHTML : identity + return escape(`${replacement.value}`) } else { /* This should not happen, * but the variables are used defined so not allowed types could still be given, diff --git a/tests/translation.test.ts b/tests/translation.test.ts index 3a7ef6f9..ead59f8c 100644 --- a/tests/translation.test.ts +++ b/tests/translation.test.ts @@ -57,6 +57,47 @@ describe('translate', () => { expect(translation).toBe('Hallo <del>Name</del>') }) + it('with global placeholder HTML escaping and enabled on parameter', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: { value: 'Name', escape: true } }, undefined, { escape: true }) + expect(translation).toBe('Hallo <del>Name</del>') + }) + + it('with global placeholder HTML escaping but disabled on parameter', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: { value: 'Name', escape: false } }, undefined, { escape: true }) + expect(translation).toBe('Hallo Name') + }) + + it('without global placeholder HTML escaping but enabled on parameter', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: { value: 'Name', escape: true } }, undefined, { escape: false }) + expect(translation).toBe('Hallo <del>Name</del>') + }) + + it('without global placeholder HTML escaping and disabled on parameter', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: { value: 'Name', escape: false } }, undefined, { escape: false }) + expect(translation).toBe('Hallo Name') + }) + + it('with global placeholder HTML escaping and invalid per-parameter escaping', () => { + const text = 'Hello {name}' + // @ts-expect-error We test calling it with an invalid value (missing) + const translation = translate('core', text, { name: { value: 'Name' } }, undefined, { escape: true }) + // `escape` needs to be an boolean, otherwise we fallback to `false` to prevent security issues + // So in this case `undefined` is falsy but we still enforce escaping as we only accept `false` + expect(translation).toBe('Hallo <del>Name</del>') + }) + + it('witout global placeholder HTML escaping and invalid per-parameter escaping', () => { + const text = 'Hello {name}' + // @ts-expect-error We test calling it with an invalid value + const translation = translate('core', text, { name: { value: 'Name', escape: 0 } }, undefined, { escape: false }) + // `escape` needs to be an boolean, otherwise we fallback to `false` to prevent security issues + expect(translation).toBe('Hallo <del>Name</del>') + }) + it('without placeholder XSS sanitizing', () => { const text = 'Hello {name}' const translation = translate('core', text, { name: '' }, undefined, { sanitize: false, escape: false })