diff --git a/packages/core-base/src/translate.ts b/packages/core-base/src/translate.ts index bf3e8b4b7..3c84e9862 100644 --- a/packages/core-base/src/translate.ts +++ b/packages/core-base/src/translate.ts @@ -15,6 +15,7 @@ import { isString, mark, measure, + sanitizeTranslatedHtml, warn } from '@intlify/shared' import { isMessageAST } from './ast' @@ -153,7 +154,16 @@ export interface TranslateOptions fallbackWarn?: boolean /** * @remarks - * Whether do escape parameter for list or named interpolation values + * Whether to escape parameters for list or named interpolation values. + * When enabled, this option: + * - Escapes HTML special characters (`<`, `>`, `"`, `'`, `&`, `/`, `=`) in interpolation parameters + * - Sanitizes the final translated HTML to prevent XSS attacks by: + * - Escaping dangerous characters in HTML attribute values + * - Neutralizing event handler attributes (onclick, onerror, etc.) + * - Disabling javascript: URLs in href, src, action, formaction, and style attributes + * + * @defaultValue false + * @see [HTML Message - Using the escapeParameter option](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#using-the-escapeparameter-option) */ escapeParameter?: boolean /** @@ -765,10 +775,15 @@ export function translate< ) // if use post translation option, proceed it with handler - const ret = postTranslation + let ret = postTranslation ? postTranslation(messaged, key as string) : messaged + // apply HTML sanitization for security + if (escapeParameter && isString(ret)) { + ret = sanitizeTranslatedHtml(ret) as MessageFunctionReturn + } + // NOTE: experimental !! if (__DEV__ || __FEATURE_PROD_INTLIFY_DEVTOOLS__) { // prettier-ignore diff --git a/packages/core-base/test/translate.test.ts b/packages/core-base/test/translate.test.ts index 027172685..ba2b2784c 100644 --- a/packages/core-base/test/translate.test.ts +++ b/packages/core-base/test/translate.test.ts @@ -685,7 +685,7 @@ describe('escapeParameter', () => { }) expect(translate(ctx, 'hello', { name: 'kazupon' })).toEqual( - 'hello, <b>kazupon</b>!' + 'hello, <b>kazupon</b>!' ) }) @@ -703,7 +703,7 @@ describe('escapeParameter', () => { expect( translate(ctx, 'hello', ['kazupon'], { escapeParameter: true }) - ).toEqual('hello, <b>kazupon</b>!') + ).toEqual('hello, <b>kazupon</b>!') }) test('no escape', () => { @@ -722,6 +722,72 @@ describe('escapeParameter', () => { 'hello, kazupon!' ) }) + + test('vulnerable case from GHSA report - img onerror attack', () => { + // Mock console.warn to suppress warnings for this test + const originalWarn = console.warn + console.warn = vi.fn() + + const ctx = context({ + locale: 'en', + warnHtmlMessage: false, + escapeParameter: true, + messages: { + en: { + vulnerable: 'Caution: ' + } + } + }) + + const result = translate(ctx, 'vulnerable', { + payload: '' + }) + + // with the fix, the payload should be escaped, preventing the attack + // The onerror attribute is neutralized by converting 'o' to o + expect(result).toEqual( + 'Caution: ' + ) + + // result should NOT contain executable script tags + expect(result).not.toContain('') + + // Restore console.warn + console.warn = originalWarn + }) + + test('vulnerable case - attribute injection attack', () => { + const ctx = context({ + locale: 'en', + warnHtmlMessage: false, + escapeParameter: true, + messages: { + en: { + message: 'Click here' + } + } + }) + + const result = translate(ctx, 'message', { + url: 'javascript:alert(1)' + }) + + // with the fix, javascript: URL scheme is neutralized + expect(result).toEqual('Click here') + + // another attack vector with quotes + const result2 = translate(ctx, 'message', { + url: '" onclick="alert(1)"' + }) + + expect(result2).toEqual( + 'Click here' + ) + + // `onclick` attribute should be escaped + expect(result2).not.toContain('onclick=') + }) }) describe('error', () => { diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index f967df0cf..013e1085c 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -3,6 +3,8 @@ * written by kazuya kawaguchi */ +import { warn } from './warn' + export const inBrowser = typeof window !== 'undefined' export let mark: (tag: string) => void | undefined @@ -104,10 +106,66 @@ export const getGlobalThis = (): any => { export function escapeHtml(rawText: string): string { return rawText + .replace(/&/g, '&') // escape `&` first to avoid double escaping .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') + .replace(/\//g, '/') // escape `/` to prevent closing tags or JavaScript URLs + .replace(/=/g, '=') // escape `=` to prevent attribute injection +} + +function escapeAttributeValue(value: string): string { + return value + .replace(/&(?![a-zA-Z0-9#]{2,6};)/g, '&') // escape unescaped `&` + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') +} + +export function sanitizeTranslatedHtml(html: string): string { + // Escape dangerous characters in attribute values + // Process attributes with double quotes + html = html.replace( + /(\w+)\s*=\s*"([^"]*)"/g, + (_, attrName, attrValue) => + `${attrName}="${escapeAttributeValue(attrValue)}"` + ) + + // Process attributes with single quotes + html = html.replace( + /(\w+)\s*=\s*'([^']*)'/g, + (_, attrName, attrValue) => + `${attrName}='${escapeAttributeValue(attrValue)}'` + ) + + // Detect and neutralize event handler attributes + const eventHandlerPattern = /\s*on\w+\s*=\s*["']?[^"'>]+["']?/gi + if (eventHandlerPattern.test(html)) { + if (__DEV__) { + warn( + 'Potentially dangerous event handlers detected in translation. ' + + 'Consider removing onclick, onerror, etc. from your translation messages.' + ) + } + // Neutralize event handler attributes by escaping 'on' + html = html.replace(/(\s+)(on)(\w+\s*=)/gi, '$1on$3') + } + + // Disable javascript: URLs in various contexts + const javascriptUrlPattern = [ + // In href, src, action, formaction attributes + /(\s+(?:href|src|action|formaction)\s*=\s*["']?)\s*javascript:/gi, + // In style attributes within url() + /(style\s*=\s*["'][^"']*url\s*\(\s*)javascript:/gi + ] + + javascriptUrlPattern.forEach(pattern => { + html = html.replace(pattern, '$1javascript:') + }) + + return html } const hasOwnProperty = Object.prototype.hasOwnProperty diff --git a/packages/shared/test/utils.test.ts b/packages/shared/test/utils.test.ts index 5cefc131c..8889eb245 100644 --- a/packages/shared/test/utils.test.ts +++ b/packages/shared/test/utils.test.ts @@ -1,4 +1,18 @@ -import { format, generateCodeFrame, makeSymbol, join } from '../src/index' +import { vi } from 'vitest' + +// Mock the warn function before importing anything else +vi.mock('../src/warn', () => ({ + warn: vi.fn() +})) + +import { + escapeHtml, + format, + generateCodeFrame, + join, + makeSymbol, + sanitizeTranslatedHtml +} from '../src/index' test('format', () => { expect(format(`foo: {0}`, 'x')).toEqual('foo: x') @@ -54,3 +68,330 @@ test('join', () => { ] expect(join(longSize, ' ')).toEqual(longSize.join(' ')) }) + +describe('escapeHtml', () => { + test('escape `<` and `>`', () => { + expect(escapeHtml('
test
')).toBe( + '<div>test</div>' + ) + }) + + test('escape quotes', () => { + expect(escapeHtml(`"double" and 'single'`)).toBe( + '"double" and 'single'' + ) + }) + + test('escape `&` correctly', () => { + expect(escapeHtml('<')).toBe('&lt;') + expect(escapeHtml('&')).toBe('&amp;') + }) + + test('escape `/` for preventing closing tags', () => { + expect(escapeHtml('')).toBe('</script>') + expect(escapeHtml('javascript://')).toBe('javascript://') + }) + + test('escape `=` for preventing attribute injection', () => { + expect(escapeHtml('onerror=alert(1)')).toBe('onerror=alert(1)') + expect(escapeHtml('src=x onerror=alert(1)')).toBe( + 'src=x onerror=alert(1)' + ) + }) + + test('prevent img `onerror` attack', () => { + expect(escapeHtml('')).toBe( + '<img src=x onerror=alert(1)>' + ) + }) + + test('prevent script injection', () => { + expect(escapeHtml('')).toBe( + '</script><script>alert(1)</script>' + ) + }) + + test('handle complex XSS payloads', () => { + expect(escapeHtml('')).toBe( + '<img src="x" onerror="alert('XSS')">' + ) + }) + + test('handle empty string', () => { + expect(escapeHtml('')).toBe('') + }) + + test('handle normal text without special characters', () => { + expect(escapeHtml('Hello World')).toBe('Hello World') + }) + + test('escape all special characters in order', () => { + expect(escapeHtml('&<>"\'/=')).toBe('&<>"'/=') + }) +}) + +describe('sanitizeTranslatedHtml', () => { + test('neutralize event handlers', () => { + const html = 'Click' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('Click') + }) + + test('neutralize javascript URLs', () => { + const html = 'Click' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('Click') + }) + + test('escape dangerous characters in attribute values', () => { + const html = '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle class attribute with normal values', () => { + const html = '
Button
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('
Button
') + }) + + test('escape dangerous characters in class attribute', () => { + const html = '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle class attribute with script tags', () => { + const html = '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle multiple attributes including class', () => { + const html = + '
Test
' + const result = sanitizeTranslatedHtml(html) + // Check that dangerous characters in attribute values are escaped + expect(result).toContain('class="btn"') + expect(result).toContain('data-value="<>"') + // Check that onclick is neutralized + expect(result).toContain('onclick=') + }) + + test('handle class attribute with single quotes', () => { + const html = "
Alert
" + const result = sanitizeTranslatedHtml(html) + expect(result).toBe("
Alert
") + }) + + test('escape single quotes in class attribute with single quotes', () => { + const html = "
Test
" + const result = sanitizeTranslatedHtml(html) + expect(result).toBe("
Test
") + }) + + test('handle id attribute with dangerous characters', () => { + const html = '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle id attribute with script injection', () => { + const html = '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle style attribute with dangerous characters', () => { + const html = + '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle style attribute with javascript URL', () => { + const html = '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle style attribute with javascript URL with spaces', () => { + const html = + '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle style attribute with uppercase JavaScript URL', () => { + const html = '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Test
' + ) + }) + + test('handle multiple dangerous attributes including id and style', () => { + const html = + '
Test
' + const result = sanitizeTranslatedHtml(html) + expect(result).toContain('id="test>"') + expect(result).toContain('style="color: red"') + expect(result).toContain('class="btn"') + expect(result).toContain('onclick=') + }) + + test('handle formaction attribute with javascript URL', () => { + const html = '' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '' + ) + }) + + test('handle data attributes with javascript URL', () => { + const html = + '
Test
' + const result = sanitizeTranslatedHtml(html) + // data-* attributes are not sanitized as they don't execute directly + expect(result).toBe( + '
Test
' + ) + }) + + test('handle srcdoc attribute', () => { + const html = '' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '' + ) + }) + + test('handle attribute values without quotes', () => { + const html = '' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('') + }) + + test('handle mixed quote styles', () => { + const html = `
Test
` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + `
Test
` + ) + }) + + test('handle HTML entities in attribute values', () => { + const html = '
Test
' + const result = sanitizeTranslatedHtml(html) + // Already escaped entities should remain as is + expect(result).toBe( + '
Test
' + ) + }) + + test('handle nested quotes in attributes', () => { + const html = `
Test
` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + `
Test
` + ) + }) + + // Accessibility (a11y) attributes tests + test('handle aria-label with dangerous characters', () => { + const html = + '' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '' + ) + }) + + test('handle aria-describedby with quotes', () => { + const html = `` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '' + ) + }) + + test('handle role attribute with dangerous characters', () => { + const html = '
Click
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Click
' + ) + }) + + test('handle tabindex attribute', () => { + const html = + '
Focusable
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Focusable
' + ) + }) + + test('handle alt attribute with dangerous content', () => { + const html = + 'Image of <script>alert(1)</script>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + 'Image of <script>alert(1)</script>' + ) + }) + + test('handle title attribute with dangerous content', () => { + const html = 'XSS' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + 'XSS' + ) + }) + + test('handle multiple a11y attributes with dangerous content', () => { + const html = + '' + const result = sanitizeTranslatedHtml(html) + expect(result).toContain( + 'aria-label="<script>alert(1)</script>"' + ) + expect(result).toContain('role="button"') + expect(result).toContain('onclick=') + expect(result).toContain('tabindex="0"') + }) + + test('handle aria-hidden attribute', () => { + const html = + '
Hidden
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Hidden
' + ) + }) + + test('handle aria-live attribute', () => { + const html = + '
Status
' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '
Status
' + ) + }) +}) diff --git a/packages/vue-i18n-core/src/composer.ts b/packages/vue-i18n-core/src/composer.ts index b5bc124e3..b2a6c76fd 100644 --- a/packages/vue-i18n-core/src/composer.ts +++ b/packages/vue-i18n-core/src/composer.ts @@ -473,17 +473,21 @@ export interface ComposerOptions< warnHtmlMessage?: boolean /** * @remarks - * If `escapeParameter` is configured as true then interpolation parameters are escaped before the message is translated. + * Whether to escape parameters for list or named interpolation values. + * When enabled, this option: + * - Escapes HTML special characters (`<`, `>`, `"`, `'`, `&`, `/`, `=`) in interpolation parameters + * - Sanitizes the final translated HTML to prevent XSS attacks by: + * - Escaping dangerous characters in HTML attribute values + * - Neutralizing event handler attributes (onclick, onerror, etc.) + * - Disabling javascript: URLs in href, src, action, formaction, and style attributes * * 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 components. * - * The escape process involves replacing the following symbols with their respective HTML character entities: `<`, `>`, `"`, `'`. - * * Setting `escapeParameter` as true should not break existing functionality but provides a safeguard against a subtle type of XSS attack vectors. * - * @VueI18nSee [HTML Message](../guide/essentials/syntax#html-message) + * @VueI18nSee [HTML Message - Using the escapeParameter option](../guide/essentials/syntax#using-the-escapeparameter-option) * * @defaultValue `false` */ diff --git a/packages/vue-i18n-core/src/legacy.ts b/packages/vue-i18n-core/src/legacy.ts index ce33eb622..fdd2aa37e 100644 --- a/packages/vue-i18n-core/src/legacy.ts +++ b/packages/vue-i18n-core/src/legacy.ts @@ -248,17 +248,21 @@ export interface VueI18nOptions< warnHtmlInMessage?: WarnHtmlInMessageLevel /** * @remarks - * If `escapeParameterHtml` is configured as true then interpolation parameters are escaped before the message is translated. + * Whether to escape parameters for list or named interpolation values. + * When enabled, this option: + * - Escapes HTML special characters (`<`, `>`, `"`, `'`, `&`, `/`, `=`) in interpolation parameters + * - Sanitizes the final translated HTML to prevent XSS attacks by: + * - Escaping dangerous characters in HTML attribute values + * - Neutralizing event handler attributes (onclick, onerror, etc.) + * - Disabling javascript: URLs in href, src, action, formaction, and style attributes * * 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 components. * - * 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. * - * @VueI18nSee [HTML Message](../guide/essentials/syntax#html-message) + * @VueI18nSee [HTML Message - Using the escapeParameter option](../guide/essentials/syntax#using-the-escapeparameter-option) * * @defaultValue `false` */