CVE-2022-25948: Exposure of Sensitive Information to an Unauthorized Actor (CWE-200)
value: The package liquidjs before 10.0.0 are vulnerable to Information Exposure when ownPropertyOnly parameter is set to False, which results in leaking properties of a prototype. Workaround For versions 9.34.0 and higher, an option to disable this functionality is provided.
CWE description: The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information.
CVSS v3 base score: 5.3
Published: 2022-12-22T05:15Z
Repository: https://github.com/harttle/liquidjs
Language: TypeScript
Files changed: docs/source/tutorials/options.md, docs/source/zh-cn/tutorials/options.md, package.json, src/context/context.ts, src/liquid-options.ts, test/e2e/issues.ts, test/unit/context/context.ts
Commit date: 2022-01-29 01:22:34 +0800
Commit message: feat: `ownPropertyOnly` option to protect prototype, #454

Diff: diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md
index d9db59b..144818f 100644
--- a/docs/source/tutorials/options.md
+++ b/docs/source/tutorials/options.md
@@ -126,10 +126,12 @@ it defaults to false.  For example, when set to true, a blank string would evalu
 
 **strictVariables** is used to assert variable existence.  If set to `false`, undefined variables will be rendered as empty string.  Otherwise, undefined variables will cause a render exception. Defaults to `false`.
 
 **lenientIf** modifies the behavior of `strictVariables` to allow handling optional variables. If set to `true`, an undefined variable will *not* cause an exception in the following two situations: a) it is the condition to an `if`, `elsif`, or `unless` tag; b) it occurs right before a `default` filter. Irrelevant if `strictVariables` is not set. Defaults to `false`.
 
+**ownPropertyOnly** hides scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. Defaults to `false`.
+
 {% note info Non-existent Tags %}
 Non-existent tags always throw errors during parsing and this behavior can not be customized.
 {% endnote %}
 
 ## Parameter Order
diff --git a/docs/source/zh-cn/tutorials/options.md b/docs/source/zh-cn/tutorials/options.md
index aaa51f5..bd9ce65 100644
--- a/docs/source/zh-cn/tutorials/options.md
+++ b/docs/source/zh-cn/tutorials/options.md
@@ -123,10 +123,12 @@ LiquidJS 把这个选项默认值设为 <code>true</code> 以兼容于 shopify/l
 
 **strictFilters** 用来启用过滤器的严格模式，如果设置为 `true` 过滤器不存在时解析会抛出异常。默认为 `false`，这时会跳过不存在的过滤器。
 
 **strictVariables** 用来启用变量严格模式。如果设置为 `true` 变量不存在时渲染会抛出异常，默认为 `false` 这时不存在的变量会被渲染为空字符串。
 
+**ownPropertyOnly** 用来隐藏原型上的变量，如果你需要把未经处理过的对象传递给模板时，可以设置 `ownPropertyOnly` 为 `true`，默认为 `false`。
+
 {% note info 不存在的标签 %}
 不存在的标签总是会抛出一个解析异常，这一行为无法自定义。
 {% endnote %}
 
 ## 参数顺序
