From 72f0d323006fc7363b18cab62d4522dadd874411 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 28 Nov 2024 13:17:29 +0900 Subject: [PATCH] Merge commit from fork * fix: XSS vulnerability with prototype pollution on AST * test: add e2e test for scurity fix * fix: update e2e * fix: filename * fix: change type name --- e2e/hotfix.spec.ts | 11 + e2e/hotfix/CVE-2024-52809.html | 69 +++ packages/core-base/src/compilation.ts | 23 +- packages/core-base/src/errors.ts | 4 +- packages/core-base/src/format.ts | 174 ++++++-- packages/core-base/test/format.test.ts | 474 ++++++++++++++++++++- packages/core-base/test/issues.test.ts | 58 +++ packages/vue-i18n-core/test/issues.test.ts | 70 ++- 8 files changed, 839 insertions(+), 44 deletions(-) create mode 100644 e2e/hotfix.spec.ts create mode 100644 e2e/hotfix/CVE-2024-52809.html create mode 100644 packages/core-base/test/issues.test.ts diff --git a/e2e/hotfix.spec.ts b/e2e/hotfix.spec.ts new file mode 100644 index 000000000..b14680764 --- /dev/null +++ b/e2e/hotfix.spec.ts @@ -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!') + }) +}) diff --git a/e2e/hotfix/CVE-2024-52809.html b/e2e/hotfix/CVE-2024-52809.html new file mode 100644 index 000000000..c2e6c3cbf --- /dev/null +++ b/e2e/hotfix/CVE-2024-52809.html @@ -0,0 +1,69 @@ + + + + + vue-i18n XSS + + + + + + +
+

{{ t('hello') }}

