Skip to content

Commit 49f9824

Browse files
authored
fix: escapeParameterHtml does not prevent DOM-based XSS via tag attributes like onerror (#2229)
1 parent 9b20905 commit 49f9824

File tree

6 files changed

+501
-13
lines changed

6 files changed

+501
-13
lines changed

packages/core-base/src/translate.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
isString,
1616
mark,
1717
measure,
18+
sanitizeTranslatedHtml,
1819
warn
1920
} from '@intlify/shared'
2021
import { isMessageAST } from './ast'
@@ -153,7 +154,16 @@ export interface TranslateOptions<Locales = Locale>
153154
fallbackWarn?: boolean
154155
/**
155156
* @remarks
156-
* Whether do escape parameter for list or named interpolation values
157+
* Whether to escape parameters for list or named interpolation values.
158+
* When enabled, this option:
159+
* - Escapes HTML special characters (`<`, `>`, `"`, `'`, `&`, `/`, `=`) in interpolation parameters
160+
* - Sanitizes the final translated HTML to prevent XSS attacks by:
161+
* - Escaping dangerous characters in HTML attribute values
162+
* - Neutralizing event handler attributes (onclick, onerror, etc.)
163+
* - Disabling javascript: URLs in href, src, action, formaction, and style attributes
164+
*
165+
* @defaultValue false
166+
* @see [HTML Message - Using the escapeParameter option](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#using-the-escapeparameter-option)
157167
*/
158168
escapeParameter?: boolean
159169
/**
@@ -765,10 +775,15 @@ export function translate<
765775
)
766776

767777
// if use post translation option, proceed it with handler
768-
const ret = postTranslation
778+
let ret = postTranslation
769779
? postTranslation(messaged, key as string)
770780
: messaged
771781

782+
// apply HTML sanitization for security
783+
if (escapeParameter && isString(ret)) {
784+
ret = sanitizeTranslatedHtml(ret) as MessageFunctionReturn<Message>
785+
}
786+
772787
// NOTE: experimental !!
773788
if (__DEV__ || __FEATURE_PROD_INTLIFY_DEVTOOLS__) {
774789
// prettier-ignore

packages/core-base/test/translate.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ describe('escapeParameter', () => {
685685
})
686686

687687
expect(translate(ctx, 'hello', { name: '<b>kazupon</b>' })).toEqual(
688-
'hello, &lt;b&gt;kazupon&lt;/b&gt;!'
688+
'hello, &lt;b&gt;kazupon&lt;&#x2F;b&gt;!'
689689
)
690690
})
691691

@@ -703,7 +703,7 @@ describe('escapeParameter', () => {
703703

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

709709
test('no escape', () => {
@@ -722,6 +722,72 @@ describe('escapeParameter', () => {
722722
'hello, <b>kazupon</b>!'
723723
)
724724
})
725+
726+
test('vulnerable case from GHSA report - img onerror attack', () => {
727+
// Mock console.warn to suppress warnings for this test
728+
const originalWarn = console.warn
729+
console.warn = vi.fn()
730+
731+
const ctx = context({
732+
locale: 'en',
733+
warnHtmlMessage: false,
734+
escapeParameter: true,
735+
messages: {
736+
en: {
737+
vulnerable: 'Caution: <img src=x onerror="{payload}">'
738+
}
739+
}
740+
})
741+
742+
const result = translate(ctx, 'vulnerable', {
743+
payload: '<script>alert("xss")</script>'
744+
})
745+
746+
// with the fix, the payload should be escaped, preventing the attack
747+
// The onerror attribute is neutralized by converting 'o' to &#111;
748+
expect(result).toEqual(
749+
'Caution: <img src=x &#111;nerror="&lt;script&gt;alert(&quot;xss&quot;)&lt;&#x2F;script&gt;">'
750+
)
751+
752+
// result should NOT contain executable script tags
753+
expect(result).not.toContain('<script>')
754+
expect(result).not.toContain('</script>')
755+
756+
// Restore console.warn
757+
console.warn = originalWarn
758+
})
759+
760+
test('vulnerable case - attribute injection attack', () => {
761+
const ctx = context({
762+
locale: 'en',
763+
warnHtmlMessage: false,
764+
escapeParameter: true,
765+
messages: {
766+
en: {
767+
message: 'Click <a href="{url}">here</a>'
768+
}
769+
}
770+
})
771+
772+
const result = translate(ctx, 'message', {
773+
url: 'javascript:alert(1)'
774+
})
775+
776+
// with the fix, javascript: URL scheme is neutralized
777+
expect(result).toEqual('Click <a href="javascript&#58;alert(1)">here</a>')
778+
779+
// another attack vector with quotes
780+
const result2 = translate(ctx, 'message', {
781+
url: '" onclick="alert(1)"'
782+
})
783+
784+
expect(result2).toEqual(
785+
'Click <a href="&quot; onclick&#x3D;&quot;alert(1)&quot;">here</a>'
786+
)
787+
788+
// `onclick` attribute should be escaped
789+
expect(result2).not.toContain('onclick=')
790+
})
725791
})
726792

727793
describe('error', () => {

packages/shared/src/utils.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* written by kazuya kawaguchi
44
*/
55

