Skip to content

Commit 8b7c162

Browse files
committed
feat(compiler-dom): handle constant expressions when stringifying static content
1 parent 1389d7b commit 8b7c162

File tree

4 files changed

+184
-16
lines changed

4 files changed

+184
-16
lines changed

packages/compiler-core/src/transforms/transformExpression.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export function processExpression(
9090

9191
// fast path if expression is a simple identifier.
9292
const rawExp = node.content
93+
// bail on parens to prevent any possible function invocations.
94+
const bailConstant = rawExp.indexOf(`(`) > -1
9395
if (isSimpleIdentifier(rawExp)) {
9496
if (
9597
!asParams &&
@@ -98,7 +100,7 @@ export function processExpression(
98100
!isLiteralWhitelisted(rawExp)
99101
) {
100102
node.content = `_ctx.${rawExp}`
101-
} else if (!context.identifiers[rawExp]) {
103+
} else if (!context.identifiers[rawExp] && !bailConstant) {
102104
// mark node constant for hoisting unless it's referring a scope variable
103105
node.isConstant = true
104106
}
@@ -139,12 +141,13 @@ export function processExpression(
139141
node.prefix = `${node.name}: `
140142
}
141143
node.name = `_ctx.${node.name}`
142-
node.isConstant = false
143144
ids.push(node)
144145
} else if (!isStaticPropertyKey(node, parent)) {
145146
// The identifier is considered constant unless it's pointing to a
146147
// scope variable (a v-for alias, or a v-slot prop)
147-
node.isConstant = !(needPrefix && knownIds[node.name])
148+
if (!(needPrefix && knownIds[node.name]) && !bailConstant) {
149+
node.isConstant = true
150+
}
148151
// also generate sub-expressions for other identifiers for better
149152
// source map support. (except for property keys which are static)
150153
ids.push(node)
@@ -234,7 +237,7 @@ export function processExpression(
234237
ret = createCompoundExpression(children, node.loc)
235238
} else {
236239
ret = node
237-
ret.isConstant = true
240+
ret.isConstant = !bailConstant
238241
}
239242
ret.identifiers = Object.keys(knownIds)
240243
return ret
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { compile, NodeTypes, CREATE_STATIC } from '../../src'
2+
import {
3+
stringifyStatic,
4+
StringifyThresholds
5+
} from '../../src/transforms/stringifyStatic'
6+
7+
describe('stringify static html', () => {
8+
function compileWithStringify(template: string) {
9+
return compile(template, {
10+
hoistStatic: true,
11+
prefixIdentifiers: true,
12+
transformHoist: stringifyStatic
13+
})
14+
}
15+
16+
function repeat(code: string, n: number): string {
17+
return new Array(n)
18+
.fill(0)
19+
.map(() => code)
20+
.join('')
21+
}
22+
23+
test('should bail on non-eligible static trees', () => {
24+
const { ast } = compileWithStringify(
25+
`<div><div><div>hello</div><div>hello</div></div></div>`
26+
)
27+
expect(ast.hoists.length).toBe(1)
28+
// should be a normal vnode call
29+
expect(ast.hoists[0].type).toBe(NodeTypes.VNODE_CALL)
30+
})
31+
32+
test('should work on eligible content (elements with binding > 5)', () => {
33+
const { ast } = compileWithStringify(
34+
`<div><div>${repeat(
35+
`<span class="foo"/>`,
36+
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
37+
)}</div></div>`
38+
)
39+
expect(ast.hoists.length).toBe(1)
40+
// should be optimized now
41+
expect(ast.hoists[0]).toMatchObject({
42+
type: NodeTypes.JS_CALL_EXPRESSION,
43+
callee: CREATE_STATIC,
44+
arguments: [
45+
JSON.stringify(
46+
`<div>${repeat(
47+
`<span class="foo"></span>`,
48+
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
49+
)}</div>`
50+
)
51+
]
52+
})
53+
})
54+
55+
test('should work on eligible content (elements > 20)', () => {
56+
const { ast } = compileWithStringify(
57+
`<div><div>${repeat(
58+
`<span/>`,
59+
StringifyThresholds.NODE_COUNT
60+
)}</div></div>`
61+
)
62+
expect(ast.hoists.length).toBe(1)
63+
// should be optimized now
64+
expect(ast.hoists[0]).toMatchObject({
65+
type: NodeTypes.JS_CALL_EXPRESSION,
66+
callee: CREATE_STATIC,
67+
arguments: [
68+
JSON.stringify(
69+
`<div>${repeat(
70+
`<span></span>`,
71+
StringifyThresholds.NODE_COUNT
72+
)}</div>`
73+
)
74+
]
75+
})
76+
})
77+
78+
test('serliazing constant bindings', () => {
79+
const { ast } = compileWithStringify(
80+
`<div><div>${repeat(
81+
`<span :class="'foo' + 'bar'">{{ 1 }} + {{ false }}</span>`,
82+
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
83+
)}</div></div>`
84+
)
85+
expect(ast.hoists.length).toBe(1)
86+
// should be optimized now
87+
expect(ast.hoists[0]).toMatchObject({
88+
type: NodeTypes.JS_CALL_EXPRESSION,
89+
callee: CREATE_STATIC,
90+
arguments: [
91+
JSON.stringify(
92+
`<div>${repeat(
93+
`<span class="foobar">1 + false</span>`,
94+
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
95+
)}</div>`
96+
)
97+
]
98+
})
99+
})
100+
101+
test('escape', () => {
102+
const { ast } = compileWithStringify(
103+
`<div><div>${repeat(
104+
`<span :class="'foo' + '&gt;ar'">{{ 1 }} + {{ '<' }}</span>` +
105+
`<span>&amp;</span>`,
106+
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
107+
)}</div></div>`
108+
)
109+
expect(ast.hoists.length).toBe(1)
110+
// should be optimized now
111+
expect(ast.hoists[0]).toMatchObject({
112+
type: NodeTypes.JS_CALL_EXPRESSION,
113+
callee: CREATE_STATIC,
114+
arguments: [
115+
JSON.stringify(
116+
`<div>${repeat(
117+
`<span class="foo&gt;ar">1 + &lt;</span>` + `<span>&amp;</span>`,
118+
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
119+
)}</div>`
120+
)
121+
]
122+
})
123+
})
124+
})

packages/compiler-dom/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { transformModel } from './transforms/vModel'
1818
import { transformOn } from './transforms/vOn'
1919
import { transformShow } from './transforms/vShow'
2020
import { warnTransitionChildren } from './transforms/warnTransitionChildren'
21-
import { stringifyStatic } from './stringifyStatic'
21+
import { stringifyStatic } from './transforms/stringifyStatic'
2222

2323
export const parserOptions = __BROWSER__
2424
? parserOptionsMinimal

packages/compiler-dom/src/stringifyStatic.ts renamed to packages/compiler-dom/src/transforms/stringifyStatic.ts

+52-11
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@ import {
66
SimpleExpressionNode,
77
createCallExpression,
88
HoistTransform,
9-
CREATE_STATIC
9+
CREATE_STATIC,
10+
ExpressionNode
1011
} from '@vue/compiler-core'
11-
import { isVoidTag, isString, isSymbol, escapeHtml } from '@vue/shared'
12+
import {
13+
isVoidTag,
14+
isString,
15+
isSymbol,
16+
escapeHtml,
17+
toDisplayString
18+
} from '@vue/shared'
1219

1320
// Turn eligible hoisted static trees into stringied static nodes, e.g.
1421
// const _hoisted_1 = createStaticVNode(`<div class="foo">bar</div>`)
22+
// This is only performed in non-in-browser compilations.
1523
export const stringifyStatic: HoistTransform = (node, context) => {
1624
if (shouldOptimize(node)) {
1725
return createCallExpression(context.helper(CREATE_STATIC), [
@@ -22,15 +30,20 @@ export const stringifyStatic: HoistTransform = (node, context) => {
2230
}
2331
}
2432

33+
export const enum StringifyThresholds {
34+
ELEMENT_WITH_BINDING_COUNT = 5,
35+
NODE_COUNT = 20
36+
}
37+
2538
// Opt-in heuristics based on:
2639
// 1. number of elements with attributes > 5.
2740
// 2. OR: number of total nodes > 20
2841
// For some simple trees, the performance can actually be worse.
2942
// it is only worth it when the tree is complex enough
3043
// (e.g. big piece of static content)
3144
function shouldOptimize(node: ElementNode): boolean {
32-
let bindingThreshold = 5
33-
let nodeThreshold = 20
45+
let bindingThreshold = StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
46+
let nodeThreshold = StringifyThresholds.NODE_COUNT
3447

3548
// TODO: check for cases where using innerHTML will result in different
3649
// output compared to imperative node insertions.
@@ -67,11 +80,13 @@ function stringifyElement(
6780
if (p.type === NodeTypes.ATTRIBUTE) {
6881
res += ` ${p.name}`
6982
if (p.value) {
70-
res += `="${p.value.content}"`
83+
res += `="${escapeHtml(p.value.content)}"`
7184
}
7285
} else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
7386
// constant v-bind, e.g. :foo="1"
74-
// TODO
87+
res += ` ${(p.arg as SimpleExpressionNode).content}="${escapeHtml(
88+
evaluateConstant(p.exp as ExpressionNode)
89+
)}"`
7590
}
7691
}
7792
if (context.scopeId) {
@@ -105,16 +120,42 @@ function stringifyNode(
105120
case NodeTypes.COMMENT:
106121
return `<!--${escapeHtml(node.content)}-->`
107122
case NodeTypes.INTERPOLATION:
108-
// constants
109-
// TODO check eval
110-
return (node.content as SimpleExpressionNode).content
123+
return escapeHtml(toDisplayString(evaluateConstant(node.content)))
111124
case NodeTypes.COMPOUND_EXPRESSION:
112-
// TODO proper handling
113-
return node.children.map((c: any) => stringifyNode(c, context)).join('')
125+
return escapeHtml(evaluateConstant(node))
114126
case NodeTypes.TEXT_CALL:
115127
return stringifyNode(node.content, context)
116128
default:
117129
// static trees will not contain if/for nodes
118130
return ''
119131
}
120132
}
133+
134+
// __UNSAFE__
135+
// Reason: eval.
136+
// It's technically safe to eval because only constant expressions are possible
137+
// here, e.g. `{{ 1 }}` or `{{ 'foo' }}`
138+
// in addition, constant exps bail on presence of parens so you can't even
139+
// run JSFuck in here. But we mark it unsafe for security review purposes.
140+
// (see compiler-core/src/transformExpressions)
141+
function evaluateConstant(exp: ExpressionNode): string {
142+
if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
143+
return new Function(`return ${exp.content}`)()
144+
} else {
145+
// compound
146+
let res = ``
147+
exp.children.forEach(c => {
148+
if (isString(c) || isSymbol(c)) {
149+
return
150+
}
151+
if (c.type === NodeTypes.TEXT) {
152+
res += c.content
153+
} else if (c.type === NodeTypes.INTERPOLATION) {
154+
res += evaluateConstant(c.content)
155+
} else {
156+
res += evaluateConstant(c)
157+
}
158+
})
159+
return res
160+
}
161+
}

0 commit comments

Comments
 (0)