Commit Graph
7 Commits
Author SHA1 Message Date
7ab49f999a fix: charge join/json/inspect filters by produced output size (#925)
* fix(filters): charge join/array_to_sentence_string by output size

join charged memoryLimit by array element count, not by the string it
produces, letting concat doubling (cheap reference copies) inflate an
array's element count and then materialize a huge string via join far
past the configured memoryLimit (GHSA-4r6h-5v86-94p3). Charge by the
sum of stringified element lengths plus separators before allocating.
Apply the same fix to the sibling array_to_sentence_string filter.

Co-authored-by: Cursor <[email protected]>

* refactor(filters): simplify join output-size accounting

Sum stringified element lengths in a single pass and keep the guarded
Array.prototype.join for the result, instead of building an intermediate
parts array.

Co-authored-by: Cursor <[email protected]>

* fix(filters): charge json/jsonify/inspect serialization to memoryLimit

json/jsonify/inspect serialized values without charging memoryLimit, so
a concat-doubled array (cheap reference copies) could be materialized
into a huge JSON string past the configured limit — the same unbounded
class as the join bug (GHSA-4r6h-5v86-94p3). Charge via a JSON.stringify
replacer that accounts string lengths as it walks, aborting mid-
serialization instead of allocating the full blob first.

Co-authored-by: Cursor <[email protected]>

* fix(memory): charge rendered output to memoryLimit at emission

Move output-length accounting into the emitters, which charge each
written chunk against ctx.memoryLimit right before it reaches the
result string or stream. Filters/tags now only pre-charge the extra
working memory they allocate apart from that output, so join drops its
bespoke output-size counting and charges array.length like its siblings.

The block.super capture emitter intentionally omits the limiter to
avoid double-counting content that is re-emitted through the final
emitter.

Co-authored-by: Cursor <[email protected]>

* refactor(filters): rely on emitter output charge for json/inspect/array_to_sentence_string

With rendered output charged at emission, these filters no longer need
bespoke output-size counting: the emitted case is covered by the final
emitter. Revert json/inspect to their original form and array_to_sentence_string
to its element-count charge, dropping the non-emitted `| size` guards.

Co-authored-by: Cursor <[email protected]>

* revert(memory): drop emitter output charge, restore filter output-size accounting

join/array_to_sentence_string/json/inspect charge memoryLimit by the
string they materialize (not element count), so discarded results like
{% assign out = a | join %}{{ out | size }} are still bounded.

Remove the emitter-level limiter added in 2f343f063; it cannot catch
materialized-but-not-emitted values.

Co-authored-by: Cursor <[email protected]>

* fix(filters): charge json/inspect replacer by serialized node size

Replace the flat 1-unit charge for non-string JSON nodes with per-type
estimates (primitives via JSON.stringify length, containers by structure).

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-07-06 23:54:00 +08:00
8a0c74a7fc fix(security): charge pop filter allocation to memoryLimit (#907)
* fix(security): charge pop filter allocation to memoryLimit (CWE-770)

The `pop` array filter cloned the input via `[...toArray(v)]` without
charging `this.context.memoryLimit.use(...)`, bypassing the memoryLimit
DoS guard that its sibling filters (shift, unshift, compact, etc.) apply.
Mirror `shift` to account for the O(N) allocation.

Co-authored-by: Cursor <[email protected]>

* fix(security): charge sample filter full clone allocation to memoryLimit (CWE-770)

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-06-14 15:50:50 +08:00
3616a744b9 fix(strip_html): rewrite as linear single-pass scan to avoid ReDoS (#896)
* fix(strip_html): rewrite as linear single-pass scan to avoid ReDoS

The previous strip_html regex
  /<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g
contains lazy alternatives that backtrack O(n^2) on inputs with many
unclosed `<script` / `<style` openers. A 350KB payload of
`'<script'.repeat(50000)` blocked the Node.js event loop for ~10s, and
cost grew quadratically with input size. memoryLimit only charged
str.length, which does not bound regex CPU.

Replace the regex with an indexOf-based single-pass scan. For each `<`
we:
- if `<script` opener: find next `</script>` and skip the whole block;
  cache "no closer after pos k" so subsequent unclosed `<script`
  openers do not re-scan the tail.
- same for `<style` / `</style>`.
- otherwise treat as a generic `<...>` tag (matches the original
  behavior, where the `<[\s\S]*?>` alternative also caught comments).
- if no closing `>` exists, emit the tail as literal text and stop.

Total work is O(n). All existing strip_html test cases pass unchanged.

Add regression tests covering the PoCs (`<script` / `<style` repeats,
and `<script>foo` repeats with `>` but no `</script>`) plus a
memoryLimit assertion.

Co-authored-by: Cursor <[email protected]>

* refactor(strip_html): factor block kinds into a small table

Same algorithm and complexity, fewer lines. Document why a regex-only
solution can't be O(n) in V8 (no atomic groups / possessive quantifiers
/ memoization, so unrolled-loop patterns are still O(n^2) on unclosed
openers — empirically confirmed: original 280KB ~4s, Friedl unrolled
~14s, atomic lookahead ~7s; tokenizer ~1ms).

Co-authored-by: Cursor <[email protected]>

* refactor(strip_html): inline block kinds to match file style

Drop the module-level STRIP_BLOCKS table; the rest of the file keeps
each filter self-contained (only escapeMap/unescapeMap are top-level
maps shared across filters). Two openers don't justify a table.

Co-authored-by: Cursor <[email protected]>

* refactor(strip_html): unify raw-text blocks; treat <!--...--> as opaque

In HTML5, <script>, <style>, and <!-- --> are all raw-text blocks: their
content is opaque until the matching closer, so a `>` inside CSS, JS, or
a comment must not be treated as a tag end. The previous code only had
this special handling for <script> and <style>; comments containing `>`
fell through to the generic `<...>` branch and were partially stripped
(e.g. `<!-- a > b -->` left `b -->` in the output).

Match Shopify Liquid's STRIP_HTML_BLOCKS set (script + style + comment),
and consolidate the three near-identical branches into a small
opener/closer table inside the function.

Algorithm and complexity unchanged (O(n) via indexOf + cached closer
positions). Add a regression test for `>` inside a comment.

Co-authored-by: Cursor <[email protected]>

* refactor(strip_html): drop position cache, delete dead blocks from Set

Once `indexOf(closer, X)` returns -1, all subsequent searches (with
monotonically increasing start) also return -1. So tracking absence is
enough; storing positions is unnecessary. Make `blocks` a Set and
delete a kind once its closer is known absent — no parallel `dead`
bookkeeping. Use Jest's per-test timeout for the ReDoS regressions
instead of manual Date.now() bookkeeping.

Co-authored-by: Cursor <[email protected]>

* refactor(strip_html): treat '<...>' as a catch-all block kind

Adding ['<', '>'] as the lowest-priority entry of `blocks` lets the
inner loop subsume the generic-tag fallback: the `end` sentinel and
its `< 0` / `<= 0` follow-up checks disappear, the "no terminator"
exit becomes a single `i === lt` test, and Set<[string, string]>
collapses to Map<string, string>.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-05-11 23:59:40 +08:00
5b9c346908 fix: enforce renderLimit for empty renderTemplates calls (#894)
renderLimit was only checked inside the per-template loop, so
renderTemplates([], ...) skipped it. Empty {% for %} and {% tablerow %}
bodies call that path once per iteration (tablerow still does emitter
work for <tr>/<td>), bypassing the documented time budget. Check the
limiter at renderTemplates entry before the loop.

Add regression tests for empty for-body and empty tablerow-body.

Co-authored-by: Cursor <[email protected]>
2026-05-07 23:03:43 +08:00
Jun YangandGitHub e941378535 docs: add internal types in apidoc (#734)
* docs: add internal types in apidoc

* chore: downgrade package-lock version for NodeJS 14

* test: test case when performance uses Date.now
2024-08-17 00:55:24 +08:00
Jun YangandGitHub e4aeb023fd feat: locale support for date filter, #567 (#723) 2024-07-22 00:39:44 +08:00
Yang Jun e443068cb9 feat: DoS prevention, #250 2024-07-09 22:51:11 +08:00