Skip to content

Commit a470996

Browse files
authored
fix: DOM-based XSS via tag attributes for escape parameter (#2230)
1 parent 0e52ea3 commit a470996

File tree

6 files changed

+497
-14
lines changed

6 files changed

+497
-14
lines changed

packages/core-base/src/translate.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
generateFormatCacheKey,
1010
generateCodeFrame,
1111
escapeHtml,
12+
sanitizeTranslatedHtml,
1213
inBrowser,
1314
warn,
1415
mark,
@@ -154,7 +155,16 @@ export interface TranslateOptions<Locales = Locale>
154155
fallbackWarn?: boolean
155156
/**
156157
* @remarks
157-
* Whether do escape parameter for list or named interpolation values
158+
* Whether to escape parameters for list or named interpolation values.
159+
* When enabled, this option:
160+
* - Escapes HTML special characters (`<`, `>`, `"`, `'`, `&`, `/`, `=`) in interpolation parameters
161+
* - Sanitizes the final translated HTML to prevent XSS attacks by:
162+
* - Escaping dangerous characters in HTML attribute values
163+
* - Neutralizing event handler attributes (onclick, onerror, etc.)
164+
* - Disabling javascript: URLs in href, src, action, formaction, and style attributes
165+
*
166+
* @defaultValue false
167+
* @see [HTML Message - Using the escapeParameter option](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#using-the-escapeparameter-option)
158168
*/
159169
escapeParameter?: boolean
160170
/**
@@ -763,10 +773,15 @@ export function translate<
763773
)
764774

765775
// if use post translation option, proceed it with handler
766-
const ret = postTranslation
776+
let ret = postTranslation
767777
? postTranslation(messaged, key as string)
768778
: messaged
769779

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

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

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

681681
expect(translate(ctx, 'hello', { name: '<b>kazupon</b>' })).toEqual(
682-
'hello, &lt;b&gt;kazupon&lt;/b&gt;!'
682+
'hello, &lt;b&gt;kazupon&lt;&#x2F;b&gt;!'
683683
)
684684
})
685685

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

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

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

721787
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)