diff --git a/packages/compiler-core/src/transforms/transformExpression.ts b/packages/compiler-core/src/transforms/transformExpression.ts index 12d8fc0c29c..b78daaf7226 100644 --- a/packages/compiler-core/src/transforms/transformExpression.ts +++ b/packages/compiler-core/src/transforms/transformExpression.ts @@ -90,6 +90,8 @@ export function processExpression( // fast path if expression is a simple identifier. const rawExp = node.content + // bail on parens to prevent any possible function invocations. + const bailConstant = rawExp.indexOf(`(`) > -1 if (isSimpleIdentifier(rawExp)) { if ( !asParams && @@ -98,7 +100,7 @@ export function processExpression( !isLiteralWhitelisted(rawExp) ) { node.content = `_ctx.${rawExp}` - } else if (!context.identifiers[rawExp]) { + } else if (!context.identifiers[rawExp] && !bailConstant) { // mark node constant for hoisting unless it's referring a scope variable node.isConstant = true } @@ -139,12 +141,13 @@ export function processExpression( node.prefix = `${node.name}: ` } node.name = `_ctx.${node.name}` - node.isConstant = false ids.push(node) } else if (!isStaticPropertyKey(node, parent)) { // The identifier is considered constant unless it's pointing to a // scope variable (a v-for alias, or a v-slot prop) - node.isConstant = !(needPrefix && knownIds[node.name]) + if (!(needPrefix && knownIds[node.name]) && !bailConstant) { + node.isConstant = true + } // also generate sub-expressions for other identifiers for better // source map support. (except for property keys which are static) ids.push(node) @@ -234,7 +237,7 @@ export function processExpression( ret = createCompoundExpression(children, node.loc) } else { ret = node - ret.isConstant = true + ret.isConstant = !bailConstant } ret.identifiers = Object.keys(knownIds) return ret diff --git a/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts new file mode 100644 index 00000000000..d1095d3f425 --- /dev/null +++ b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts @@ -0,0 +1,124 @@ +import { compile, NodeTypes, CREATE_STATIC } from '../../src' +import { + stringifyStatic, + StringifyThresholds +} from '../../src/transforms/stringifyStatic' + +describe('stringify static html', () => { + function compileWithStringify(template: string) { + return compile(template, { + hoistStatic: true, + prefixIdentifiers: true, + transformHoist: stringifyStatic + }) + } + + function repeat(code: string, n: number): string { + return new Array(n) + .fill(0) + .map(() => code) + .join('') + } + + test('should bail on non-eligible static trees', () => { + const { ast } = compileWithStringify( + `
hello
hello
` + ) + expect(ast.hoists.length).toBe(1) + // should be a normal vnode call + expect(ast.hoists[0].type).toBe(NodeTypes.VNODE_CALL) + }) + + test('should work on eligible content (elements with binding > 5)', () => { + const { ast } = compileWithStringify( + `
${repeat( + ``, + StringifyThresholds.ELEMENT_WITH_BINDING_COUNT + )}
` + ) + expect(ast.hoists.length).toBe(1) + // should be optimized now + expect(ast.hoists[0]).toMatchObject({ + type: NodeTypes.JS_CALL_EXPRESSION, + callee: CREATE_STATIC, + arguments: [ + JSON.stringify( + `
${repeat( + ``, + StringifyThresholds.ELEMENT_WITH_BINDING_COUNT + )}
` + ) + ] + }) + }) + + test('should work on eligible content (elements > 20)', () => { + const { ast } = compileWithStringify( + `
${repeat( + ``, + StringifyThresholds.NODE_COUNT + )}
` + ) + expect(ast.hoists.length).toBe(1) + // should be optimized now + expect(ast.hoists[0]).toMatchObject({ + type: NodeTypes.JS_CALL_EXPRESSION, + callee: CREATE_STATIC, + arguments: [ + JSON.stringify( + `
${repeat( + ``, + StringifyThresholds.NODE_COUNT + )}
` + ) + ] + }) + }) + + test('serliazing constant bindings', () => { + const { ast } = compileWithStringify( + `
${repeat( + `{{ 1 }} + {{ false }}`, + StringifyThresholds.ELEMENT_WITH_BINDING_COUNT + )}
` + ) + expect(ast.hoists.length).toBe(1) + // should be optimized now + expect(ast.hoists[0]).toMatchObject({ + type: NodeTypes.JS_CALL_EXPRESSION, + callee: CREATE_STATIC, + arguments: [ + JSON.stringify( + `
${repeat( + `1 + false`, + StringifyThresholds.ELEMENT_WITH_BINDING_COUNT + )}
` + ) + ] + }) + }) + + test('escape', () => { + const { ast } = compileWithStringify( + `
${repeat( + `{{ 1 }} + {{ '<' }}` + + `&`, + StringifyThresholds.ELEMENT_WITH_BINDING_COUNT + )}
` + ) + expect(ast.hoists.length).toBe(1) + // should be optimized now + expect(ast.hoists[0]).toMatchObject({ + type: NodeTypes.JS_CALL_EXPRESSION, + callee: CREATE_STATIC, + arguments: [ + JSON.stringify( + `
${repeat( + `1 + <` + `&`, + StringifyThresholds.ELEMENT_WITH_BINDING_COUNT + )}
` + ) + ] + }) + }) +}) diff --git a/packages/compiler-dom/src/index.ts b/packages/compiler-dom/src/index.ts index ca305153818..b0202263661 100644 --- a/packages/compiler-dom/src/index.ts +++ b/packages/compiler-dom/src/index.ts @@ -18,7 +18,7 @@ import { transformModel } from './transforms/vModel' import { transformOn } from './transforms/vOn' import { transformShow } from './transforms/vShow' import { warnTransitionChildren } from './transforms/warnTransitionChildren' -import { stringifyStatic } from './stringifyStatic' +import { stringifyStatic } from './transforms/stringifyStatic' export const parserOptions = __BROWSER__ ? parserOptionsMinimal diff --git a/packages/compiler-dom/src/stringifyStatic.ts b/packages/compiler-dom/src/transforms/stringifyStatic.ts similarity index 64% rename from packages/compiler-dom/src/stringifyStatic.ts rename to packages/compiler-dom/src/transforms/stringifyStatic.ts index 47e5321b441..a2efa56eec0 100644 --- a/packages/compiler-dom/src/stringifyStatic.ts +++ b/packages/compiler-dom/src/transforms/stringifyStatic.ts @@ -6,12 +6,20 @@ import { SimpleExpressionNode, createCallExpression, HoistTransform, - CREATE_STATIC + CREATE_STATIC, + ExpressionNode } from '@vue/compiler-core' -import { isVoidTag, isString, isSymbol, escapeHtml } from '@vue/shared' +import { + isVoidTag, + isString, + isSymbol, + escapeHtml, + toDisplayString +} from '@vue/shared' // Turn eligible hoisted static trees into stringied static nodes, e.g. // const _hoisted_1 = createStaticVNode(`
bar
`) +// This is only performed in non-in-browser compilations. export const stringifyStatic: HoistTransform = (node, context) => { if (shouldOptimize(node)) { return createCallExpression(context.helper(CREATE_STATIC), [ @@ -22,6 +30,11 @@ export const stringifyStatic: HoistTransform = (node, context) => { } } +export const enum StringifyThresholds { + ELEMENT_WITH_BINDING_COUNT = 5, + NODE_COUNT = 20 +} + // Opt-in heuristics based on: // 1. number of elements with attributes > 5. // 2. OR: number of total nodes > 20 @@ -29,8 +42,8 @@ export const stringifyStatic: HoistTransform = (node, context) => { // it is only worth it when the tree is complex enough // (e.g. big piece of static content) function shouldOptimize(node: ElementNode): boolean { - let bindingThreshold = 5 - let nodeThreshold = 20 + let bindingThreshold = StringifyThresholds.ELEMENT_WITH_BINDING_COUNT + let nodeThreshold = StringifyThresholds.NODE_COUNT // TODO: check for cases where using innerHTML will result in different // output compared to imperative node insertions. @@ -67,11 +80,13 @@ function stringifyElement( if (p.type === NodeTypes.ATTRIBUTE) { res += ` ${p.name}` if (p.value) { - res += `="${p.value.content}"` + res += `="${escapeHtml(p.value.content)}"` } } else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') { // constant v-bind, e.g. :foo="1" - // TODO + res += ` ${(p.arg as SimpleExpressionNode).content}="${escapeHtml( + evaluateConstant(p.exp as ExpressionNode) + )}"` } } if (context.scopeId) { @@ -105,12 +120,9 @@ function stringifyNode( case NodeTypes.COMMENT: return `` case NodeTypes.INTERPOLATION: - // constants - // TODO check eval - return (node.content as SimpleExpressionNode).content + return escapeHtml(toDisplayString(evaluateConstant(node.content))) case NodeTypes.COMPOUND_EXPRESSION: - // TODO proper handling - return node.children.map((c: any) => stringifyNode(c, context)).join('') + return escapeHtml(evaluateConstant(node)) case NodeTypes.TEXT_CALL: return stringifyNode(node.content, context) default: @@ -118,3 +130,32 @@ function stringifyNode( return '' } } + +// __UNSAFE__ +// Reason: eval. +// It's technically safe to eval because only constant expressions are possible +// here, e.g. `{{ 1 }}` or `{{ 'foo' }}` +// in addition, constant exps bail on presence of parens so you can't even +// run JSFuck in here. But we mark it unsafe for security review purposes. +// (see compiler-core/src/transformExpressions) +function evaluateConstant(exp: ExpressionNode): string { + if (exp.type === NodeTypes.SIMPLE_EXPRESSION) { + return new Function(`return ${exp.content}`)() + } else { + // compound + let res = `` + exp.children.forEach(c => { + if (isString(c) || isSymbol(c)) { + return + } + if (c.type === NodeTypes.TEXT) { + res += c.content + } else if (c.type === NodeTypes.INTERPOLATION) { + res += evaluateConstant(c.content) + } else { + res += evaluateConstant(c) + } + }) + return res + } +}