Skip to content

Commit

Permalink
Merge pull request #756 from nextcloud-libraries/feat/allow-per-repla…
Browse files Browse the repository at this point in the history
…cement-escaping

feat: Allow setting `escape` option per parameter replacing
  • Loading branch information
susnux authored May 6, 2024
2 parents 5cf6b4b + 7775257 commit 558a387
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 5 deletions.
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

0 comments on commit 558a387

Please sign in to comment.