CVE-2025-53892
Description
Vue I18n is the internationalization plugin for Vue.js. The escapeParameterHtml: true option in Vue I18n is designed to protect against HTML/script injection by escaping interpolated parameters. However, starting in version 9.0.0 and prior to versions 9.14.5, 10.0.8, and 11.1.0, this setting fails to prevent execution of certain tag-based payloads, such as <img src=x onerror=...>, if the interpolated value is inserted inside an HTML context using v-html. This may lead to a DOM-based XSS vulnerability, even when using escapeParameterHtml: true, if a translation string includes minor HTML and is rendered via v-html. Versions 9.14.5, 10.0.8, and 11.1.0 contain a fix for the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
vue-i18nnpm | >= 9.0.0, < 9.14.5 | 9.14.5 |
vue-i18nnpm | >= 10.0.0, < 10.0.8 | 10.0.8 |
vue-i18nnpm | >= 11.0.0, < 11.1.10 | 11.1.10 |
@intlify/corenpm | >= 9.0.0, < 9.14.5 | 9.14.5 |
@intlify/corenpm | >= 10.0.0, < 10.0.8 | 10.0.8 |
@intlify/corenpm | >= 11.0.0, < 11.1.10 | 11.1.10 |
@intlify/core-basenpm | >= 9.0.0, < 9.14.5 | 9.14.5 |
@intlify/core-basenpm | >= 10.0.0, < 10.0.8 | 10.0.8 |
@intlify/core-basenpm | >= 11.0.0, < 11.1.10 | 11.1.10 |
@intlify/vue-i18n-corenpm | >= 9.2.0, < 9.14.5 | 9.14.5 |
@intlify/vue-i18n-corenpm | >= 10.0.0, < 10.0.8 | 10.0.8 |
@intlify/vue-i18n-corenpm | >= 11.0.0, < 11.1.10 | 11.1.10 |
petite-vue-i18nnpm | >= 10.0.0, < 10.0.8 | 10.0.8 |
petite-vue-i18nnpm | >= 11.0.0, < 11.1.10 | 11.1.10 |
Affected products
1Patches
5a47099619fb9fix: DOM-based XSS via tag attributes for escape parameter (#2230)
6 files changed · +497 −14
packages/core-base/src/translate.ts+17 −2 modified@@ -9,6 +9,7 @@ import { generateFormatCacheKey, generateCodeFrame, escapeHtml, + sanitizeTranslatedHtml, inBrowser, warn, mark, @@ -154,7 +155,16 @@ export interface TranslateOptions<Locales = Locale> 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 /** @@ -763,10 +773,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<Message> + } + // NOTE: experimental !! if (__DEV__ || __FEATURE_PROD_INTLIFY_DEVTOOLS__) { // prettier-ignore
packages/core-base/test/translate.test.ts+68 −2 modified@@ -679,7 +679,7 @@ describe('escapeParameter', () => { }) expect(translate(ctx, 'hello', { name: '<b>kazupon</b>' })).toEqual( - 'hello, <b>kazupon</b>!' + 'hello, <b>kazupon</b>!' ) }) @@ -697,7 +697,7 @@ describe('escapeParameter', () => { expect( translate(ctx, 'hello', ['<b>kazupon</b>'], { escapeParameter: true }) - ).toEqual('hello, <b>kazupon</b>!') + ).toEqual('hello, <b>kazupon</b>!') }) test('no escape', () => { @@ -716,6 +716,72 @@ describe('escapeParameter', () => { 'hello, <b>kazupon</b>!' ) }) + + 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: <img src=x onerror="{payload}">' + } + } + }) + + const result = translate(ctx, 'vulnerable', { + payload: '<script>alert("xss")</script>' + }) + + // 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: <img src=x onerror="<script>alert("xss")</script>">' + ) + + // result should NOT contain executable script tags + expect(result).not.toContain('<script>') + expect(result).not.toContain('</script>') + + // 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 <a href="{url}">here</a>' + } + } + }) + + const result = translate(ctx, 'message', { + url: 'javascript:alert(1)' + }) + + // with the fix, javascript: URL scheme is neutralized + expect(result).toEqual('Click <a href="javascript:alert(1)">here</a>') + + // another attack vector with quotes + const result2 = translate(ctx, 'message', { + url: '" onclick="alert(1)"' + }) + + expect(result2).toEqual( + 'Click <a href="" onclick="alert(1)"">here</a>' + ) + + // `onclick` attribute should be escaped + expect(result2).not.toContain('onclick=') + }) }) describe('error', () => {
packages/shared/src/utils.ts+58 −0 modified@@ -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, ''') + .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, '<') + .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
packages/shared/test/utils.test.ts+338 −2 modified@@ -1,9 +1,18 @@ +import { vi } from 'vitest' + +// Mock the warn function before importing anything else +vi.mock('../src/warn', () => ({ + warn: vi.fn() +})) + import { + escapeHtml, format, generateCodeFrame, - makeSymbol, join, - incrementer + makeSymbol, + incrementer, + sanitizeTranslatedHtml } from '../src/index' test('format', () => { @@ -70,3 +79,330 @@ test('incrementer', () => { expect(inc2()).toBe(3) expect(inc2()).toBe(4) }) + +describe('escapeHtml', () => { + test('escape `<` and `>`', () => { + expect(escapeHtml('<div>test</div>')).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('</script>')).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('<img src=x onerror=alert(1)>')).toBe( + '<img src=x onerror=alert(1)>' + ) + }) + + test('prevent script injection', () => { + expect(escapeHtml('</script><script>alert(1)</script>')).toBe( + '</script><script>alert(1)</script>' + ) + }) + + test('handle complex XSS payloads', () => { + expect(escapeHtml('<img src="x" onerror="alert(\'XSS\')">')).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 = '<a href="#" onclick="alert(1)">Click</a>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('<a href="#" onclick="alert(1)">Click</a>') + }) + + test('neutralize javascript URLs', () => { + const html = '<a href="javascript:alert(1)">Click</a>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('<a href="javascript:alert(1)">Click</a>') + }) + + test('escape dangerous characters in attribute values', () => { + const html = '<div title="<script>alert(1)</script>">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div title="<script>alert(1)</script>">Test</div>' + ) + }) + + test('handle class attribute with normal values', () => { + const html = '<div class="btn btn-primary">Button</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('<div class="btn btn-primary">Button</div>') + }) + + test('escape dangerous characters in class attribute', () => { + const html = '<div class="normal" onclick="alert(1)">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div class="normal" onclick="alert(1)">Test</div>' + ) + }) + + test('handle class attribute with script tags', () => { + const html = '<div class="<script>alert(1)</script>">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div class="<script>alert(1)</script>">Test</div>' + ) + }) + + test('handle multiple attributes including class', () => { + const html = + '<div id="test" class="btn" onclick="alert(1)" data-value="<>">Test</div>' + 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 = "<div class='btn btn-danger'>Alert</div>" + const result = sanitizeTranslatedHtml(html) + expect(result).toBe("<div class='btn btn-danger'>Alert</div>") + }) + + test('escape single quotes in class attribute with single quotes', () => { + const html = "<div class='btn' onclick='alert(1)'>Test</div>" + const result = sanitizeTranslatedHtml(html) + expect(result).toBe("<div class='btn' onclick='alert(1)'>Test</div>") + }) + + test('handle id attribute with dangerous characters', () => { + const html = '<div id="test" onclick="alert(1)">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div id="test" onclick="alert(1)">Test</div>' + ) + }) + + test('handle id attribute with script injection', () => { + const html = '<div id="<script>alert(1)</script>">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div id="<script>alert(1)</script>">Test</div>' + ) + }) + + test('handle style attribute with dangerous characters', () => { + const html = + '<div style="color: red" onclick="alert(1)">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div style="color: red" onclick="alert(1)">Test</div>' + ) + }) + + test('handle style attribute with javascript URL', () => { + const html = '<div style="background: url(javascript:alert(1))">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div style="background: url(javascript:alert(1))">Test</div>' + ) + }) + + test('handle style attribute with javascript URL with spaces', () => { + const html = + '<div style="background: url( javascript:alert(1) )">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div style="background: url( javascript:alert(1) )">Test</div>' + ) + }) + + test('handle style attribute with uppercase JavaScript URL', () => { + const html = '<div style="background: url(JAVASCRIPT:alert(1))">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div style="background: url(javascript:alert(1))">Test</div>' + ) + }) + + test('handle multiple dangerous attributes including id and style', () => { + const html = + '<div id="test>" style="color: red" class="btn" onclick="alert(1)">Test</div>' + 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 = '<button formaction="javascript:alert(1)">Submit</button>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<button formaction="javascript:alert(1)">Submit</button>' + ) + }) + + test('handle data attributes with javascript URL', () => { + const html = + '<div data-href="javascript:alert(1)" data-value="safe">Test</div>' + const result = sanitizeTranslatedHtml(html) + // data-* attributes are not sanitized as they don't execute directly + expect(result).toBe( + '<div data-href="javascript:alert(1)" data-value="safe">Test</div>' + ) + }) + + test('handle srcdoc attribute', () => { + const html = '<iframe srcdoc="<script>alert(1)</script>">Test</iframe>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<iframe srcdoc="<script>alert(1)</script>">Test</iframe>' + ) + }) + + test('handle attribute values without quotes', () => { + const html = '<img src=x onerror=alert(1)>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('<img src=x onerror=alert(1)>') + }) + + test('handle mixed quote styles', () => { + const html = `<div title='test" onclick="alert(1)'>Test</div>` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + `<div title='test" onclick="alert(1)'>Test</div>` + ) + }) + + test('handle HTML entities in attribute values', () => { + const html = '<div title="<script>alert(1)</script>">Test</div>' + const result = sanitizeTranslatedHtml(html) + // Already escaped entities should remain as is + expect(result).toBe( + '<div title="<script>alert(1)</script>">Test</div>' + ) + }) + + test('handle nested quotes in attributes', () => { + const html = `<div title='"hello" onclick="alert(1)"'>Test</div>` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + `<div title='"hello" onclick="alert(1)"'>Test</div>` + ) + }) + + // Accessibility (a11y) attributes tests + test('handle aria-label with dangerous characters', () => { + const html = + '<button aria-label="Click to <script>alert(1)</script>">Button</button>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<button aria-label="Click to <script>alert(1)</script>">Button</button>' + ) + }) + + test('handle aria-describedby with quotes', () => { + const html = `<input aria-describedby="desc" onclick="alert(1)" />` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<input aria-describedby="desc" onclick="alert(1)" />' + ) + }) + + test('handle role attribute with dangerous characters', () => { + const html = '<div role="button" onclick="alert(1)">Click</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div role="button" onclick="alert(1)">Click</div>' + ) + }) + + test('handle tabindex attribute', () => { + const html = + '<div tabindex="0" onclick="alert(1)">Focusable</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div tabindex="0" onclick="alert(1)">Focusable</div>' + ) + }) + + test('handle alt attribute with dangerous content', () => { + const html = + '<img src="test.jpg" alt="Image of <script>alert(1)</script>" />' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<img src="test.jpg" alt="Image of <script>alert(1)</script>" />' + ) + }) + + test('handle title attribute with dangerous content', () => { + const html = '<abbr title="<img src=x onerror=alert(1)>">XSS</abbr>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<abbr title="<img src=x onerror=alert(1)>">XSS</abbr>' + ) + }) + + test('handle multiple a11y attributes with dangerous content', () => { + const html = + '<button aria-label="<script>alert(1)</script>" role="button" onclick="alert(1)" tabindex="0">Click</button>' + 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 = + '<div aria-hidden="true" onclick="alert(1)">Hidden</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div aria-hidden="true" onclick="alert(1)">Hidden</div>' + ) + }) + + test('handle aria-live attribute', () => { + const html = + '<div aria-live="polite" aria-label="Status: <b>Active</b>">Status</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div aria-live="polite" aria-label="Status: <b>Active</b>">Status</div>' + ) + }) +})
packages/vue-i18n-core/src/composer.ts+8 −4 modified@@ -476,17 +476,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. <b> 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` */
packages/vue-i18n-core/src/legacy.ts+8 −4 modified@@ -270,17 +270,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. <b> 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` */
49f982443ab8fix: escapeParameterHtml does not prevent DOM-based XSS via tag attributes like onerror (#2229)
6 files changed · +501 −13
packages/core-base/src/translate.ts+17 −2 modified@@ -15,6 +15,7 @@ import { isString, mark, measure, + sanitizeTranslatedHtml, warn } from '@intlify/shared' import { isMessageAST } from './ast' @@ -153,7 +154,16 @@ export interface TranslateOptions<Locales = Locale> 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<Message> + } + // NOTE: experimental !! if (__DEV__ || __FEATURE_PROD_INTLIFY_DEVTOOLS__) { // prettier-ignore
packages/core-base/test/translate.test.ts+68 −2 modified@@ -685,7 +685,7 @@ describe('escapeParameter', () => { }) expect(translate(ctx, 'hello', { name: '<b>kazupon</b>' })).toEqual( - 'hello, <b>kazupon</b>!' + 'hello, <b>kazupon</b>!' ) }) @@ -703,7 +703,7 @@ describe('escapeParameter', () => { expect( translate(ctx, 'hello', ['<b>kazupon</b>'], { escapeParameter: true }) - ).toEqual('hello, <b>kazupon</b>!') + ).toEqual('hello, <b>kazupon</b>!') }) test('no escape', () => { @@ -722,6 +722,72 @@ describe('escapeParameter', () => { 'hello, <b>kazupon</b>!' ) }) + + 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: <img src=x onerror="{payload}">' + } + } + }) + + const result = translate(ctx, 'vulnerable', { + payload: '<script>alert("xss")</script>' + }) + + // 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: <img src=x onerror="<script>alert("xss")</script>">' + ) + + // result should NOT contain executable script tags + expect(result).not.toContain('<script>') + expect(result).not.toContain('</script>') + + // 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 <a href="{url}">here</a>' + } + } + }) + + const result = translate(ctx, 'message', { + url: 'javascript:alert(1)' + }) + + // with the fix, javascript: URL scheme is neutralized + expect(result).toEqual('Click <a href="javascript:alert(1)">here</a>') + + // another attack vector with quotes + const result2 = translate(ctx, 'message', { + url: '" onclick="alert(1)"' + }) + + expect(result2).toEqual( + 'Click <a href="" onclick="alert(1)"">here</a>' + ) + + // `onclick` attribute should be escaped + expect(result2).not.toContain('onclick=') + }) }) describe('error', () => {
packages/shared/src/utils.ts+58 −0 modified@@ -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, ''') + .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, '<') + .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
packages/shared/test/utils.test.ts+342 −1 modified@@ -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('<div>test</div>')).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('</script>')).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('<img src=x onerror=alert(1)>')).toBe( + '<img src=x onerror=alert(1)>' + ) + }) + + test('prevent script injection', () => { + expect(escapeHtml('</script><script>alert(1)</script>')).toBe( + '</script><script>alert(1)</script>' + ) + }) + + test('handle complex XSS payloads', () => { + expect(escapeHtml('<img src="x" onerror="alert(\'XSS\')">')).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 = '<a href="#" onclick="alert(1)">Click</a>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('<a href="#" onclick="alert(1)">Click</a>') + }) + + test('neutralize javascript URLs', () => { + const html = '<a href="javascript:alert(1)">Click</a>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('<a href="javascript:alert(1)">Click</a>') + }) + + test('escape dangerous characters in attribute values', () => { + const html = '<div title="<script>alert(1)</script>">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div title="<script>alert(1)</script>">Test</div>' + ) + }) + + test('handle class attribute with normal values', () => { + const html = '<div class="btn btn-primary">Button</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('<div class="btn btn-primary">Button</div>') + }) + + test('escape dangerous characters in class attribute', () => { + const html = '<div class="normal" onclick="alert(1)">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div class="normal" onclick="alert(1)">Test</div>' + ) + }) + + test('handle class attribute with script tags', () => { + const html = '<div class="<script>alert(1)</script>">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div class="<script>alert(1)</script>">Test</div>' + ) + }) + + test('handle multiple attributes including class', () => { + const html = + '<div id="test" class="btn" onclick="alert(1)" data-value="<>">Test</div>' + 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 = "<div class='btn btn-danger'>Alert</div>" + const result = sanitizeTranslatedHtml(html) + expect(result).toBe("<div class='btn btn-danger'>Alert</div>") + }) + + test('escape single quotes in class attribute with single quotes', () => { + const html = "<div class='btn' onclick='alert(1)'>Test</div>" + const result = sanitizeTranslatedHtml(html) + expect(result).toBe("<div class='btn' onclick='alert(1)'>Test</div>") + }) + + test('handle id attribute with dangerous characters', () => { + const html = '<div id="test" onclick="alert(1)">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div id="test" onclick="alert(1)">Test</div>' + ) + }) + + test('handle id attribute with script injection', () => { + const html = '<div id="<script>alert(1)</script>">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div id="<script>alert(1)</script>">Test</div>' + ) + }) + + test('handle style attribute with dangerous characters', () => { + const html = + '<div style="color: red" onclick="alert(1)">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div style="color: red" onclick="alert(1)">Test</div>' + ) + }) + + test('handle style attribute with javascript URL', () => { + const html = '<div style="background: url(javascript:alert(1))">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div style="background: url(javascript:alert(1))">Test</div>' + ) + }) + + test('handle style attribute with javascript URL with spaces', () => { + const html = + '<div style="background: url( javascript:alert(1) )">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div style="background: url( javascript:alert(1) )">Test</div>' + ) + }) + + test('handle style attribute with uppercase JavaScript URL', () => { + const html = '<div style="background: url(JAVASCRIPT:alert(1))">Test</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div style="background: url(javascript:alert(1))">Test</div>' + ) + }) + + test('handle multiple dangerous attributes including id and style', () => { + const html = + '<div id="test>" style="color: red" class="btn" onclick="alert(1)">Test</div>' + 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 = '<button formaction="javascript:alert(1)">Submit</button>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<button formaction="javascript:alert(1)">Submit</button>' + ) + }) + + test('handle data attributes with javascript URL', () => { + const html = + '<div data-href="javascript:alert(1)" data-value="safe">Test</div>' + const result = sanitizeTranslatedHtml(html) + // data-* attributes are not sanitized as they don't execute directly + expect(result).toBe( + '<div data-href="javascript:alert(1)" data-value="safe">Test</div>' + ) + }) + + test('handle srcdoc attribute', () => { + const html = '<iframe srcdoc="<script>alert(1)</script>">Test</iframe>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<iframe srcdoc="<script>alert(1)</script>">Test</iframe>' + ) + }) + + test('handle attribute values without quotes', () => { + const html = '<img src=x onerror=alert(1)>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe('<img src=x onerror=alert(1)>') + }) + + test('handle mixed quote styles', () => { + const html = `<div title='test" onclick="alert(1)'>Test</div>` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + `<div title='test" onclick="alert(1)'>Test</div>` + ) + }) + + test('handle HTML entities in attribute values', () => { + const html = '<div title="<script>alert(1)</script>">Test</div>' + const result = sanitizeTranslatedHtml(html) + // Already escaped entities should remain as is + expect(result).toBe( + '<div title="<script>alert(1)</script>">Test</div>' + ) + }) + + test('handle nested quotes in attributes', () => { + const html = `<div title='"hello" onclick="alert(1)"'>Test</div>` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + `<div title='"hello" onclick="alert(1)"'>Test</div>` + ) + }) + + // Accessibility (a11y) attributes tests + test('handle aria-label with dangerous characters', () => { + const html = + '<button aria-label="Click to <script>alert(1)</script>">Button</button>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<button aria-label="Click to <script>alert(1)</script>">Button</button>' + ) + }) + + test('handle aria-describedby with quotes', () => { + const html = `<input aria-describedby="desc" onclick="alert(1)" />` + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<input aria-describedby="desc" onclick="alert(1)" />' + ) + }) + + test('handle role attribute with dangerous characters', () => { + const html = '<div role="button" onclick="alert(1)">Click</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div role="button" onclick="alert(1)">Click</div>' + ) + }) + + test('handle tabindex attribute', () => { + const html = + '<div tabindex="0" onclick="alert(1)">Focusable</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div tabindex="0" onclick="alert(1)">Focusable</div>' + ) + }) + + test('handle alt attribute with dangerous content', () => { + const html = + '<img src="test.jpg" alt="Image of <script>alert(1)</script>" />' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<img src="test.jpg" alt="Image of <script>alert(1)</script>" />' + ) + }) + + test('handle title attribute with dangerous content', () => { + const html = '<abbr title="<img src=x onerror=alert(1)>">XSS</abbr>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<abbr title="<img src=x onerror=alert(1)>">XSS</abbr>' + ) + }) + + test('handle multiple a11y attributes with dangerous content', () => { + const html = + '<button aria-label="<script>alert(1)</script>" role="button" onclick="alert(1)" tabindex="0">Click</button>' + 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 = + '<div aria-hidden="true" onclick="alert(1)">Hidden</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div aria-hidden="true" onclick="alert(1)">Hidden</div>' + ) + }) + + test('handle aria-live attribute', () => { + const html = + '<div aria-live="polite" aria-label="Status: <b>Active</b>">Status</div>' + const result = sanitizeTranslatedHtml(html) + expect(result).toBe( + '<div aria-live="polite" aria-label="Status: <b>Active</b>">Status</div>' + ) + }) +})
packages/vue-i18n-core/src/composer.ts+8 −4 modified@@ -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. <b> 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` */
packages/vue-i18n-core/src/legacy.ts+8 −4 modified@@ -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. <b> 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` */
30971026b77cecbbd3a22361924596094e31Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/advisories/GHSA-x8qp-wqqm-57phghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-53892ghsaADVISORY
- github.com/intlify/vue-i18n/commit/49f982443ab8fd94ecc427b265ce97d57df94d7envdWEB
- github.com/intlify/vue-i18n/commit/a47099619fb9b256e86341a8658ebe72e92ab099nvdWEB
- github.com/intlify/vue-i18n/pull/2229nvdWEB
- github.com/intlify/vue-i18n/pull/2230nvdWEB
- github.com/intlify/vue-i18n/releases/tag/v10.0.8nvdWEB
- github.com/intlify/vue-i18n/releases/tag/v11.1.10nvdWEB
- github.com/intlify/vue-i18n/releases/tag/v9.14.5nvdWEB
- github.com/intlify/vue-i18n/security/advisories/GHSA-x8qp-wqqm-57phnvdWEB
News mentions
0No linked articles in our index yet.