Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow setting escape option per parameter replacing #756

Merged
merged 1 commit into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions lib/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ interface TranslationOptions {
sanitize?: boolean
}

/** @notExported */
interface TranslationVariableReplacementObject<T> {
/** The value to use for the replacement */
value: T
/** Overwrite the `escape` option just for this replacement */
escape: boolean
}

/** @notExported */
type TranslationVariables = Record<string, string | number | TranslationVariableReplacementObject<string | number>>

/**
* Translate a string
*
Expand All @@ -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<string, string | number>,
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 = <T, >(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<string, string | number>, 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,
Expand Down
41 changes: 41 additions & 0 deletions tests/translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,47 @@ describe('translate', () => {
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('with global placeholder HTML escaping and enabled on parameter', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: true } }, undefined, { escape: true })
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('with global placeholder HTML escaping but disabled on parameter', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: false } }, undefined, { escape: true })
expect(translation).toBe('Hallo <del>Name</del>')
})

it('without global placeholder HTML escaping but enabled on parameter', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: true } }, undefined, { escape: false })
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('without global placeholder HTML escaping and disabled on parameter', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: false } }, undefined, { escape: false })
expect(translation).toBe('Hallo <del>Name</del>')
})

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: '<del>Name</del>' } }, 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 &lt;del&gt;Name&lt;/del&gt;')
})

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: '<del>Name</del>', escape: 0 } }, undefined, { escape: false })
// `escape` needs to be an boolean, otherwise we fallback to `false` to prevent security issues
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('without placeholder XSS sanitizing', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: '<img src=x onerror=alert(1)//>' }, undefined, { sanitize: false, escape: false })
Expand Down
Loading