diff --git a/package.json b/package.json
index 7e855cd..206e90c 100644
--- a/package.json
+++ b/package.json
@@ -10,11 +10,11 @@
     "./dist/liquid.node.esm.js": "./dist/liquid.browser.esm.js"
   },
   "types": "dist/liquid.d.ts",
   "scripts": {
     "lint": "eslint \"**/*.ts\" .",
-    "check": "npm run build && npm test && npm run lint",
+    "check": "npm run build && npm test && npm run lint && npm run perf:diff",
     "test": "nyc mocha \"test/**/*.ts\"",
     "test:e2e": "mocha \"test/e2e/**/*.ts\"",
     "perf": "cd benchmark && npm ci && npm start",
     "perf:diff": "bin/perf-diff.sh",
     "perf:engines": "cd benchmark && npm run engines",
diff --git a/src/context/context.ts b/src/context/context.ts
index fb14df5..bb980d9 100644
--- a/src/context/context.ts
+++ b/src/context/context.ts
@@ -57,15 +57,15 @@ export class Context {
   public get (paths: string[]) {
     const scope = this.findScope(paths[0])
     return this.getFromScope(scope, paths)
   }
   public getFromScope (scope: object, paths: string[] | string) {
-    if (typeof paths === 'string') paths = paths.split('.')
-    return paths.reduce((scope, path) => {
-      scope = readProperty(scope, path)
+    if (isString(paths)) paths = paths.split('.')
+    return paths.reduce((scope, path, i) => {
+      scope = readProperty(scope, path, this.opts.ownPropertyOnly)
       if (isNil(scope) && this.strictVariables) {
-        throw new InternalUndefinedVariableError(path)
+        throw new InternalUndefinedVariableError((paths as string[]).slice(0, i + 1).join!('.'))
       }
       return scope
     }, scope)
   }
   public push (ctx: object) {
@@ -85,21 +85,26 @@ export class Context {
     if (key in this.environments) return this.environments
     return this.globals
   }
 }
 
-export function readProperty (obj: Scope, key: string) {
+export function readProperty (obj: Scope, key: string, ownPropertyOnly: boolean) {
   if (isNil(obj)) return obj
   obj = toLiquid(obj)
-  if (isFunction(obj[key])) return obj[key]()
+  const jsProperty = readJSProperty(obj, key, ownPropertyOnly)
+  if (isFunction(jsProperty)) return jsProperty.call(obj)
   if (obj instanceof Drop) {
     if (obj.hasOwnProperty(key)) return obj[key]
     return obj.liquidMethodMissing(key)
   }
   if (key === 'size') return readSize(obj)
   if (key === 'first') return readFirst(obj)
   if (key === 'last') return readLast(obj)
+  return jsProperty
+}
+export function readJSProperty (obj: Scope, key: string, ownPropertyOnly: boolean) {
+  if (ownPropertyOnly && !Object.hasOwnProperty.call(obj, key)) return undefined
   return obj[key]
 }
 
 function readFirst (obj: Scope) {
   if (isArray(obj)) return obj[0]
diff --git a/src/liquid-options.ts b/src/liquid-options.ts
index fe98d86..6654375 100644
--- a/src/liquid-options.ts
+++ b/src/liquid-options.ts
@@ -29,10 +29,12 @@ export interface LiquidOptions {
   dynamicPartials?: boolean;
   /** Whether or not to assert filter existence. If set to `false`, undefined filters will be skipped. Otherwise, undefined filters will cause an exception. Defaults to `false`. */
   strictFilters?: boolean;
   /** Whether or not to assert variable existence.  If set to `false`, undefined variables will be rendered as empty string.  Otherwise, undefined variables will cause an exception. Defaults to `false`. */
   strictVariables?: boolean;
+  /** Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. */
+  ownPropertyOnly?: boolean;
   /** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/
   lenientIf?: boolean;
   /** JavaScript timezoneOffset for `date` filter, default to local time. That means if you're in Australia (UTC+10), it'll default to -600 */
   timezoneOffset?: number;
   /** Strip blank characters (including ` `, `\t`, and `\r`) from the right of tags (`{% %}`) until `\n` (inclusive). Defaults to `false`. */
@@ -78,10 +80,14 @@ export interface RenderOptions {
   globals?: object;
   /**
    * Same as `strictVariables` on LiquidOptions, but only for current render() call
    */
   strictVariables?: boolean;
+  /**
+   * Same as `ownPropertyOnly` on LiquidOptions, but only for current render() call
+   */
+  ownPropertyOnly?: boolean;
 }
 
 interface NormalizedOptions extends LiquidOptions {
   root?: string[];
   partials?: string[];
@@ -101,10 +107,11 @@ export interface NormalizedFullOptions extends NormalizedOptions {
   jsTruthy: boolean;
   dynamicPartials: boolean;
   fs: FS;
   strictFilters: boolean;
   strictVariables: boolean;
+  ownPropertyOnly: boolean;
   lenientIf: boolean;
   trimTagRight: boolean;
   trimTagLeft: boolean;
   trimOutputRight: boolean;
   trimOutputLeft: boolean;
@@ -141,10 +148,11 @@ export const defaultOptions: NormalizedFullOptions = {
   outputDelimiterLeft: '{{',
   outputDelimiterRight: '}}',
   preserveTimezones: false,
   strictFilters: false,
   strictVariables: false,
+  ownPropertyOnly: false,
   lenientIf: false,
   globals: {},
   keepOutputType: false,
   operators: defaultOperators,
   operatorsTrie: createTrie(defaultOperators)
diff --git a/test/e2e/issues.ts b/test/e2e/issues.ts
index a6dcc4f..93ad7b4 100644
--- a/test/e2e/issues.ts
+++ b/test/e2e/issues.ts
@@ -192,6 +192,11 @@ describe('Issues', function () {
     -%}`
     const engine = new Liquid()
     const html = engine.parseAndRenderSync(template, { array: [1, 2, 3] })
     expect(html).to.equal('4#8#12#6')
   })
+  it('#454 leaking JS prototype getter functions in evaluation', async () => {
+    const engine = new Liquid({ ownPropertyOnly: true })
+    const html = engine.parseAndRenderSync('{{foo | size}}-{{bar.coo}}', { foo: 'foo', bar: Object.create({ coo: 'COO' }) })
+    expect(html).to.equal('3-')
+  })
 })
diff --git a/test/unit/context/context.ts b/test/unit/context/context.ts
index e569ec2..3b27768 100644
--- a/test/unit/context/context.ts
+++ b/test/unit/context/context.ts
@@ -93,25 +93,87 @@ describe('Context', function () {
     it('should throw when variable not defined', function () {
       return expect(() => ctx.get(['notdefined'])).to.throw(/undefined variable: notdefined/)
     })
     it('should throw when deep variable not exist', async function () {
       ctx.push({ foo: 'FOO' })
-      return expect(() => ctx.get(['foo', 'bar', 'not', 'defined'])).to.throw(/undefined variable: bar/)
+      return expect(() => ctx.get(['foo', 'bar', 'not', 'defined'])).to.throw(/undefined variable: foo.bar/)
     })
     it('should throw when itself not defined', async function () {
       ctx.push({ foo: 'FOO' })
-      return expect(() => ctx.get(['foo', 'BAR'])).to.throw(/undefined variable: BAR/)
+      return expect(() => ctx.get(['foo', 'BAR'])).to.throw(/undefined variable: foo.BAR/)
     })
     it('should find variable in parent scope', async function () {
       ctx.push({ 'foo': 'foo' })
       ctx.push({
         'bar': 'bar'
       })
       expect(ctx.get(['foo'])).to.equal('foo')
     })
   })
 
+  describe('ownPropertyOnly', async function () {
+    let ctx: Context
+    beforeEach(function () {
+      ctx = new Context(ctx, {
+        ownPropertyOnly: true
+      } as any)
+    })
+    it('should return undefined for prototype object property', function () {
+      ctx.push({ foo: Object.create({ bar: 'BAR' }) })
+      return expect(ctx.get(['foo', 'bar'])).to.equal(undefined)
+    })
+    it('should return undefined for Array.prototype.reduce', function () {
+      ctx.push({ foo: [] })
+      return expect(ctx.get(['foo', 'reduce'])).to.equal(undefined)
+    })
+    it('should return undefined for function prototype property', function () {
+      function Foo () {}
+      Foo.prototype.bar = 'BAR'
+      ctx.push({ foo: new (Foo as any)() })
+      return expect(ctx.get(['foo', 'bar'])).to.equal(undefined)
+    })
+    it('should allow function constructor properties', function () {
+      function Foo (this: any) { this.bar = 'BAR' }
+      ctx.push({ foo: new (Foo as any)() })
+      return expect(ctx.get(['foo', 'bar'])).to.equal('BAR')
+    })
+    it('should return undefined for class method', function () {
+      class Foo { bar () {} }
+      ctx.push({ foo: new Foo() })
+      return expect(ctx.get(['foo', 'bar'])).to.equal(undefined)
+    })
+    it('should allow class property', function () {
+      class Foo { bar = 'BAR' }
+      ctx.push({ foo: new Foo() })
+      return expect(ctx.get(['foo', 'bar'])).to.equal('BAR')
+    })
+    it('should allow Array.prototype.length', function () {
+      ctx.push({ foo: [1, 2] })
+      return expect(ctx.get(['foo', 'length'])).to.equal(2)
+    })
+    it('should allow size to access Array.prototype.length', function () {
+      ctx.push({ foo: [1, 2] })
+      return expect(ctx.get(['foo', 'size'])).to.equal(2)
+    })
+    it('should allow size to access Set.prototype.size', function () {
+      ctx.push({ foo: new Set([1, 2]) })
+      return expect(ctx.get(['foo', 'size'])).to.equal(2)
+    })
+    it('should allow size to access Object key count', function () {
+      ctx.push({ foo: { bar: 'BAR', coo: 'COO' } })
+      return expect(ctx.get(['foo', 'size'])).to.equal(2)
+    })
+    it('should throw when property is hidden and strictVariables is true', function () {
+      ctx = new Context(ctx, {
+        ownPropertyOnly: true,
+        strictVariables: true
+      } as any)
+      ctx.push({ foo: Object.create({ bar: 'BAR' }) })
+      return expect(() => ctx.get(['foo', 'bar'])).to.throw(/undefined variable: foo.bar/)
+    })
+  })
+
   describe('.getAll()', function () {
     it('should get all properties when arguments empty', async function () {
       expect(ctx.getAll()).deep.equal(scope)
     })
   })

Vulnerable code:     "check": "npm run build && npm test && npm run lint",
    if (typeof paths === 'string') paths = paths.split('.')
    return paths.reduce((scope, path) => {
      scope = readProperty(scope, path)
        throw new InternalUndefinedVariableError(path)
export function readProperty (obj: Scope, key: string) {
  if (isFunction(obj[key])) return obj[key]()
      return expect(() => ctx.get(['foo', 'bar', 'not', 'defined'])).to.throw(/undefined variable: bar/)
      return expect(() => ctx.get(['foo', 'BAR'])).to.throw(/undefined variable: BAR/)
Fixed code: **ownPropertyOnly** hides scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. Defaults to `false`.

**ownPropertyOnly** 用来隐藏原型上的变量，如果你需要把未经处理过的对象传递给模板时，可以设置 `ownPropertyOnly` 为 `true`，默认为 `false`。

    "check": "npm run build && npm test && npm run lint && npm run perf:diff",
    if (isString(paths)) paths = paths.split('.')
    return paths.reduce((scope, path, i) => {
      scope = readProperty(scope, path, this.opts.ownPropertyOnly)
        throw new InternalUndefinedVariableError((paths as string[]).slice(0, i + 1).join!('.'))
export function readProperty (obj: Scope, key: string, ownPropertyOnly: boolean) {
  const jsProperty = readJSProperty(obj, key, ownPropertyOnly)
  if (isFunction(jsProperty)) return jsProperty.call(obj)
  return jsProperty
}
export function readJSProperty (obj: Scope, key: string, ownPropertyOnly: boolean) {
  if (ownPropertyOnly && !Object.hasOwnProperty.call(obj, key)) return undefined
  /** Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. */
  ownPropertyOnly?: boolean;
  /**
   * Same as `ownPropertyOnly` on LiquidOptions, but only for current render() call
   */
  ownPropertyOnly?: boolean;
  ownPropertyOnly: boolean;
  ownPropertyOnly: false,
  it('#454 leaking JS prototype getter functions in evaluation', async () => {
    const engine = new Liquid({ ownPropertyOnly: true })
    const html = engine.parseAndRenderSync('{{foo | size}}-{{bar.coo}}', { foo: 'foo', bar: Object.create({ coo: 'COO' }) })
    expect(html).to.equal('3-')
  })
      return expect(() => ctx.get(['foo', 'bar', 'not', 'defined'])).to.throw(/undefined variable: foo.bar/)
      return expect(() => ctx.get(['foo', 'BAR'])).to.throw(/undefined variable: foo.BAR/)
  describe('ownPropertyOnly', async function () {
    let ctx: Context
    beforeEach(function () {
      ctx = new Context(ctx, {
        ownPropertyOnly: true
      } as any)
    })
    it('should return undefined for prototype object property', function () {
      ctx.push({ foo: Object.create({ bar: 'BAR' }) })
      return expect(ctx.get(['foo', 'bar'])).to.equal(undefined)
    })
    it('should return undefined for Array.prototype.reduce', function () {
      ctx.push({ foo: [] })
      return expect(ctx.get(['foo', 'reduce'])).to.equal(undefined)
    })
    it('should return undefined for function prototype property', function () {
      function Foo () {}
      Foo.prototype.bar = 'BAR'
      ctx.push({ foo: new (Foo as any)() })
      return expect(ctx.get(['foo', 'bar'])).to.equal(undefined)
    })
    it('should allow function constructor properties', function () {
      function Foo (this: any) { this.bar = 'BAR' }
      ctx.push({ foo: new (Foo as any)() })
      return expect(ctx.get(['foo', 'bar'])).to.equal('BAR')
    })
    it('should return undefined for class method', function () {
      class Foo { bar () {} }
      ctx.push({ foo: new Foo() })
      return expect(ctx.get(['foo', 'bar'])).to.equal(undefined)
    })
    it('should allow class property', function () {
      class Foo { bar = 'BAR' }
      ctx.push({ foo: new Foo() })
      return expect(ctx.get(['foo', 'bar'])).to.equal('BAR')
    })
    it('should allow Array.prototype.length', function () {
      ctx.push({ foo: [1, 2] })
      return expect(ctx.get(['foo', 'length'])).to.equal(2)
    })
    it('should allow size to access Array.prototype.length', function () {
      ctx.push({ foo: [1, 2] })
      return expect(ctx.get(['foo', 'size'])).to.equal(2)
    })
    it('should allow size to access Set.prototype.size', function () {
      ctx.push({ foo: new Set([1, 2]) })
      return expect(ctx.get(['foo', 'size'])).to.equal(2)
    })
    it('should allow size to access Object key count', function () {
      ctx.push({ foo: { bar: 'BAR', coo: 'COO' } })
      return expect(ctx.get(['foo', 'size'])).to.equal(2)
    })
    it('should throw when property is hidden and strictVariables is true', function () {
      ctx = new Context(ctx, {
        ownPropertyOnly: true,
        strictVariables: true
      } as any)
      ctx.push({ foo: Object.create({ bar: 'BAR' }) })
      return expect(() => ctx.get(['foo', 'bar'])).to.throw(/undefined variable: foo.bar/)
    })
  })