Skip to content

Commit

Permalink
Merge commit from fork
Browse files Browse the repository at this point in the history
* fix: XSS vulnerability with prototype pollution on AST

* test: add e2e test for scurity fix

* fix: update e2e

* fix: filename

* fix: change type name
  • Loading branch information
kazupon authored Nov 28, 2024
1 parent 554cc24 commit 72f0d32
Show file tree
Hide file tree
Showing 8 changed files with 839 additions and 44 deletions.
11 changes: 11 additions & 0 deletions e2e/hotfix.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getText } from './helper'

describe('CVE-2024-52809', () => {
beforeAll(async () => {
await page.goto(`http://localhost:8080/e2e/hotfix/CVE-2024-52809.html`)
})

test('fix', async () => {
expect(await getText(page, 'p')).toMatch('hello world!')
})
})
69 changes: 69 additions & 0 deletions e2e/hotfix/CVE-2024-52809.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>vue-i18n XSS</title>
<script src="../../node_modules/vue/dist/vue.global.js"></script>
<script src="../../packages/vue-i18n/dist/vue-i18n.global.js"></script>
<!-- Scripts that perform prototype contamination, such as being distributed from malicious hosting sites or injected through supply chain attacks, etc. -->
<script>
/**
* Prototype pollution vulnerability with `Object.prototype`.
* The 'static' property is part of the optimized AST generated by the vue-i18n message compiler.
* About details of special properties, see https://github.com/intlify/vue-i18n/blob/master/packages/message-compiler/src/nodes.ts
*
* In general, the locale messages of vue-i18n are optimized during production builds using `@intlify/unplugin-vue-i18n`,
* so there is always a property that is attached during optimization like this time.
* 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.
*/
Object.defineProperty(Object.prototype, 'static', {
configurable: true,
get() {
alert('prototype polluted!')
return 'prototype pollution'
}
})
</script>
</head>
<body>
<div id="app">
<p>{{ t('hello') }}</p>
</div>
<script>
const { createApp } = Vue
const { createI18n, useI18n } = VueI18n

// AST style locale message, which build by `@intlify/unplugin-vue-i18n`
const en = {
hello: {
type: 0,
body: {
items: [
{
type: 3,
value: 'hello world!'
}
]
}
}
}

const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en
}
})

const app = createApp({
setup() {
const { t } = useI18n()
return { t }
}
})
app.use(i18n)
app.mount('#app')
</script>
</body>
</html>
23 changes: 17 additions & 6 deletions packages/core-base/src/compilation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ import {
defaultOnError,
detectHtmlTag
} from '@intlify/message-compiler'
import { format, isBoolean, isObject, isString, warn } from '@intlify/shared'
import { format as formatMessage } from './format'
import {
format,
hasOwn,
isBoolean,
isObject,
isString,
warn
} from '@intlify/shared'
import { format as formatMessage, resolveType } from './format'

import type {
CompileError,
CompileOptions,
CompilerResult,
Node,
ResourceNode
} from '@intlify/message-compiler'
import type { MessageCompilerContext } from './context'
Expand All @@ -30,10 +38,13 @@ export function clearCompileCache(): void {
compileCache = Object.create(null)
}

export const isMessageAST = (val: unknown): val is ResourceNode =>
isObject(val) &&
(val.t === 0 || val.type === 0) &&
('b' in val || 'body' in val)
export function isMessageAST(val: unknown): val is ResourceNode {
return (
isObject(val) &&
resolveType(val as Node) === 0 &&
(hasOwn(val, 'b') || hasOwn(val, 'body'))
)
}

function baseCompile(
message: string,
Expand Down
4 changes: 2 additions & 2 deletions packages/core-base/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
createCompileError,
COMPILE_ERROR_CODES_EXTEND_POINT
COMPILE_ERROR_CODES_EXTEND_POINT,
createCompileError
} from '@intlify/message-compiler'

import type { BaseError } from '@intlify/shared'
Expand Down
174 changes: 141 additions & 33 deletions packages/core-base/src/format.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import { NodeTypes } from '@intlify/message-compiler'
import { hasOwn, isNumber } from '@intlify/shared'

import type {
Node,
TextNode,
LiteralNode,
LinkedModifierNode,
LinkedNode,
ListNode,
MessageNode,
NamedNode,
LinkedNode,
LinkedKeyNode,
LinkedModifierNode,
Node,
PluralNode,
ResourceNode
} from '@intlify/message-compiler'
import type {
MessageContext,
MessageFunction,
MessageType,
MessageFunctionReturn
MessageFunctionReturn,
MessageType
} from './runtime'