6+
import { warn } from './warn'
7+
68
export const inBrowser = typeof window !== 'undefined'
79

810
export let mark: (tag: string) => void | undefined
@@ -104,10 +106,66 @@ export const getGlobalThis = (): any => {
104106

105107
export function escapeHtml(rawText: string): string {
106108
return rawText
109+
.replace(/&/g, '&amp;') // escape `&` first to avoid double escaping
107110
.replace(/</g, '&lt;')
108111
.replace(/>/g, '&gt;')
109112
.replace(/"/g, '&quot;')
110113
.replace(/'/g, '&apos;')
114+
.replace(/\//g, '&#x2F;') // escape `/` to prevent closing tags or JavaScript URLs
115+
.replace(/=/g, '&#x3D;') // escape `=` to prevent attribute injection
116+
}
117+
118+
function escapeAttributeValue(value: string): string {
119+
return value
120+
.replace(/&(?![a-zA-Z0-9#]{2,6};)/g, '&amp;') // escape unescaped `&`
121+
.replace(/"/g, '&quot;')
122+
.replace(/'/g, '&apos;')
123+
.replace(/</g, '&lt;')
124+
.replace(/>/g, '&gt;')
125+
}
126+
127+
export function sanitizeTranslatedHtml(html: string): string {
128+
// Escape dangerous characters in attribute values
129+
// Process attributes with double quotes
130+
html = html.replace(
131+
/(\w+)\s*=\s*"([^"]*)"/g,
132+
(_, attrName, attrValue) =>
133+
`${attrName}="${escapeAttributeValue(attrValue)}"`
134+
)
135+
136+
// Process attributes with single quotes
137+
html = html.replace(
138+
/(\w+)\s*=\s*'([^']*)'/g,
139+
(_, attrName, attrValue) =>
140+
`${attrName}='${escapeAttributeValue(attrValue)}'`
141+
)
142+
143+
// Detect and neutralize event handler attributes
144+
const eventHandlerPattern = /\s*on\w+\s*=\s*["']?[^"'>]+["']?/gi
145+
if (eventHandlerPattern.test(html)) {
146+
if (__DEV__) {
147+
warn(
148+
'Potentially dangerous event handlers detected in translation. ' +
149+
'Consider removing onclick, onerror, etc. from your translation messages.'
150+
)
151+
}
152+
// Neutralize event handler attributes by escaping 'on'
153+
html = html.replace(/(\s+)(on)(\w+\s*=)/gi, '$1&#111;n$3')
154+
}
155+
156+
// Disable javascript: URLs in various contexts
157+
const javascriptUrlPattern = [
158+
// In href, src, action, formaction attributes
159+
/(\s+(?:href|src|action|formaction)\s*=\s*["']?)\s*javascript:/gi,
160+
// In style attributes within url()
161+
/(style\s*=\s*["'][^"']*url\s*\(\s*)javascript:/gi
162+
]
163+
164+
javascriptUrlPattern.forEach(pattern => {
165+
html = html.replace(pattern, '$1javascript&#58;')
166+
})
167+
168+
return html
111169
}
112170

113171
const hasOwnProperty = Object.prototype.hasOwnProperty

0 commit comments

Comments
 (0)