Skip to content

Commit b1331af

Browse files
authored
Merge commit from fork
* fix: XSS vulnerability with prototype pollution on AST for v9 * fix: update e2e * fix: filename * fix: change type name
1 parent ac10bc3 commit b1331af

File tree

7 files changed

+855
-42
lines changed

7 files changed

+855
-42
lines changed

e2e/hotfix.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { getText } from './helper'
2+
3+
describe('CVE-2024-52809', () => {
4+
beforeAll(async () => {
5+
await page.goto(`http://localhost:8080/e2e/hotfix/CVE-2024-52809.html`)
6+
})
7+
8+
test('fix', async () => {
9+
expect(await getText(page, 'p')).toMatch('hello world!')
10+
})
11+
})

e2e/hotfix/CVE-2024-52809.html

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>vue-i18n XSS</title>
6+
<script src="../../node_modules/vue/dist/vue.global.js"></script>
7+
<script src="../../packages/vue-i18n/dist/vue-i18n.global.js"></script>
8+
<!-- Scripts that perform prototype contamination, such as being distributed from malicious hosting sites or injected through supply chain attacks, etc. -->
9+
<script>
10+
/**
11+
* Prototype pollution vulnerability with `Object.prototype`.
12+
* The 'static' property is part of the optimized AST generated by the vue-i18n message compiler.
13+
* About details of special properties, see https://github.com/intlify/vue-i18n/blob/master/packages/message-compiler/src/nodes.ts
14+
*
15+
* In general, the locale messages of vue-i18n are optimized during production builds using `@intlify/unplugin-vue-i18n`,
16+
* so there is always a property that is attached during optimization like this time.
17+
* But if you are using a locale message AST in development or your own, there is a possibility of XSS if a third party injects prototype pollution code.
18+
*/
19+
Object.defineProperty(Object.prototype, 'static', {
20+
configurable: true,
21+
get() {
22+
alert('prototype polluted!')
23+
return 'prototype pollution'
24+
}
25+
})
26+
</script>
27+
</head>
28+
<body>
29+
<div id="app">
30+
<p>{{ t('hello') }}</p>
31+
</div>
32+
<script>
33+
const { createApp } = Vue
34+
const { createI18n, useI18n } = VueI18n
35+
36+
// AST style locale message, which build by `@intlify/unplugin-vue-i18n`
37+
const en = {
38+
hello: {
39+
type: 0,
40+
body: {
41+
items: [
42+
{
43+
type: 3,
44+
value: 'hello world!'
45+
}
46+
]
47+
}
48+
}
49+
}
50+
51+
const i18n = createI18n({
52+
legacy: false,
53+
locale: 'en',
54+
messages: {
55+
en
56+
}
57+
})
58+
59+
const app = createApp({
60+
setup() {
61+
const { t } = useI18n()
62+
return { t }
63+
}
64+
})
65+
app.use(i18n)
66+
app.mount('#app')
67+
</script>
68+
</body>
69+
</html>

packages/core-base/src/compilation.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1-
import { warn, format, isObject, isBoolean, isString } from '@intlify/shared'
1+
import {
2+
format,
3+
hasOwn,
4+
isBoolean,
5+
isObject,
6+
isString,
7+
warn
8+
} from '@intlify/shared'
9+
import { format as formatMessage, resolveType } from './format'
210
import {
311
baseCompile as baseCompileCore,
412
CompileWarnCodes,
513
defaultOnError,
614
detectHtmlTag
715
} from '@intlify/message-compiler'
8-
import { format as formatMessage } from './format'
916
import { CoreErrorCodes, createCoreError } from './errors'
1017

1118
import type {
1219
CompileOptions,
1320
CompileError,
1421
CompilerResult,
1522
ResourceNode,
23+
Node,
1624
CompileWarn
1725
} from '@intlify/message-compiler'
1826
import type { MessageFunction, MessageFunctions } from './runtime'
@@ -44,10 +52,13 @@ export function clearCompileCache(): void {
4452
compileCache = Object.create(null)
4553
}
4654

47-
export const isMessageAST = (val: unknown): val is ResourceNode =>
48-
isObject(val) &&
49-
(val.t === 0 || val.type === 0) &&
50-
('b' in val || 'body' in val)
55+
export function isMessageAST(val: unknown): val is ResourceNode {
56+
return (
57+
isObject(val) &&
58+
resolveType(val as Node) === 0 &&
59+
(hasOwn(val, 'b') || hasOwn(val, 'body'))
60+
)
61+
}
5162