+
+ + + diff --git a/packages/core-base/src/compilation.ts b/packages/core-base/src/compilation.ts index 8a0f83707..9b809e1f3 100644 --- a/packages/core-base/src/compilation.ts +++ b/packages/core-base/src/compilation.ts @@ -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' @@ -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, diff --git a/packages/core-base/src/errors.ts b/packages/core-base/src/errors.ts index f09dc9adb..d63ed43f9 100644 --- a/packages/core-base/src/errors.ts +++ b/packages/core-base/src/errors.ts @@ -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' diff --git a/packages/core-base/src/format.ts b/packages/core-base/src/format.ts index 947ceff7f..0c2804620 100644 --- a/packages/core-base/src/format.ts +++ b/packages/core-base/src/format.ts @@ -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( @@ -28,14 +26,18 @@ export function format( return msg } -function formatParts( +export function formatParts( ctx: MessageContext, ast: ResourceNode ): MessageFunctionReturn { - 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) => @@ -51,17 +53,33 @@ function formatParts( } } -function formatMessageParts( +const PROPS_BODY = ['b', 'body'] + +function resolveBody(node: ResourceNode) { + return resolveProps(node, PROPS_BODY) +} + +const PROPS_CASES = ['c', 'cases'] + +function resolveCases(node: PluralNode) { + return resolveProps( + node, + PROPS_CASES, + [] + ) +} + +export function formatMessageParts( ctx: MessageContext, node: MessageNode ): MessageFunctionReturn { - const _static = node.s || node.static - if (_static != null) { + const static_ = resolveStatic(node) + if (static_ != null) { return ctx.type === 'text' - ? (_static as MessageFunctionReturn) - : ctx.normalize([_static] as MessageType[]) + ? (static_ as MessageFunctionReturn) + : ctx.normalize([static_] as MessageType[]) } else { - const messages = (node.i || node.items).reduce( + const messages = resolveItems(node).reduce( (acm, c) => [...acm, formatMessagePart(ctx, c)], [] as MessageType[] ) @@ -69,46 +87,136 @@ function formatMessageParts( } } -function formatMessagePart( +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( + node, + PROPS_ITEMS, + [] + ) +} + +type NodeValue = { + v?: MessageType + value?: MessageType +} + +export function formatMessagePart( ctx: MessageContext, node: Node ): MessageType { - 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 + return resolveValue(node as NodeValue, type) } case NodeTypes.Literal: { - const literal = node as LiteralNode - return (literal.v || literal.value) as MessageType + return resolveValue(node as NodeValue, 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 + return resolveValue(node as NodeValue, type) } case NodeTypes.LinkedModifier: { - const linkedModifier = node as LinkedModifierNode - return (linkedModifier.v || linkedModifier.value) as MessageType + return resolveValue(node as NodeValue, 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(node, PROPS_TYPE) +} + +const PROPS_VALUE = ['v', 'value'] + +function resolveValue( + node: { v?: MessageType; value?: MessageType }, + type: NodeTypes +): MessageType { + const resolved = resolveProps( + node as Node, + PROPS_VALUE + ) as MessageType + if (resolved) { + return resolved + } else { + throw createUnhandleNodeError(type) + } +} + +const PROPS_MODIFIER = ['m', 'modifier'] + +function resolveLinkedModifier(node: LinkedNode) { + return resolveProps(node, PROPS_MODIFIER) +} + +const PROPS_KEY = ['k', 'key'] + +function resolveLinkedKey(node: LinkedNode) { + const resolved = resolveProps(node, PROPS_KEY) + if (resolved) { + return resolved + } else { + throw createUnhandleNodeError(NodeTypes.Linked) } } + +function resolveProps( + 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}`) +} diff --git a/packages/core-base/test/format.test.ts b/packages/core-base/test/format.test.ts index aa6794262..3709a1282 100644 --- a/packages/core-base/test/format.test.ts +++ b/packages/core-base/test/format.test.ts @@ -1,7 +1,24 @@ -import { baseCompile as compile } from '@intlify/message-compiler' -import { format } from '../src/format' +import { baseCompile as compile, NodeTypes } from '@intlify/message-compiler' +import { + format, + formatMessagePart, + formatMessageParts, + formatParts +} from '../src/format' import { createMessageContext as context } from '../src/runtime' +import type { + LinkedKeyNode, + LinkedModifierNode, + LinkedNode, + ListNode, + LiteralNode, + MessageNode, + NamedNode, + ResourceNode, + TextNode +} from '@intlify/message-compiler' + describe('features', () => { test('text: hello world', () => { const { ast } = compile('hello world', { jit: true }) @@ -143,3 +160,456 @@ describe('edge cases', () => { expect(msg(ctx)).toBe('') }) }) + +describe('formatParts', () => { + test('prop: body', () => { + const node: ResourceNode = { + type: NodeTypes.Resource, + body: { + type: NodeTypes.Message, + items: [ + { + type: NodeTypes.Text, + value: 'hello world' + } + ] + } + } + + const ctx = context() + expect(formatParts(ctx, node)).toBe('hello world') + }) + + test('prop: b', () => { + const node: ResourceNode = { + type: NodeTypes.Resource, + body: { + type: NodeTypes.Message, + items: [ + { + type: NodeTypes.Text, + value: 'hello world' + } + ] + } + } + + const ctx = context() + expect(formatParts(ctx, node)).toBe('hello world') + }) + + test(`body has plural prop cases`, () => { + const node: ResourceNode = { + type: NodeTypes.Resource, + body: { + type: NodeTypes.Plural, + cases: [ + { + type: NodeTypes.Message, + items: [ + { + type: NodeTypes.Text, + value: 'hello' + } + ] + }, + { + type: NodeTypes.Message, + items: [ + { + type: NodeTypes.Text, + value: 'world' + } + ] + } + ] + } + } + + const ctx = context({ + pluralIndex: 2 + }) + expect(formatParts(ctx, node)).toBe('world') + }) + + test(`body has plural prop c`, () => { + const node: ResourceNode = { + type: NodeTypes.Resource, + // @ts-ignore + body: { + type: NodeTypes.Plural, + c: [ + { + type: NodeTypes.Message, + items: [ + { + type: NodeTypes.Text, + value: 'hello' + } + ] + }, + { + type: NodeTypes.Message, + items: [ + { + type: NodeTypes.Text, + value: 'world' + } + ] + } + ] + } + } + + const ctx = context({ + pluralIndex: 1 + }) + expect(formatParts(ctx, node)).toBe('hello') + }) + + test('not found prop body', () => { + // @ts-ignore + const node: ResourceNode = { + type: NodeTypes.Resource + } + + const ctx = context() + expect(() => formatParts(ctx, node)).toThrow( + `unhandled node type: ${NodeTypes.Resource}` + ) + }) +}) + +describe('formatMessageParts', () => { + test('prop: static', () => { + const node: MessageNode = { + type: NodeTypes.Message, + static: 'hello world', + items: [] + } + const ctx = context() + expect(formatMessageParts(ctx, node)).toBe('hello world') + }) + + test('prop: s', () => { + const node: MessageNode = { + type: NodeTypes.Message, + s: 'hello world', + items: [] + } + const ctx = context() + expect(formatMessageParts(ctx, node)).toBe('hello world') + }) + + test('prop: items', () => { + const node: MessageNode = { + type: NodeTypes.Message, + items: [ + { + type: NodeTypes.Text, + value: 'hello' + }, + { + type: NodeTypes.Text, + value: 'world' + } + ] + } + const ctx = context() + expect(formatMessageParts(ctx, node)).toEqual('helloworld') + }) + + test('prop: i', () => { + // @ts-ignore + const node: MessageNode = { + type: NodeTypes.Message, + i: [ + { + type: NodeTypes.Text, + value: 'hello' + }, + { + type: NodeTypes.Text, + value: 'world' + } + ] + } + const ctx = context() + expect(formatMessageParts(ctx, node)).toEqual('helloworld') + }) +}) + +describe('formatMessagePart', () => { + describe('text node', () => { + test('prop: value', () => { + const node: TextNode = { + type: NodeTypes.Text, + value: 'hello world' + } + const ctx = context() + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test('prop: v', () => { + const node: TextNode = { + type: NodeTypes.Text, + v: 'hello world' + } + const ctx = context() + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test(`prop 'value' and 'v' not found`, () => { + const node: TextNode = { + type: NodeTypes.Text + } + const ctx = context() + expect(() => formatMessagePart(ctx, node)).toThrow( + `unhandled node type: ${NodeTypes.Text}` + ) + }) + }) + + describe('literal node', () => { + test('prop: value', () => { + const node: LiteralNode = { + type: NodeTypes.Literal, + value: 'hello world' + } + const ctx = context() + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test('prop: v', () => { + const node: LiteralNode = { + type: NodeTypes.Literal, + v: 'hello world' + } + const ctx = context() + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test(`prop 'value' and 'v' not found`, () => { + const node: LiteralNode = { + type: NodeTypes.Literal + } + const ctx = context() + expect(() => formatMessagePart(ctx, node)).toThrow( + `unhandled node type: ${NodeTypes.Literal}` + ) + }) + }) + + describe('named node', () => { + test('prop: key', () => { + const node: NamedNode = { + type: NodeTypes.Named, + key: 'key' + } + const ctx = context({ + named: { key: 'hello world' } + }) + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test('prop: k', () => { + // @ts-ignore + const node: NamedNode = { + type: NodeTypes.Named, + k: 'key' + } + const ctx = context({ + named: { key: 'hello world' } + }) + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test(`prop 'key' and 'k' not found`, () => { + // @ts-ignore + const node: NamedNode = { + type: NodeTypes.Named + } + const ctx = context() + expect(() => formatMessagePart(ctx, node)).toThrow( + `unhandled node type: ${NodeTypes.Named}` + ) + }) + }) + + describe('list node', () => { + test('prop: index', () => { + const node: ListNode = { + type: NodeTypes.List, + index: 0 + } + const ctx = context({ + list: ['hello world'] + }) + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test('prop: i', () => { + // @ts-ignore + const node: ListNode = { + type: NodeTypes.List, + i: 0 + } + const ctx = context({ + list: ['hello world'] + }) + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test(`prop 'index' and 'i' not found`, () => { + // @ts-ignore + const node: ListNode = { + type: NodeTypes.List + } + const ctx = context() + expect(() => formatMessagePart(ctx, node)).toThrow( + `unhandled node type: ${NodeTypes.List}` + ) + }) + }) + + describe('linked key node', () => { + test('prop: value', () => { + const node: LinkedKeyNode = { + type: NodeTypes.LinkedKey, + value: 'hello world' + } + const ctx = context() + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test('prop: v', () => { + // @ts-ignore + const node: LinkedKeyNode = { + type: NodeTypes.LinkedKey, + v: 'hello world' + } + const ctx = context() + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test(`prop 'value' and 'v' not found`, () => { + // @ts-ignore + const node: LinkedKeyNode = { + type: NodeTypes.LinkedKey + } + const ctx = context() + expect(() => formatMessagePart(ctx, node)).toThrow( + `unhandled node type: ${NodeTypes.LinkedKey}` + ) + }) + }) + + describe('linked modifier node', () => { + test('prop: value', () => { + const node: LinkedModifierNode = { + type: NodeTypes.LinkedModifier, + value: 'hello world' + } + const ctx = context() + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test('prop: v', () => { + // @ts-ignore + const node: LinkedModifierNode = { + type: NodeTypes.LinkedModifier, + v: 'hello world' + } + const ctx = context() + expect(formatMessagePart(ctx, node)).toBe('hello world') + }) + + test(`prop 'value' and 'v' not found`, () => { + // @ts-ignore + const node: LinkedModifierNode = { + type: NodeTypes.LinkedModifier + } + const ctx = context() + expect(() => formatMessagePart(ctx, node)).toThrow( + `unhandled node type: ${NodeTypes.LinkedModifier}` + ) + }) + }) + + describe('linked node', () => { + test('prop: modifier, key', () => { + const node: LinkedNode = { + type: NodeTypes.Linked, + modifier: { + type: NodeTypes.LinkedModifier, + value: 'upper' + }, + key: { + type: NodeTypes.LinkedKey, + value: 'name' + } + } + const ctx = context({ + modifiers: { + upper: (val: string) => val.toUpperCase() + }, + messages: { + name: () => 'kazupon' + } + }) + expect(formatMessagePart(ctx, node)).toBe('KAZUPON') + }) + + test('prop: m, k', () => { + // @ts-ignore + const node: LinkedNode = { + type: NodeTypes.Linked, + m: { + type: NodeTypes.LinkedModifier, + value: 'upper' + }, + k: { + type: NodeTypes.LinkedKey, + value: 'name' + } + } + const ctx = context({ + modifiers: { + upper: (val: string) => val.toUpperCase() + }, + messages: { + name: () => 'kazupon' + } + }) + expect(formatMessagePart(ctx, node)).toBe('KAZUPON') + }) + + test(`prop 'key' not found`, () => { + // @ts-ignore + const node: LinkedNode = { + type: NodeTypes.Linked + } + const ctx = context({ + modifiers: { + upper: (val: string) => val.toUpperCase() + }, + messages: { + name: () => 'kazupon' + } + }) + expect(() => formatMessagePart(ctx, node)).toThrow( + `unhandled node type: ${NodeTypes.Linked}` + ) + }) + }) + + test('unhandled node', () => { + const node = { + type: -1 + } + const ctx = context() + expect(() => formatMessagePart(ctx, node)).toThrow( + `unhandled node on format message part: -1` + ) + }) +}) diff --git a/packages/core-base/test/issues.test.ts b/packages/core-base/test/issues.test.ts new file mode 100644 index 000000000..1a39aa0f5 --- /dev/null +++ b/packages/core-base/test/issues.test.ts @@ -0,0 +1,58 @@ +import { format } from '../src/format' +import { createMessageContext as context } from '../src/runtime' + +import { NodeTypes, ResourceNode } from '@intlify/message-compiler' + +describe('CVE-2024-52809', () => { + function attackGetter() { + return 'polluted' + } + + afterEach(() => { + // @ts-ignore -- initialize polluted property + delete Object.prototype.static + }) + + test('success', () => { + Object.defineProperty(Object.prototype, 'static', { + configurable: true, + get: attackGetter + }) + const ast: ResourceNode = { + type: NodeTypes.Resource, + body: { + type: NodeTypes.Message, + static: 'hello world', + items: [ + { + type: NodeTypes.Text + } + ] + } + } + const msg = format(ast) + const ctx = context() + expect(msg(ctx)).toEqual('hello world') + }) + + test('error', () => { + Object.defineProperty(Object.prototype, 'static', { + configurable: true, + get: attackGetter + }) + const ast: ResourceNode = { + type: NodeTypes.Resource, + body: { + type: NodeTypes.Message, + items: [ + { + type: NodeTypes.Text + } + ] + } + } + const msg = format(ast) + const ctx = context() + expect(() => msg(ctx)).toThrow(`unhandled node type: ${NodeTypes.Text}`) + }) +}) diff --git a/packages/vue-i18n-core/test/issues.test.ts b/packages/vue-i18n-core/test/issues.test.ts index 552aa9dcb..8ca2c22d1 100644 --- a/packages/vue-i18n-core/test/issues.test.ts +++ b/packages/vue-i18n-core/test/issues.test.ts @@ -1406,7 +1406,7 @@ test('#1912', async () => { expect(el?.innerHTML).include(`No apples found`) }) -test('#1972', async () => { +test('#1972', () => { const i18n = createI18n({ legacy: false, locale: 'en', @@ -1418,3 +1418,71 @@ test('#1972', async () => { }) expect(i18n.global.t('test', 0)).toEqual('') }) + +describe('CVE-2024-52809', () => { + function attackGetter() { + return 'polluted' + } + + afterEach(() => { + // @ts-ignore -- initialize polluted property + delete Object.prototype.static + }) + + test('success', () => { + Object.defineProperty(Object.prototype, 'static', { + configurable: true, + get: attackGetter + }) + const en = { + hello: { + type: 0, + body: { + type: 2, + static: 'hello world', + items: [ + { + type: 3 + } + ] + } + } + } + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en + } + }) + expect(i18n.global.t('hello')).toEqual('hello world') + }) + + test('error', () => { + Object.defineProperty(Object.prototype, 'static', { + configurable: true, + get: attackGetter + }) + const en = { + hello: { + type: 0, + body: { + type: 2, + items: [ + { + type: 3 + } + ] + } + } + } + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en + } + }) + expect(() => i18n.global.t('hello')).toThrow(`unhandled node type: 3`) + }) +})