export function format<Message = string>(
Expand All @@ -28,14 +26,18 @@ export function format<Message = string>(
return msg
}

function formatParts<Message = string>(
export function formatParts<Message = string>(
ctx: MessageContext<Message>,
ast: ResourceNode
): MessageFunctionReturn<Message> {
const body = ast.b || ast.body
if ((body.t || body.type) === NodeTypes.Plural) {
const body = resolveBody(ast)
if (body == null) {
throw createUnhandleNodeError(NodeTypes.Resource)
}
const type = resolveType(body)
if (type === NodeTypes.Plural) {
const plural = body as PluralNode
const cases = plural.c || plural.cases
const cases = resolveCases(plural)
return ctx.plural(
cases.reduce(
(messages, c) =>
Expand All @@ -51,64 +53,170 @@ function formatParts<Message = string>(
}
}

function formatMessageParts<Message = string>(
const PROPS_BODY = ['b', 'body']

function resolveBody(node: ResourceNode) {
return resolveProps<MessageNode | PluralNode>(node, PROPS_BODY)
}

const PROPS_CASES = ['c', 'cases']

function resolveCases(node: PluralNode) {
return resolveProps<PluralNode['cases'], PluralNode['cases']>(
node,
PROPS_CASES,
[]
)
}

export function formatMessageParts<Message = string>(
ctx: MessageContext<Message>,
node: MessageNode
): MessageFunctionReturn<Message> {
const _static = node.s || node.static
if (_static != null) {
const static_ = resolveStatic(node)
if (static_ != null) {
return ctx.type === 'text'
? (_static as MessageFunctionReturn<Message>)
: ctx.normalize([_static] as MessageType<Message>[])
? (static_ as MessageFunctionReturn<Message>)
: ctx.normalize([static_] as MessageType<Message>[])
} else {
const messages = (node.i || node.items).reduce(
const messages = resolveItems(node).reduce(
(acm, c) => [...acm, formatMessagePart(ctx, c)],
[] as MessageType<Message>[]
)
return ctx.normalize(messages) as MessageFunctionReturn<Message>
}
}

function formatMessagePart<Message = string>(
const PROPS_STATIC = ['s', 'static']

function resolveStatic(node: MessageNode) {
return resolveProps(node, PROPS_STATIC)
}

const PROPS_ITEMS = ['i', 'items']

function resolveItems(node: MessageNode) {
return resolveProps<MessageNode['items'], MessageNode['items']>(
node,
PROPS_ITEMS,
[]
)
}

type NodeValue<Message> = {
v?: MessageType<Message>
value?: MessageType<Message>
}

export function formatMessagePart<Message = string>(
ctx: MessageContext<Message>,
node: Node
): MessageType<Message> {
const type = node.t || node.type
const type = resolveType(node)
switch (type) {
case NodeTypes.Text: {
const text = node as TextNode
return (text.v || text.value) as MessageType<Message>
return resolveValue<Message>(node as NodeValue<Message>, type)
}
case NodeTypes.Literal: {
const literal = node as LiteralNode
return (literal.v || literal.value) as MessageType<Message>
return resolveValue<Message>(node as NodeValue<Message>, type)
}
case NodeTypes.Named: {
const named = node as NamedNode
return ctx.interpolate(ctx.named(named.k || named.key))
if (hasOwn(named, 'k') && named.k) {
return ctx.interpolate(ctx.named(named.k))
}
if (hasOwn(named, 'key') && named.key) {
return ctx.interpolate(ctx.named(named.key))
}
throw createUnhandleNodeError(type)
}
case NodeTypes.List: {
const list = node as ListNode
return ctx.interpolate(ctx.list(list.i != null ? list.i : list.index))
if (hasOwn(list, 'i') && isNumber(list.i)) {
return ctx.interpolate(ctx.list(list.i))
}
if (hasOwn(list, 'index') && isNumber(list.index)) {
return ctx.interpolate(ctx.list(list.index))
}
throw createUnhandleNodeError(type)
}
case NodeTypes.Linked: {
const linked = node as LinkedNode
const modifier = linked.m || linked.modifier
const modifier = resolveLinkedModifier(linked)
const key = resolveLinkedKey(linked)
return ctx.linked(
formatMessagePart(ctx, linked.k || linked.key) as string,
formatMessagePart(ctx, key!) as string,
modifier ? (formatMessagePart(ctx, modifier) as string) : undefined,
ctx.type
)
}
case NodeTypes.LinkedKey: {
const linkedKey = node as LinkedKeyNode
return (linkedKey.v || linkedKey.value) as MessageType<Message>
return resolveValue<Message>(node as NodeValue<Message>, type)
}
case NodeTypes.LinkedModifier: {
const linkedModifier = node as LinkedModifierNode
return (linkedModifier.v || linkedModifier.value) as MessageType<Message>
return resolveValue<Message>(node as NodeValue<Message>, type)
}
default:
throw new Error(`unhandled node type on format message part: ${type}`)
throw new Error(`unhandled node on format message part: ${type}`)
}
}

const PROPS_TYPE = ['t', 'type']

export function resolveType(node: Node) {
return resolveProps<NodeTypes>(node, PROPS_TYPE)
}

const PROPS_VALUE = ['v', 'value']

function resolveValue<Message = string>(
node: { v?: MessageType<Message>; value?: MessageType<Message> },
type: NodeTypes
): MessageType<Message> {
const resolved = resolveProps<Message>(
node as Node,
PROPS_VALUE
) as MessageType<Message>
if (resolved) {
return resolved
} else {
throw createUnhandleNodeError(type)
}
}

const PROPS_MODIFIER = ['m', 'modifier']

function resolveLinkedModifier(node: LinkedNode) {
return resolveProps<LinkedModifierNode>(node, PROPS_MODIFIER)
}

const PROPS_KEY = ['k', 'key']

function resolveLinkedKey(node: LinkedNode) {
const resolved = resolveProps<LinkedNode['key']>(node, PROPS_KEY)
if (resolved) {
return resolved
} else {
throw createUnhandleNodeError(NodeTypes.Linked)
}
}

function resolveProps<T = string, Default = undefined>(
node: Node,
props: string[],
defaultValue?: Default
): T | Default {
for (let i = 0; i < props.length; i++) {
const prop = props[i]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (hasOwn(node, prop) && (node as any)[prop] != null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (node as any)[prop] as T
}
}
return defaultValue as Default
}

function createUnhandleNodeError(type: NodeTypes) {
return new Error(`unhandled node type: ${type}`)
}
Loading

0 comments on commit 72f0d32

Please sign in to comment.