Skip to content

fix: DOM-based XSS via tag attributes for escape parameter #2230

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

Merged
merged 1 commit into from
Jul 16, 2025
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
19 changes: 17 additions & 2 deletions packages/core-base/src/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
generateFormatCacheKey,
generateCodeFrame,
escapeHtml,
sanitizeTranslatedHtml,
inBrowser,
warn,
mark,
Expand Down Expand Up @@ -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
/**
Expand Down Expand Up @@ -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
Expand Down
70 changes: 68 additions & 2 deletions packages/core-base/test/translate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ describe('escapeParameter', () => {
})

expect(translate(ctx, 'hello', { name: '<b>kazupon</b>' })).toEqual(
'hello, &lt;b&gt;kazupon&lt;/b&gt;!'
'hello, &lt;b&gt;kazupon&lt;&#x2F;b&gt;!'
)
})

Expand All @@ -697,7 +697,7 @@ describe('escapeParameter', () => {

expect(
translate(ctx, 'hello', ['<b>kazupon</b>'], { escapeParameter: true })
).toEqual('hello, &lt;b&gt;kazupon&lt;/b&gt;!')
).toEqual('hello, &lt;b&gt;kazupon&lt;&#x2F;b&gt;!')
})

test('no escape', () => {
Expand All @@ -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 &#111;
expect(result).toEqual(
'Caution: <img src=x &#111;nerror="&lt;script&gt;alert(&quot;xss&quot;)&lt;&#x2F;script&gt;">'
)

// 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&#58;alert(1)">here</a>')

// another attack vector with quotes
const result2 = translate(ctx, 'message', {
url: '" onclick="alert(1)"'
})

expect(result2).toEqual(
'Click <a href="&quot; onclick&#x3D;&quot;alert(1)&quot;">here</a>'
)

// `onclick` attribute should be escaped
expect(result2).not.toContain('onclick=')
})
})

describe('error', () => {
Expand Down
58 changes: 58 additions & 0 deletions packages/shared/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,10 +106,66 @@ export const getGlobalThis = (): any => {

export function escapeHtml(rawText: string): string {
return rawText
.replace(/&/g, '&amp;') // escape `&` first to avoid double escaping
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/\//g, '&#x2F;') // escape `/` to prevent closing tags or JavaScript URLs
.replace(/=/g, '&#x3D;') // escape `=` to prevent attribute injection
}

function escapeAttributeValue(value: string): string {
return value
.replace(/&(?![a-zA-Z0-9#]{2,6};)/g, '&amp;') // escape unescaped `&`
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}

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, '$1&#111;n$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&#58;')
})

return html
}

const hasOwnProperty = Object.prototype.hasOwnProperty
Expand Down
Loading
Loading