5263
function baseCompile(
5364
message: string,

packages/core-base/src/format.ts

Lines changed: 141 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
import { NodeTypes } from '@intlify/message-compiler'
2+
import { hasOwn, isNumber } from '@intlify/shared'
23

34
import type {
4-
Node,
5-
TextNode,
6-
LiteralNode,
5+
LinkedModifierNode,
6+
LinkedNode,
77
ListNode,
88
MessageNode,
99
NamedNode,
10-
LinkedNode,
11-
LinkedKeyNode,
12-
LinkedModifierNode,
10+
Node,
1311
PluralNode,
1412
ResourceNode
1513
} from '@intlify/message-compiler'
1614
import type {
1715
MessageContext,
1816
MessageFunction,
19-
MessageType,
20-
MessageFunctionReturn
17+
MessageFunctionReturn,
18+
MessageType
2119
} from './runtime'
2220

2321
export function format<Message = string>(
@@ -28,14 +26,18 @@ export function format<Message = string>(
2826
return msg
2927
}
3028

31-
function formatParts<Message = string>(
29+
export function formatParts<Message = string>(
3230
ctx: MessageContext<Message>,
3331
ast: ResourceNode
3432
): MessageFunctionReturn<Message> {
35-
const body = ast.b || ast.body
36-
if ((body.t || body.type) === NodeTypes.Plural) {
33+
const body = resolveBody(ast)
34+
if (body == null) {
35+
throw createUnhandleNodeError(NodeTypes.Resource)
36+
}
37+
const type = resolveType(body)
38+
if (type === NodeTypes.Plural) {
3739
const plural = body as PluralNode
38-
const cases = plural.c || plural.cases
40+
const cases = resolveCases(plural)
3941
return ctx.plural(
4042
cases.reduce(
4143
(messages, c) =>
@@ -51,64 +53,170 @@ function formatParts<Message = string>(
5153
}
5254
}
5355

54-
function formatMessageParts<Message = string>(
56+
const PROPS_BODY = ['b', 'body']
57+
58+
function resolveBody(node: ResourceNode) {
59+
return resolveProps<MessageNode | PluralNode>(node, PROPS_BODY)
60+
}
61+
62+
const PROPS_CASES = ['c', 'cases']
63+
64+
function resolveCases(node: PluralNode) {
65+
return resolveProps<PluralNode['cases'], PluralNode['cases']>(
66+
node,
67+
PROPS_CASES,
68+
[]
69+
)
70+
}
71+
72+
export function formatMessageParts<Message = string>(
5573
ctx: MessageContext<Message>,
5674
node: MessageNode
5775
): MessageFunctionReturn<Message> {
58-
const _static = node.s || node.static
59-
if (_static) {
76+
const static_ = resolveStatic(node)
77+
if (static_ != null) {
6078
return ctx.type === 'text'
61-
? (_static as MessageFunctionReturn<Message>)
62-
: ctx.normalize([_static] as MessageType<Message>[])
79+
? (static_ as MessageFunctionReturn<Message>)
80+
: ctx.normalize([static_] as MessageType<Message>[])
6381
} else {
64-
const messages = (node.i || node.items).reduce(
82+
const messages = resolveItems(node).reduce(
6583
(acm, c) => [...acm, formatMessagePart(ctx, c)],
6684
[] as MessageType<Message>[]
6785
)
6886
return ctx.normalize(messages) as MessageFunctionReturn<Message>
6987
}
7088
}
7189

72-
function formatMessagePart<Message = string>(
90+
const PROPS_STATIC = ['s', 'static']
91+
92+
function resolveStatic(node: MessageNode) {
93+
return resolveProps(node, PROPS_STATIC)
94+
}
95+
96+
const PROPS_ITEMS = ['i', 'items']
97+
98+
function resolveItems(node: MessageNode) {
99+
return resolveProps<MessageNode['items'], MessageNode['items']>(
100+
node,
101+
PROPS_ITEMS,
102+
[]
103+
)
104+
}
105+
106+
type NodeValue<Message> = {
107+
v?: MessageType<Message>
108+
value?: MessageType<Message>
109+
}
110+
111+
export function formatMessagePart<Message = string>(
73112
ctx: MessageContext<Message>,
74113
node: Node
75114
): MessageType<Message> {
76-
const type = node.t || node.type
115+
const type = resolveType(node)
77116
switch (type) {
78117
case NodeTypes.Text: {
79-
const text = node as TextNode
80-
return (text.v || text.value) as MessageType<Message>
118+
return resolveValue<Message>(node as NodeValue<Message>, type)
81119
}
82120
case NodeTypes.Literal: {
83-
const literal = node as LiteralNode
84-
return (literal.v || literal.value) as MessageType<Message>
121+
return resolveValue<Message>(node as NodeValue<Message>, type)
85122
}
86123
case NodeTypes.Named: {
87124
const named = node as NamedNode
88-
return ctx.interpolate(ctx.named(named.k || named.key))
125+
if (hasOwn(named, 'k') && named.k) {
126+
return ctx.interpolate(ctx.named(named.k))
127+
}
128+
if (hasOwn(named, 'key') && named.key) {
129+
return ctx.interpolate(ctx.named(named.key))
130+
}
131+
throw createUnhandleNodeError(type)
89132
}
90133
case NodeTypes.List: {
91134
const list = node as ListNode
92-
return ctx.interpolate(ctx.list(list.i != null ? list.i : list.index))
135+
if (hasOwn(list, 'i') && isNumber(list.i)) {
136+
return ctx.interpolate(ctx.list(list.i))
137+
}
138+
if (hasOwn(list, 'index') && isNumber(list.index)) {
139+
return ctx.interpolate(ctx.list(list.index))
140+
}
141+
throw createUnhandleNodeError(type)
93142
}
94143
case NodeTypes.Linked: {
95144
const linked = node as LinkedNode
96-
const modifier = linked.m || linked.modifier
145+
const modifier = resolveLinkedModifier(linked)
146+
const key = resolveLinkedKey(linked)
97147
return ctx.linked(
98-
formatMessagePart(ctx, linked.k || linked.key) as string,
148+
formatMessagePart(ctx, key!) as string,
99149
modifier ? (formatMessagePart(ctx, modifier) as string) : undefined,
100150
ctx.type
101151
)
102152
}
103153
case NodeTypes.LinkedKey: {
104-
const linkedKey = node as LinkedKeyNode
105-
return (linkedKey.v || linkedKey.value) as MessageType<Message>
154+
return resolveValue<Message>(node as NodeValue<Message>, type)
106155
}
107156
case NodeTypes.LinkedModifier: {
108-
const linkedModifier = node as LinkedModifierNode
109-
return (linkedModifier.v || linkedModifier.value) as MessageType<Message>
157+
return resolveValue<Message>(node as NodeValue<Message>, type)
110158
}
111159
default:
112-
throw new Error(`unhandled node type on format message part: ${type}`)
160+
throw new Error(`unhandled node on format message part: ${type}`)
161+
}
162+
}
163+
164+
const PROPS_TYPE = ['t', 'type']
165+
166+
export function resolveType(node: Node) {
167+
return resolveProps<NodeTypes>(node, PROPS_TYPE)
168+
}
169+
170+
const PROPS_VALUE = ['v', 'value']
171+
172+
function resolveValue<Message = string>(
173+
node: { v?: MessageType<Message>; value?: MessageType<Message> },
174+
type: NodeTypes
175+
): MessageType<Message> {
176+
const resolved = resolveProps<Message>(
177+
node as Node,
178+
PROPS_VALUE
179+
) as MessageType<Message>
180+
if (resolved) {
181+
return resolved
182+
} else {
183+
throw createUnhandleNodeError(type)
184+
}
185+
}
186+
187+
const PROPS_MODIFIER = ['m', 'modifier']
188+
189+
function resolveLinkedModifier(node: LinkedNode) {
190+
return resolveProps<LinkedModifierNode>(node, PROPS_MODIFIER)
191+
}
192+
193+
const PROPS_KEY = ['k', 'key']
194+
195+
function resolveLinkedKey(node: LinkedNode) {
196+
const resolved = resolveProps<LinkedNode['key']>(node, PROPS_KEY)
197+
if (resolved) {
198+
return resolved
199+
} else {
200+
throw createUnhandleNodeError(NodeTypes.Linked)
113201
}
114202
}
203+
204+
function resolveProps<T = string, Default = undefined>(
205+
node: Node,
206+
props: string[],
207+
defaultValue?: Default
208+
): T | Default {
209+
for (let i = 0; i < props.length; i++) {
210+
const prop = props[i]
211+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
212+
if (hasOwn(node, prop) && (node as any)[prop] != null) {
213+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
214+
return (node as any)[prop] as T
215+
}
216+
}
217+
return defaultValue as Default
218+
}
219+
220+
function createUnhandleNodeError(type: NodeTypes) {
221+
return new Error(`unhandled node type: ${type}`)
222+
}

0 commit comments

Comments
 (0)