]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(compiler-dom): handle constant expressions when stringifying static content
authorEvan You <yyx990803@gmail.com>
Wed, 12 Feb 2020 20:00:00 +0000 (15:00 -0500)
committerEvan You <yyx990803@gmail.com>
Wed, 12 Feb 2020 20:00:00 +0000 (15:00 -0500)
packages/compiler-core/src/transforms/transformExpression.ts
packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts [new file with mode: 0644]
packages/compiler-dom/src/index.ts
packages/compiler-dom/src/transforms/stringifyStatic.ts [moved from packages/compiler-dom/src/stringifyStatic.ts with 64% similarity]

index 12d8fc0c29c05841cc3b5469c6c6b71a83926b73..b78daaf7226ced474717bbae67b4a4ed4a4dc0ed 100644 (file)
@@ -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 (file)
index 0000000..d1095d3
--- /dev/null
@@ -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(
+      `<div><div><div>hello</div><div>hello</div></div></div>`
+    )
+    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(
+      `<div><div>${repeat(
+        `<span class="foo"/>`,
+        StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
+      )}</div></div>`
+    )
+    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(
+          `<div>${repeat(
+            `<span class="foo"></span>`,
+            StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
+          )}</div>`
+        )
+      ]
+    })
+  })
+
+  test('should work on eligible content (elements > 20)', () => {
+    const { ast } = compileWithStringify(
+      `<div><div>${repeat(
+        `<span/>`,
+        StringifyThresholds.NODE_COUNT
+      )}</div></div>`
+    )
+    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(
+          `<div>${repeat(
+            `<span></span>`,
+            StringifyThresholds.NODE_COUNT
+          )}</div>`
+        )
+      ]
+    })
+  })
+
+  test('serliazing constant bindings', () => {
+    const { ast } = compileWithStringify(
+      `<div><div>${repeat(
+        `<span :class="'foo' + 'bar'">{{ 1 }} + {{ false }}</span>`,
+        StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
+      )}</div></div>`
+    )
+    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(
+          `<div>${repeat(
+            `<span class="foobar">1 + false</span>`,
+            StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
+          )}</div>`
+        )
+      ]
+    })
+  })
+
+  test('escape', () => {
+    const { ast } = compileWithStringify(
+      `<div><div>${repeat(
+        `<span :class="'foo' + '&gt;ar'">{{ 1 }} + {{ '<' }}</span>` +
+          `<span>&amp;</span>`,
+        StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
+      )}</div></div>`
+    )
+    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(
+          `<div>${repeat(
+            `<span class="foo&gt;ar">1 + &lt;</span>` + `<span>&amp;</span>`,
+            StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
+          )}</div>`
+        )
+      ]
+    })
+  })
+})
index ca3051538187ca3382a272d1ece29efbb39b1185..b020226366170fdefa3283dc810e2cf9b5e33ca6 100644 (file)
@@ -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
similarity index 64%
rename from packages/compiler-dom/src/stringifyStatic.ts
rename to packages/compiler-dom/src/transforms/stringifyStatic.ts
index 47e5321b441f266d22c1e0a9d683cd1b198d65a6..a2efa56eec0c0cd72abed76302cdd4c7802c081c 100644 (file)
@@ -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(`<div class="foo">bar</div>`)
+// 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 `<!--${escapeHtml(node.content)}-->`
     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
+  }
+}