Skip to content

Commit

Permalink
Escape interpolation parameteres used in run-time translations.
Browse files Browse the repository at this point in the history
This PR uses `Handlebars.Utils.escapeExpression` to ensure that any
interpolation parameter used in a run-time translation is properly
sanitized.

TEST=auto, manual

Updated unit tests and verified santitization manually.
  • Loading branch information
tmeyer2115 committed Apr 2, 2021
1 parent 2ead4d1 commit a762490
Show file tree
Hide file tree
Showing 6 changed files with 69 additions and 21 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "answers",
"version": "1.0.0",
"version": "1.6.4",
"description": "Javascript Answers Programming Interface",
"main": "gulpfile.js",
"dependencies": {
Expand Down
9 changes: 8 additions & 1 deletion src/answers-umd.js
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,14 @@ class Answers {
const initLocale = this._getInitLocale();
language = language || initLocale.substring(0, 2);

return TranslationProcessor.process(translations, interpolationParams, count, language);
if (!this.renderer) {
console.error('The renderer must be initialized before translations can be processed');
return '';
}

const escapeExpression = this.renderer.escapeExpression.bind(this.renderer);

return TranslationProcessor.process(translations, interpolationParams, count, language, escapeExpression);
}

/**
Expand Down
14 changes: 10 additions & 4 deletions src/core/i18n/translationprocessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ export default class TranslationProcessor {
* @param {Object} interpolationParams Params to use during interpolation
* @param {number} count The count associated with the pluralization
* @param {string} language The langauge associated with the pluralization
* @param {string} escapeExpression A function which escapes HTML in the passed string
* @returns {string} The translation with any interpolation or pluralization applied
*/
static process (translations, interpolationParams, count, language) {
static process (translations, interpolationParams, count, language, escapeExpression) {
const stringToInterpolate = (typeof translations === 'string')
? translations
: this._selectPluralForm(translations, count, language);

return this._interpolate(stringToInterpolate, interpolationParams);
return this._interpolate(stringToInterpolate, interpolationParams, escapeExpression);
}

/**
Expand Down Expand Up @@ -44,11 +45,16 @@ export default class TranslationProcessor {
return Array.from((new Array(numberOfPluralForms)).keys());
}

static _interpolate (stringToInterpolate, interpolationParams) {
static _interpolate (stringToInterpolate, interpolationParams, escapeExpression) {
if (interpolationParams && !escapeExpression) {
throw new Error('An escapeExpression function must be provided when processing translations with interpolation');
}

const interpolationRegex = /\[\[([a-zA-Z0-9]+)\]\]/g;

return stringToInterpolate.replace(interpolationRegex, (match, interpolationKey) => {
return interpolationParams[interpolationKey];
const interpolation = interpolationParams[interpolationKey];
return escapeExpression(interpolation);
});
}
}
4 changes: 2 additions & 2 deletions src/ui/rendering/handlebarsrenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,8 @@ export default class HandlebarsRenderer extends Renderer {
const language = locale.substring(0, 2);

return isUsingPluralization
? TranslationProcessor.process(pluralizationInfo, interpolationParams, count, language)
: TranslationProcessor.process(phrase, interpolationParams);
? TranslationProcessor.process(pluralizationInfo, interpolationParams, count, language, self.escapeExpression.bind(self))
: TranslationProcessor.process(phrase, interpolationParams, null, null, self.escapeExpression.bind(self));
});

self.registerHelper('icon', function (name, complexContentsParams, options) {
Expand Down
59 changes: 47 additions & 12 deletions tests/core/i18n/translationprocessor.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,59 @@
import TranslationProcessor from '../../../src/core/i18n/translationprocessor';
import HandleBars from 'handlebars';

const escapeExpression = HandleBars.Utils.escapeExpression;

describe('processTranslation usage', () => {
it('simple interpolation', () => {
const translation = TranslationProcessor.process('Mail maintenant [[id1]]', { id1: 'Connor' });
const translation = TranslationProcessor.process('Mail maintenant [[id1]]', { id1: 'Connor' }, null, null, escapeExpression);
expect(translation).toEqual('Mail maintenant Connor');
});

it('interpolation with multiple interpolation values', () => {
const translation = TranslationProcessor.process('Mail maintenant [[id1]] et [[id2]]', { id1: 'Connor', id2: 'Oliver' });
const translation = TranslationProcessor.process('Mail maintenant [[id1]] et [[id2]]', { id1: 'Connor', id2: 'Oliver' }, null, null, escapeExpression);
expect(translation).toEqual('Mail maintenant Connor et Oliver');
});

it('simple pluralization with singular count', () => {
const translation = TranslationProcessor.process({ 0: 'fleur', 1: 'fleurs', locale: 'fr-FR' }, {}, 1);
const translation = TranslationProcessor.process({ 0: 'fleur', 1: 'fleurs', locale: 'fr-FR' }, {}, 1, null, escapeExpression);
expect(translation).toEqual('fleur');
});

it('simple pluralization with plural count', () => {
const translation = TranslationProcessor.process({ 0: 'fleur', 1: 'fleurs', locale: 'fr-FR' }, {}, 2);
const translation = TranslationProcessor.process({ 0: 'fleur', 1: 'fleurs', locale: 'fr-FR' }, {}, 2, null, escapeExpression);
expect(translation).toEqual('fleurs');
});

it('pluralization with singular count and interpolation', () => {
const translation = TranslationProcessor.process({ 0: 'Un article [[name]]', 1: 'Les articles [[name]]', locale: 'fr-FR' }, { name: 'de presse' }, 1);
const translation = TranslationProcessor.process(
{ 0: 'Un article [[name]]', 1: 'Les articles [[name]]', locale: 'fr-FR' },
{ name: 'de presse' },
1,
null,
escapeExpression
);
expect(translation).toEqual('Un article de presse');
});

it('pluralization with plural count and interpolation', () => {
const translation = TranslationProcessor.process({ 0: 'Un article [[name]]', 1: 'Les articles [[name]]', locale: 'fr-FR' }, { name: 'de presse' }, 2);
const translation = TranslationProcessor.process(
{ 0: 'Un article [[name]]', 1: 'Les articles [[name]]', locale: 'fr-FR' },
{ name: 'de presse' },
2,
null,
escapeExpression
);
expect(translation).toEqual('Les articles de presse');
});

it('intermixed markdown with interpolation', () => {
const translation = TranslationProcessor.process('<a href="https://www.yext.com">Voir notre site web [[name]]</a>', { name: 'Howard' });
const translation = TranslationProcessor.process(
'<a href="https://www.yext.com">Voir notre site web [[name]]</a>',
{ name: 'Howard' },
null,
null,
escapeExpression
);
expect(translation).toEqual('<a href="https://www.yext.com">Voir notre site web Howard</a>');
});

Expand All @@ -41,9 +62,23 @@ describe('processTranslation usage', () => {
const translation = TranslationProcessor.process(
{ 0: '<b>Voir notre site web [[name]]</b>', 1: '<b>Voir nos sites web [[name]]</b>', locale: 'fr-FR' },
{ name: 'Howard' },
count);
count,
null,
escapeExpression
);
expect(translation).toEqual('<b>Voir nos sites web Howard</b>');
});

it('escapes html inside interpolation params', () => {
const translation = TranslationProcessor.process(
'The book named [[name]]',
{ name: '<span>Essentialism</span>' },
null,
null,
escapeExpression
);
expect(translation).toEqual('The book named &lt;span&gt;Essentialism&lt;/span&gt;');
});
});

describe('selecting the correct plural form', () => {
Expand All @@ -54,22 +89,22 @@ describe('selecting the correct plural form', () => {
};

it('uses key_0 when count = 1', () => {
const translation = TranslationProcessor.process(translations, { count: 1 }, 1, 'lt-LT');
const translation = TranslationProcessor.process(translations, { count: 1 }, 1, 'lt-LT', escapeExpression);
expect(translation).toEqual('Pasirinkta 1 tinklalapis');
});

it('uses key_1 when count = 2', () => {
const translation = TranslationProcessor.process(translations, { count: 2 }, 2, 'lt-LT');
const translation = TranslationProcessor.process(translations, { count: 2 }, 2, 'lt-LT', escapeExpression);
expect(translation).toEqual('Pasirinkta 2 tinklalapiai');
});

it('uses key_2 when count = 0', () => {
const translation = TranslationProcessor.process(translations, { count: 0 }, 0, 'lt-LT');
const translation = TranslationProcessor.process(translations, { count: 0 }, 0, 'lt-LT', escapeExpression);
expect(translation).toEqual('Pasirinkta 0 tinklalapių');
});

it('defaults locale to en when given a bogus locale', () => {
const translation = TranslationProcessor.process(translations, { count: 100 }, 100, 'hawaii');
const translation = TranslationProcessor.process(translations, { count: 100 }, 100, 'hawaii', escapeExpression);
expect(translation).toEqual('Pasirinkta 100 tinklalapiai');
});
});

0 comments on commit a762490

Please sign in to comment.