Compare commits

...
Author SHA1 Message Date
Yang JunandCursor 5e5d0cc9a1 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]>
2026-05-11 23:36:31 +08:00
Yang JunandCursor 7a77fa4f64 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]>
2026-05-11 23:25:31 +08:00
Yang JunandCursor 0681ff843c 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]>
2026-05-10 15:56:34 +08:00
Yang JunandCursor fae50bd72c 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]>
2026-05-10 15:23:01 +08:00
Yang JunandCursor 45b48bc8fe 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]>
2026-05-10 15:17:37 +08:00
Yang JunandCursor 2803730a14 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]>
2026-05-10 14:47:06 +08:00
3129d46dc9 fix(date): cap strftime widths and account padding in memoryLimit (#895)
* fix(date): cap strftime widths and account padding in memoryLimit

- Clamp numeric strftime pad widths to MAX_STRFTIME_PAD (1024)
- Export estimateStrftimePaddingMemory for the date filter to charge memoryLimit
- Replace unbounded pad() concatenation loop with ch.repeat + single concat
- Add regression tests for clamping and memoryLimit on huge %width directives

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

* fix(date): harden strftime memory accounting and document security model

Move strftime memory charging into the same formatting path used for padding, enforce pre-allocation checks, and add regression tests for non-string date format PoCs. Add dedicated docs clarifying that memoryLimit is cooperative DoS mitigation and not strict heap isolation.

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

* docs(zh-cn): add security model docs for DoS limits

Add a Chinese security-model tutorial and link it from the Chinese DoS guide to clarify that memoryLimit is cooperative accounting, list uncounted custom conversion cases, and recommend avoiding fully user-defined templates in online services.

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

* docs: consolidate DoS docs into security-model pages

Merge DoS guidance into security-model docs in both English and Chinese, and remove the placeholder dos.md pages to avoid duplicate/redirect-only docs.

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

* docs: merge DoS details into security-model docs

Move the detailed parseLimit/renderLimit/memoryLimit explanations and examples into the English and Chinese security-model pages so content from the removed dos pages is preserved.

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

* docs: reorganize security-model structure for clarity

Restructure English and Chinese security-model docs into a consistent flow: security boundary, limits overview, per-limit details, and online service guidance.

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

* refactor(strftime): simplify %N width parsing logic

Use regex-backed width assumptions to simplify %N width normalization and padding memory accounting while keeping behavior equivalent.

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

* refactor(strftime): rely on memoryLimit for width control

Remove MAX_STRFTIME_PAD hard capping and rely on memoryLimit enforcement before padding allocation. Update strftime/date tests and security-model docs to match the new boundary and renderLimit caveats.

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

* fix(strftime): use add() once for padding, minimize churn

- pad(): replace per-char loop with a single add(str, ch.repeat(n)) call.
  The earlier `probe[0] === ch` heuristic was wrong when ch happened to
  equal a leading char of 'probe' (e.g. ch === 'p').
- strftime.ts: revert unrelated typing/structural refactors so the diff
  contains only the memoryLimit threading and the %N memory charge.
- docs: rewire the deleted dos.html sidebar entry to security-model.html
  (with localized labels) so the deleted page does not 404 from the
  sidebar.

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

---------

Co-authored-by: Cursor <[email protected]>
2026-05-10 14:35:28 +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
dbbf628803 fix: propagate ownPropertyOnly into Context.spawn() for {% render %} (#893)
Child contexts from spawn() re-derived ownPropertyOnly from Liquid opts
only, dropping per-render RenderOptions overrides. That broke the contract
that parseAndRender(..., { ownPropertyOnly: true }) locks down a single
render, including partials loaded via {% render %}.

Add regression test matching prototype-chain leak PoC.

Co-authored-by: Cursor <[email protected]>
2026-05-03 22:35:31 +08:00
26ea2856c7 fix: strip html newline tags (#892)
* docs: add @talboren as financial contributor

* fix(strip_html): match tags that span newlines inside angle brackets

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

---------

Co-authored-by: Cursor <[email protected]>
2026-05-03 21:36:09 +08:00
a55f543f49 docs(readme): add Freshet to Who's Using LiquidJS (#888)
Co-authored-by: MattAltermatt <[email protected]>
2026-05-03 12:05:12 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
d1d517d1ec docs: add VladimirFilonov as a contributor for code (#891)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2026-05-03 12:03:52 +08:00
Vladimir FilonovandGitHub 1c816d4fc3 feat: add sha256 and hmac_sha256 filters for cryptographic operations (#889) 2026-05-03 12:03:26 +08:00
semantic-release-bot 34877950bf chore(release): 10.25.7 [skip ci]
## [10.25.7](https://github.com/harttle/liquidjs/compare/v10.25.6...v10.25.7) (2026-04-23)

### Bug Fixes

* **filters:** support Buffer input in base64_encode to prevent binary data corruption ([#881](https://github.com/harttle/liquidjs/issues/881)) ([0ee6dbb](https://github.com/harttle/liquidjs/commit/0ee6dbb511aa926f6d490293282060abf3bab37f))
2026-04-23 13:41:11 +00:00
Yang JunandGitHub 75c815a4d7 docs: add @talboren as financial contributor (#886) 2026-04-23 21:39:50 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
f1f896c29d docs: add talboren as a contributor for code (#885)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2026-04-23 21:31:50 +08:00
TalandGitHub 0ee6dbb511 fix(filters): support Buffer input in base64_encode to prevent binary data corruption (#881)
* fix: support Buffer input in base64_encode filter

When binary data (e.g. images, PDFs) is passed through the template
context as a Node.js Buffer, the base64_encode filter would call
stringify() on it first, which internally does String(value). This
triggers Buffer.toString() with the default 'utf-8' encoding, which
is a lossy conversion for non-UTF-8 byte sequences — invalid bytes
get replaced with U+FFFD, permanently destroying the original data.

The fix checks for Buffer.isBuffer() before stringify, and calls
buffer.toString('base64') directly, bypassing the lossy UTF-8
intermediate step. String inputs continue through the existing path
unchanged.

Made-with: Cursor

* fix: handle Buffer in filter layer to fix browser build

Move Buffer handling from base64-impl.ts (which gets swapped for the
browser impl at build time) into base64.ts (the filter layer). This
avoids a type error during the browser rollup build where the browser
impl only accepts string.

Also guard Buffer.isBuffer() with typeof Buffer !== 'undefined' for
safety in browser environments.

Made-with: Cursor
2026-04-23 21:30:58 +08:00
semantic-release-bot 30e04ba16d chore(release): 10.25.6 [skip ci]
## [10.25.6](https://github.com/harttle/liquidjs/compare/v10.25.5...v10.25.6) (2026-04-19)

### Bug Fixes

* nested block for layout ([#883](https://github.com/harttle/liquidjs/issues/883)) ([e2311df](https://github.com/harttle/liquidjs/commit/e2311dfd6e82f73509308aa8a3a1fafc92e226f0))
2026-04-19 15:42:53 +00:00
Yang JunandGitHub e2311dfd6e fix: nested block for layout (#883) 2026-04-19 23:41:35 +08:00
Yang JunandGitHub 2def22c85e docs(readme): add Kibana to README.md (#882)
* docs(readme): add Kibana and Sentry to Who's Using LiquidJS

Made-with: Cursor

* docs(readme): drop Semgrep and Sentry from Who's Using; keep Kibana

Made-with: Cursor

* docs(readme): restore Sentry in financial sponsors block

Made-with: Cursor

* docs(readme): restore Timmy Braun in all-contributors table

Made-with: Cursor
2026-04-19 21:38:15 +08:00
42 changed files with 673 additions and 148 deletions
+19 -1
View File
@@ -14,7 +14,7 @@
"login": "harttle",
"name": "Jun Yang",
"avatar_url": "https://avatars3.githubusercontent.com/u/4427974?v=4",
"profile": "https://harttle.land",
"profile": "https://github.com/harttle",
"contributions": [
"maintenance",
"code"
@@ -820,6 +820,24 @@
"contributions": [
"code"
]
},
{
"login": "talboren",
"name": "Tal",
"avatar_url": "https://avatars.githubusercontent.com/u/68807791?v=4",
"profile": "https://github.com/talboren",
"contributions": [
"code"
]
},
{
"login": "VladimirFilonov",
"name": "Vladimir Filonov",
"avatar_url": "https://avatars.githubusercontent.com/u/813224?v=4",
"profile": "https://filonov.dev",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
+17
View File
@@ -0,0 +1,17 @@
---
description: Testing conventions — e2e uses built dist, integration uses src
globs: test/**/*.ts
alwaysApply: false
---
# Testing
## End-to-end tests (`test/e2e`)
- **Use the built package**, not TypeScript sources under `src/`.
- Import the public API from the package root (for example `import { Liquid } from '../..'`), which resolves through `package.json` to **`dist/`** (`main`, `module`, etc.).
- **Avoid** `import … from '../../src/liquid'` (or other `src/` paths) in `test/e2e/**` so e2e matches what consumers get from npm and you do not depend on an unbuilt tree.
## Integration and unit tests
- Tests under `test/integration/`, `src/**/*.spec.ts`, and similar may import from **`src/`** when the suite is meant to run against the current TypeScript sources (typical for this repos Jest setup).
+14
View File
@@ -1,3 +1,17 @@
## [10.25.7](https://github.com/harttle/liquidjs/compare/v10.25.6...v10.25.7) (2026-04-23)
### Bug Fixes
* **filters:** support Buffer input in base64_encode to prevent binary data corruption ([#881](https://github.com/harttle/liquidjs/issues/881)) ([0ee6dbb](https://github.com/harttle/liquidjs/commit/0ee6dbb511aa926f6d490293282060abf3bab37f))
## [10.25.6](https://github.com/harttle/liquidjs/compare/v10.25.5...v10.25.6) (2026-04-19)
### Bug Fixes
* nested block for layout ([#883](https://github.com/harttle/liquidjs/issues/883)) ([e2311df](https://github.com/harttle/liquidjs/commit/e2311dfd6e82f73509308aa8a3a1fafc92e226f0))
## [10.25.5](https://github.com/harttle/liquidjs/compare/v10.25.4...v10.25.5) (2026-04-07)
+6 -2
View File
@@ -54,9 +54,9 @@ For more details, refer to the [Setup Guide][setup].
- [Eleventy](https://www.11ty.dev/): Eleventy, a simpler static site generator.
- [Github Docs](https://github.com/github/docs): The open-source repo for docs.github.com.
- [Kibana](https://github.com/elastic/kibana): Elastic's analytics and visualization platform for Elasticsearch; workflow features use LiquidJS for Liquid templates.
- [Opensense](https://www.opensense.com/): The smarter way to send email.
- [Directus](https://docs.directus.io/): an instant REST+GraphQL API and intuitive no-code data collaboration app for any SQL database.
- [Semgrep](https://github.com/returntocorp/semgrep): Lightweight static analysis for many languages.
- [Rock](https://www.rockrms.com/): An open source CMS, Relationship Management System (RMS) and Church Management System (ChMS) all rolled into one.
- [Mitosis](https://github.com/BuilderIO/mitosis): Write components once, run everywhere. Compiles to React, Vue, Qwik, Solid, Angular, Svelte, and more.
- [Pattern Lab](https://patternlab.io/): a frontend workshop environment that helps you build, view, test, and showcase your design system's UI components.
@@ -64,6 +64,7 @@ For more details, refer to the [Setup Guide][setup].
- [Microsoft Power Pages](https://learn.microsoft.com/en-us/power-pages/introduction): a secure, enterprise-grade, low-code software as a service (SaaS) platform for creating, hosting, and administering modern external-facing business websites.
- [Azure API Management developer portal](https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-developer-portal): an automatically generated, fully customizable website with the documentation of your APIs.
- [WISMOlabs](https://wismolabs.com/): Post Purchase Experience platform for eCommerce retailers enhancing customer satisfaction by using LiquidJS to provide customizable post-purchase experiences through programmable email, SMS, order tracking pages, and webhooks.
- [Freshet](https://chromewebstore.google.com/detail/freshet/mpclplhdencffbilobpcapccnihpelcg): *JSON in, page out* — a Chrome extension that uses LiquidJS templates per URL pattern, so the JSON becomes a rendered, useful page.
Feel free to create a PR or contact me to add your use case into this list!
@@ -82,6 +83,7 @@ If you personally love LiquidJS or it's benefiting your business, please conside
<a href="https://syntax.fm/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/130389858?v=4&s=100" height="80" style="vertical-align: middle;" alt="Syntax Podcast" title="Syntax Podcast"/></a>
<br/>
<a href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=liquidjs" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/27130435?s=200&v=4" width="80" style="vertical-align: middle;" alt="TestMu AI" title="TestMu AI"/></a>
<a href="https://github.com/talboren" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/68807791?v=4&s=100" height="80" style="vertical-align: middle;" alt="Tal" title="Tal (@talboren)"/></a>
<a href="https://chudovo.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/Chudovo/avatar/256.png?height=100" width="160" style="vertical-align: middle;background: white;padding: 8px 16px;" alt="Chudovo" title="Chudovo"/></a>
<a href="https://dailycontributors.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/dailycontributors/3c2e057/logo/256.png?height=50&width=100" width="120" style="vertical-align: middle;" alt="Dailycontributors" title="Dailycontributors"/></a>
<a href="https://www.pakstyle.pk/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/pakstyle/2b81605/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="PakStyle.pk" title="PakStyle.pk"/></a>
@@ -108,7 +110,7 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://harttle.land"><img src="https://avatars3.githubusercontent.com/u/4427974?v=4?s=100" width="100px;" alt="Jun Yang"/><br /><sub><b>Jun Yang</b></sub></a><br /><a href="#maintenance-harttle" title="Maintenance">🚧</a> <a href="https://github.com/harttle/liquidjs/commits?author=harttle" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/harttle"><img src="https://avatars3.githubusercontent.com/u/4427974?v=4?s=100" width="100px;" alt="Jun Yang"/><br /><sub><b>Jun Yang</b></sub></a><br /><a href="#maintenance-harttle" title="Maintenance">🚧</a> <a href="https://github.com/harttle/liquidjs/commits?author=harttle" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chenos"><img src="https://avatars0.githubusercontent.com/u/2993310?v=4?s=100" width="100px;" alt="chenos"/><br /><sub><b>chenos</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=chenos" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://zachleat.com/"><img src="https://avatars2.githubusercontent.com/u/39355?v=4?s=100" width="100px;" alt="Zach Leatherman"/><br /><sub><b>Zach Leatherman</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/issues?q=author%3Azachleat" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/thardy"><img src="https://avatars3.githubusercontent.com/u/120636?v=4?s=100" width="100px;" alt="Tim Hardy"/><br /><sub><b>Tim Hardy</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=thardy" title="Code">💻</a></td>
@@ -221,6 +223,8 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
<td align="center" valign="top" width="14.28%"><a href="https://doruk.ch"><img src="https://avatars.githubusercontent.com/u/77903714?v=4?s=100" width="100px;" alt="Peak Twilight"/><br /><sub><b>Peak Twilight</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=peaktwilight" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/joecottam"><img src="https://avatars.githubusercontent.com/u/44173086?v=4?s=100" width="100px;" alt="Joe Cottam"/><br /><sub><b>Joe Cottam</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=joecottam" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/timbze"><img src="https://avatars.githubusercontent.com/u/35117769?v=4?s=100" width="100px;" alt="Timmy Braun"/><br /><sub><b>Timmy Braun</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=timbze" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/talboren"><img src="https://avatars.githubusercontent.com/u/68807791?v=4?s=100" width="100px;" alt="Tal"/><br /><sub><b>Tal</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=talboren" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://filonov.dev"><img src="https://avatars.githubusercontent.com/u/813224?v=4?s=100" width="100px;" alt="Vladimir Filonov"/><br /><sub><b>Vladimir Filonov</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=VladimirFilonov" title="Code">💻</a></td>
</tr>
</tbody>
</table>
+1 -1
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
---
title: hmac_sha256
---
{% since %}vNEXT{% endsince %}
Converts a string into an SHA-256 hash using a hash message authentication code (HMAC). The secret key is passed as the filter argument. The output is a lowercase hexadecimal string.
Input
```liquid
{%- assign secret_potion = 'Polyjuice' | hmac_sha256: 'Polina' -%}
My secret potion: {{ secret_potion }}
```
Output
```text
My secret potion: 8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20
```
+1
View File
@@ -16,5 +16,6 @@ Array | slice, map, sort, sort_natural, uniq, where, where_exp, group_by, group_
Date | date, date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string
Misc | default, json, jsonify, inspect, raw, to_integer
Base64 | base64_encode, base64_decode
Crypto | sha256, hmac_sha256
[shopify/liquid]: https://github.com/Shopify/liquid
+20
View File
@@ -0,0 +1,20 @@
---
title: sha256
---
{% since %}vNEXT{% endsince %}
Converts a string into an SHA-256 hash. The output is a lowercase hexadecimal string.
Input
```liquid
{%- assign secret_potion = 'Polyjuice' | sha256 -%}
My secret potion: {{ secret_potion }}
```
Output
```text
My secret potion: 44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0
```
-57
View File
@@ -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
+76
View File
@@ -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
-55
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -51,7 +51,7 @@ sidebar:
plugins: 插件
operators: 运算符
truth: 真和假
dos: DoS
security_model: 安全模型
static_analysis: 静态分析
miscellaneous: 其他
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "liquidjs",
"version": "10.25.5",
"version": "10.25.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liquidjs",
"version": "10.25.5",
"version": "10.25.7",
"license": "MIT",
"dependencies": {
"commander": "^10.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "liquidjs",
"version": "10.25.5",
"version": "10.25.7",
"sideEffects": false,
"description": "A simple, expressive and safe Shopify / Github Pages compatible template engine in pure JavaScript.",
"main": "dist/liquid.node.js",
+10 -2
View File
@@ -50,6 +50,11 @@ const browserBase64 = {
delimiters: ['', ''],
'./base64-impl': '../build/base64-impl-browser'
}
const browserCrypto = {
include: './src/filters/crypto.ts',
delimiters: ['', ''],
'./crypto-impl': '../build/crypto-impl-browser'
}
const browserStream = {
include: './src/emitters/index.ts',
delimiters: ['', ''],
@@ -67,7 +72,7 @@ const nodeCjs = {
format: 'cjs',
banner
}],
external: ['path', 'fs', 'stream'],
external: ['path', 'fs', 'stream', 'crypto'],
plugins: [versionInjection, typescript(tsconfig('ES2020'))],
treeshake,
input
@@ -79,7 +84,7 @@ const nodeEsm = {
format: 'esm',
banner
}],
external: ['path', 'fs', 'stream'],
external: ['path', 'fs', 'stream', 'crypto'],
plugins: [
versionInjection,
replace(esmRequire),
@@ -100,6 +105,7 @@ const browserEsm = {
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('es6'))
],
@@ -119,6 +125,7 @@ const browserUmd = {
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('es5'))
],
@@ -138,6 +145,7 @@ const browserMin = {
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('es5')),
uglify()
+45
View File
@@ -0,0 +1,45 @@
import { webcrypto } from 'crypto'
import * as cryptoImpl from './crypto-impl-browser'
describe('crypto-impl/browser', function () {
beforeEach(function () {
Object.defineProperty(global, 'crypto', {
value: webcrypto,
writable: true,
configurable: true
})
})
afterEach(function () {
delete (global as any).crypto
})
describe('#sha256()', function () {
it('should hash the Shopify reference example', async function () {
expect(await cryptoImpl.sha256('Polyjuice'))
.toBe('44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0')
})
it('should hash an empty string', async function () {
expect(await cryptoImpl.sha256(''))
.toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
})
it('should hash unicode characters', async function () {
expect(await cryptoImpl.sha256('你好世界'))
.toBe('beca6335b20ff57ccc47403ef4d9e0b8fccb4442b3151c2e7d50050673d43172')
})
})
describe('#hmacSha256()', function () {
it('should hash the Shopify reference example', async function () {
expect(await cryptoImpl.hmacSha256('Polyjuice', 'Polina'))
.toBe('8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20')
})
it('should hash an empty message with a key', async function () {
expect(await cryptoImpl.hmacSha256('', 'key'))
.toBe('5d5d139563c95b5967b9bd9a8c9b233a9dedb45072794cd232dc1b74832607d0')
})
})
})
+27
View File
@@ -0,0 +1,27 @@
function bufferToHex (buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let hex = ''
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0')
}
return hex
}
export async function sha256 (str: string): Promise<string> {
const data = new TextEncoder().encode(str)
const digest = await crypto.subtle.digest('SHA-256', data)
return bufferToHex(digest)
}
export async function hmacSha256 (str: string, key: string): Promise<string> {
const encoder = new TextEncoder()
const cryptoKey = await crypto.subtle.importKey(
'raw',
encoder.encode(key),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(str))
return bufferToHex(signature)
}
+4 -3
View File
@@ -48,8 +48,8 @@ export class Context {
this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit)
this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit))
}
public getRegister (key: string) {
return (this.registers[key] = this.registers[key] || {})
public getRegister<T> (key: string, defaultValue: T = undefined as T): T {
return (this.registers[key] = this.registers[key] || defaultValue)
}
public setRegister (key: string, value: any) {
return (this.registers[key] = value)
@@ -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
+5 -1
View File
@@ -8,7 +8,11 @@ import { FilterImpl } from '../template'
import { stringify } from '../util'
import { base64Encode, base64Decode } from './base64-impl'
export function base64_encode (this: FilterImpl, value: string): string {
export function base64_encode (this: FilterImpl, value: string | Buffer): string {
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {
this.context.memoryLimit.use(value.byteLength)
return value.toString('base64')
}
const str = stringify(value)
this.context.memoryLimit.use(str.length)
return base64Encode(str)
+9
View File
@@ -0,0 +1,9 @@
import { createHash, createHmac } from 'crypto'
export function sha256 (str: string): string {
return createHash('sha256').update(str, 'utf8').digest('hex')
}
export function hmacSha256 (str: string, key: string): string {
return createHmac('sha256', key).update(str, 'utf8').digest('hex')
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Crypto related filters
*
* Implements sha256 and hmac_sha256 filters for Shopify compatibility
*/
import { FilterImpl } from '../template'
import { stringify } from '../util'
import { sha256 as sha256Impl, hmacSha256 as hmacSha256Impl } from './crypto-impl'
export function sha256 (this: FilterImpl, value: unknown): string | Promise<string> {
const str = stringify(value)
this.context.memoryLimit.use(str.length)
return sha256Impl(str)
}
export function hmac_sha256 (this: FilterImpl, value: unknown, key: unknown): string | Promise<string> {
const str = stringify(value)
const keyStr = stringify(key)
this.context.memoryLimit.use(str.length + keyStr.length)
return hmacSha256Impl(str, keyStr)
}
+7 -5
View File
@@ -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 {
+18 -1
View File
@@ -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]*?-->/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
}
+2
View File
@@ -5,6 +5,7 @@ import * as arrayFilters from './array'
import * as dateFilters from './date'
import * as stringFilters from './string'
import * as base64Filters from './base64'
import * as cryptoFilters from './crypto'
import misc from './misc'
import { FilterImplOptions } from '../template'
@@ -16,5 +17,6 @@ export const filters: Record<string, FilterImplOptions> = {
...dateFilters,
...stringFilters,
...base64Filters,
...cryptoFilters,
...misc
}
+1
View File
@@ -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())
+8 -3
View File
@@ -23,20 +23,25 @@ export default class extends Tag {
* render (ctx: Context, emitter: Emitter) {
const blockRender = this.getBlockRender(ctx)
if (ctx.getRegister('blockMode') === BlockMode.STORE) {
ctx.getRegister('blocks')[this.block] = blockRender
ctx.getRegister('blocks', {} as Record<string, any>)[this.block] = blockRender
} else {
yield blockRender(new BlockDrop(), emitter)
}
}
private getBlockRender (ctx: Context) {
const self = this as Tag
const { liquid, templates } = this
const renderChild = ctx.getRegister('blocks')[this.block]
const renderChild = ctx.getRegister('blocks', {} as Record<string, any>)[this.block]
const renderCurrent = function * (superBlock: BlockDrop, emitter: Emitter) {
// add {{ block.super }} support when rendering
const stack: Tag[] = ctx.getRegister('blockStack', [])
if (stack.includes(self)) throw new Error('block tag cannot be nested')
stack.push(self)
ctx.push({ block: superBlock })
yield liquid.renderer.renderTemplates(templates, ctx, emitter)
ctx.pop()
stack.pop()
}
return renderChild
? (superBlock: BlockDrop, emitter: Emitter) => renderChild(
+1 -1
View File
@@ -27,7 +27,7 @@ export default class extends Tag {
* render (ctx: Context, emitter: Emitter): Generator<unknown, unknown, unknown> {
const group = (yield evalToken(this.group, ctx)) as ValueToken
const fingerprint = `cycle:${group}:` + this.candidates.join(',')
const groups = ctx.getRegister('cycle')
const groups = ctx.getRegister('cycle', {} as Record<string, number>)
let idx = groups[fingerprint]
if (idx === undefined) {
+1 -1
View File
@@ -50,7 +50,7 @@ export default class extends Tag {
}
const continueKey = 'continue-' + this.variable + '-' + this.collection.getText()
ctx.push({ continue: ctx.getRegister(continueKey) })
ctx.push({ continue: ctx.getRegister(continueKey, {}) })
const hash = yield this.hash.render(ctx)
ctx.pop()
+1 -1
View File
@@ -32,7 +32,7 @@ export default class extends Tag {
// render remaining contents and store rendered results
ctx.setRegister('blockMode', BlockMode.STORE)
const html = yield renderer.renderTemplates(this.templates, ctx)
const blocks = ctx.getRegister('blocks')
const blocks = ctx.getRegister('blocks', {} as Record<string, any>)
// set whole content to anonymous block if anonymous doesn't specified
if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html)
+8 -4
View File
@@ -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,25 +121,25 @@ 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()
@@ -144,5 +147,6 @@ function format (d: LiquidDate, match: RegExpExecArray) {
if (flags['_']) padChar = ' '
else if (flags['0']) padChar = '0'
if (flags['-']) padWidth = 0
memoryLimit?.use(Number(padWidth) - ret.length)
return padStart(ret, padWidth, padChar)
}
+3 -3
View File
@@ -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 {
+35
View File
@@ -82,4 +82,39 @@ describe('.parseAndRender()', function () {
expect(() => e.parseAndRenderSync('{% render "link" %}')).toThrow(/ENOENT|Failed to lookup/)
})
})
describe('layout: nested {% block %} regression', function () {
let root: string
beforeEach(function () {
root = mkdtempSync(join(tmpdir(), 'liquid-e2e-layout-nested-'))
})
afterEach(function () {
rmSync(root, { recursive: true, force: true })
})
it('should reject same-name {% block %} nested in child template (no hang / OOM)', async function () {
writeFileSync(
join(root, 'layout.html'),
'<header>{% block a %}default-a{% endblock %}</header>' +
'<main>{% block b %}default-b{% endblock %}</main>' +
'<footer>{% block c %}default-c{% endblock %}</footer>'
)
writeFileSync(
join(root, 'template.html'),
'{% layout "layout" %}' +
'{% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %}' +
'{% block b %}content-b{% endblock %}' +
'{% block c %}content-c{% endblock %}'
)
const liquid = new Liquid({ root, extname: '.html' })
await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/)
})
it('should reject nested anonymous {% block %} in child template (no hang / OOM)', async function () {
writeFileSync(join(root, 'parent.html'), 'X{%block%}{%endblock%}Y')
writeFileSync(
join(root, 'template.html'),
'{% layout "parent" %}{%block%}A{%block%}B{%endblock%}{%endblock%}'
)
const liquid = new Liquid({ root, extname: '.html' })
await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/)
})
})
})
+28 -1
View File
@@ -1,4 +1,4 @@
import { test } from '../../stub/render'
import { test, liquid } from '../../stub/render'
describe('filters/base64', function () {
describe('base64_encode', function () {
@@ -65,6 +65,33 @@ describe('filters/base64', function () {
})
})
describe('base64_encode with Buffer input', function () {
it('should encode a Buffer to base64 without data corruption', async () => {
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe])
const result = await liquid.parseAndRender('{{ data | base64_encode }}', { data: buf })
expect(result).toBe(buf.toString('base64'))
})
it('should preserve bytes that are invalid UTF-8', async () => {
const buf = Buffer.from([0x80, 0xff, 0xfe, 0x00, 0x01])
const result = await liquid.parseAndRender('{{ data | base64_encode }}', { data: buf })
const decoded = Buffer.from(result, 'base64')
expect(decoded).toEqual(buf)
})
it('should handle an empty Buffer', async () => {
const buf = Buffer.alloc(0)
const result = await liquid.parseAndRender('{{ data | base64_encode }}', { data: buf })
expect(result).toBe('')
})
it('should handle a Buffer containing valid UTF-8 text', async () => {
const buf = Buffer.from('Hello World', 'utf8')
const result = await liquid.parseAndRender('{{ data | base64_encode }}', { data: buf })
expect(result).toBe(Buffer.from('Hello World').toString('base64'))
})
})
describe('base64 round-trip', function () {
it('should encode and decode back to original', () => {
return test('{{ "Hello, World!" | base64_encode | base64_decode }}', 'Hello, World!')
+70
View File
@@ -0,0 +1,70 @@
import { test } from '../../stub/render'
const SHA256_EMPTY = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
describe('filters/crypto', function () {
describe('sha256', function () {
it('should hash the Shopify reference example', () => {
return test(
'{{ "Polyjuice" | sha256 }}',
'44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0'
)
})
it('should hash an empty string', () => {
return test('{{ "" | sha256 }}', SHA256_EMPTY)
})
it('should treat undefined as empty string', () => {
return test('{{ foo | sha256 }}', SHA256_EMPTY)
})
it('should treat null as empty string', () => {
return test('{{ null | sha256 }}', SHA256_EMPTY)
})
it('should stringify numeric input', () => {
return test(
'{{ 123 | sha256 }}',
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'
)
})
it('should stringify boolean input', () => {
return test(
'{{ true | sha256 }}',
'b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b'
)
})
})
describe('hmac_sha256', function () {
it('should hash the Shopify reference example', () => {
return test(
"{{ 'Polyjuice' | hmac_sha256: 'Polina' }}",
'8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20'
)
})
it('should accept a numeric key (stringified)', () => {
return test(
"{{ 'hello' | hmac_sha256: 42 }}",
'3bdadea6ed0e95ededc15dc4421ce7654c970156843dfd997be3fef5358168ca'
)
})
it('should hash an empty message with an empty key', () => {
return test(
"{{ '' | hmac_sha256: '' }}",
'b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad'
)
})
it('should treat undefined input as empty string', () => {
return test(
"{{ foo | hmac_sha256: '' }}",
'b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad'
)
})
})
})
+27
View File
@@ -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()
+8
View File
@@ -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?')
@@ -77,5 +80,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('')
})
})
})
+35
View File
@@ -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 () => {
@@ -69,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)
})
})
+1 -1
View File
@@ -50,7 +50,7 @@ describe('tags/include', function () {
})
return liquid.renderFile('/parent.html').catch(function (e) {
expect(e.name).toBe('TokenizationError')
expect(e.message).toMatch('illegal file path, file:/parent.html, line:1, col:11')
expect(e.message).toMatch(/illegal file path, file:.*parent.html, line:1, col:11/)
})
})
+21
View File
@@ -166,6 +166,27 @@ describe('tags/layout', function () {
const html = await liquid.renderFile('/main.html')
return expect(html).toBe('XAY')
})
it('should reject nested {% block %} with the same name (no OOM / hang)', function () {
mock({
'/layout.html':
'<header>{% block a %}default-a{% endblock %}</header>' +
'<main>{% block b %}default-b{% endblock %}</main>' +
'<footer>{% block c %}default-c{% endblock %}</footer>',
'/template.html':
'{% layout "layout" %}' +
'{% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %}' +
'{% block b %}content-b{% endblock %}' +
'{% block c %}content-c{% endblock %}'
})
return expect(liquid.renderFile('/template.html')).rejects.toThrow(/block tag cannot be nested/)
})
it('should reject nested anonymous {% block %} (no OOM / hang)', function () {
mock({
'/parent.html': 'X{%block%}{%endblock%}Y'
})
const src = '{% layout "parent.html" %}{%block%}A{%block%}B{%endblock%}{%endblock%}'
return expect(liquid.parseAndRender(src)).rejects.toThrow(/block tag cannot be nested/)
})
it('should not bleed scope into `include` layout', async function () {
mock({
'/parent.html': 'X{%block a%}{%endblock%}Y{%block b%}{%endblock%}Z',
+21
View File
@@ -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(() => {