Compare commits

..
Author SHA1 Message Date
Yang JunandCursor 7910e4996c fix: enforce renderLimit for empty renderTemplates calls
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 22:39:56 +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
semantic-release-bot 4af7be695c chore(release): 10.25.5 [skip ci]
## [10.25.5](https://github.com/harttle/liquidjs/compare/v10.25.4...v10.25.5) (2026-04-07)

### Bug Fixes

* enforce root containment for renderFile/parseFile lookups ([#870](https://github.com/harttle/liquidjs/issues/870)) ([f41c1fc](https://github.com/harttle/liquidjs/commit/f41c1fc02fe901598f3328118b42b13bc6bc9b04))
* null date should return empty ([#868](https://github.com/harttle/liquidjs/issues/868)) ([#872](https://github.com/harttle/liquidjs/issues/872)) ([4f9a499](https://github.com/harttle/liquidjs/commit/4f9a49988a93c156524981e189a4fec238e682b8))
* rounding negative away from zero when half ([#873](https://github.com/harttle/liquidjs/issues/873)) ([1cdf10b](https://github.com/harttle/liquidjs/commit/1cdf10b57d82f0592414efbfca19e204b37aea9f))
2026-04-07 17:18:16 +00:00
Yang JunandGitHub 05c47da46d refactor: replace shell scripts with JS for cross-platform support (#875)
Convert bin/ shell scripts to Node.js and npm scripts using shx and npm-run-all2. Remove unused build-icons.sh. Inlined simple scripts (build-docs-liquid, build-apidoc) as npm scripts.

Made-with: Cursor
2026-04-08 01:16:49 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
66011d14b0 docs: add timbze as a contributor for code (#874)
* 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-08 00:53:27 +08:00
Timmy BraunandGitHub 1cdf10b57d fix: rounding negative away from zero when half (#873) 2026-04-08 00:52:35 +08:00
Timmy BraunandGitHub 4f9a49988a fix: null date should return empty (#868) (#872) 2026-04-08 00:10:33 +08:00
Yang JunandGitHub f41c1fc02f fix: enforce root containment for renderFile/parseFile lookups (#870)
Made-with: Cursor
2026-04-07 23:18:53 +08:00
40 changed files with 491 additions and 34 deletions
+28 -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"
@@ -811,6 +811,33 @@
"contributions": [
"code"
]
},
{
"login": "timbze",
"name": "Timmy Braun",
"avatar_url": "https://avatars.githubusercontent.com/u/35117769?v=4",
"profile": "https://github.com/timbze",
"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).
+23
View File
@@ -1,3 +1,26 @@
## [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)
### Bug Fixes
* enforce root containment for renderFile/parseFile lookups ([#870](https://github.com/harttle/liquidjs/issues/870)) ([f41c1fc](https://github.com/harttle/liquidjs/commit/f41c1fc02fe901598f3328118b42b13bc6bc9b04))
* null date should return empty ([#868](https://github.com/harttle/liquidjs/issues/868)) ([#872](https://github.com/harttle/liquidjs/issues/872)) ([4f9a499](https://github.com/harttle/liquidjs/commit/4f9a49988a93c156524981e189a4fec238e682b8))
* rounding negative away from zero when half ([#873](https://github.com/harttle/liquidjs/issues/873)) ([1cdf10b](https://github.com/harttle/liquidjs/commit/1cdf10b57d82f0592414efbfca19e204b37aea9f))
## [10.25.4](https://github.com/harttle/liquidjs/compare/v10.25.3...v10.25.4) (2026-04-07)
+6 -1
View File
@@ -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>
@@ -220,6 +222,9 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
<td align="center" valign="top" width="14.28%"><a href="https://github.com/MorielHarush"><img src="https://avatars.githubusercontent.com/u/93482738?v=4?s=100" width="100px;" alt="MorielHarush"/><br /><sub><b>MorielHarush</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=MorielHarush" title="Code">💻</a></td>
<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>
+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
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "liquidjs",
"version": "10.25.4",
"version": "10.25.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liquidjs",
"version": "10.25.4",
"version": "10.25.7",
"license": "MIT",
"dependencies": {
"commander": "^10.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "liquidjs",
"version": "10.25.4",
"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)
}
+3 -1
View File
@@ -45,7 +45,9 @@ function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffse
const defaultTimezoneOffset = timezoneOffset ?? opts.timezoneOffset
const locale = opts.locale
v = toValue(v)
if (v === 'now' || v === 'today') {
if (isNil(v)) {
return undefined
} else if (v === 'now' || v === 'today') {
date = new LiquidDate(Date.now(), locale, defaultTimezoneOffset)
} else if (isNumber(v)) {
date = new LiquidDate(v * 1000, locale, defaultTimezoneOffset)
+1 -1
View File
@@ -45,5 +45,5 @@ export function newline_to_br (this: FilterImpl, v: string) {
export function strip_html (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<.*?>|<!--[\s\S]*?-->/g, '')
return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<[\s\S]*?>|<!--[\s\S]*?-->/g, '')
}
+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
}
+3 -1
View File
@@ -15,5 +15,7 @@ export function round (v: number, arg = 0) {
v = toNumber(v)
arg = toNumber(arg)
const amp = Math.pow(10, arg)
return Math.round(v * amp) / amp
const scaled = v * amp
// Round half away from zero
return Math.sign(v) * Math.round(Math.abs(scaled)) / amp
}
+6
View File
@@ -30,5 +30,11 @@ describe('fs/loader', function () {
const result = toValueSync(loader.lookup('./foo/bar', LookupType.Partials, true, '/root/current'))
expect(result).toBe(resolve('/root/foo/bar'))
})
it('should enforce containment for LookupType.Root', function () {
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
const loader = new Loader({ relativeReference: false, fs: mockFs, extname: '', root: ['/safe'] } as any)
expect(() => toValueSync(loader.lookup('/etc/hosts', LookupType.Root, true)))
.toThrow(/ENOENT/)
})
})
})
+4 -7
View File
@@ -43,15 +43,12 @@ export class Loader {
public * lookup (file: string, type: LookupType, sync?: boolean, currentFile?: string): Generator<unknown, string, string> {
const dirs = this.options[type]
const enforceRoot = type !== LookupType.Root
for (const filepath of this.candidates(file, dirs, currentFile)) {
if (enforceRoot) {
let allowed = false
for (const dir of dirs) {
if (yield this.contains(!!sync, dir, filepath)) { allowed = true; break }
}
if (!allowed) continue
let allowed = false
for (const dir of dirs) {
if (yield this.contains(!!sync, dir, filepath)) { allowed = true; break }
}
if (!allowed) continue
if (yield this.exists(!!sync, filepath)) return filepath
}
throw this.lookupError(file, dirs)
+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)
+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/)
})
})
})
+1
View File
@@ -38,6 +38,7 @@ describe('#renderFile()', function () {
return expect(html).toContain('"name": "liquidjs"')
})
it('should render file with context', async function () {
engine = new Liquid({ root: views, extname: '.html' })
const html = await engine.renderFile(resolve(views, 'name.html'), { name: 'harttle' })
return expect(html).toBe('My name is harttle.')
})
+2 -2
View File
@@ -4,8 +4,8 @@ import { drainStream } from '../stub/stream'
describe('.renderToNodeStream()', function () {
it('should render to stream in Node.js', done => {
const cjs = require('../../dist/liquid.node')
const engine = new cjs.Liquid()
const tpl = engine.parseFileSync(resolve(__dirname, '../stub/root/foo.html'))
const engine = new cjs.Liquid({ root: resolve(__dirname, '../stub/root/') })
const tpl = engine.parseFileSync('foo.html')
const stream = engine.renderToNodeStream(tpl)
let html = ''
stream.on('data', (data: string) => { html += data })
+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'
)
})
})
})
+6 -2
View File
@@ -21,8 +21,12 @@ describe('filters/date', function () {
const time = String(new Date('2017-03-07T12:00:00').getTime() / 1000)
return test('{{ time | date: "%Y-%m-%dT%H:%M:%S" }}', { time }, '2017-03-07T12:00:00')
})
it('should treat nil as 0', () => {
expect(liquid.parseAndRenderSync('{{ nil | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}')).toEqual('1970-01-01T08:00:00')
it('should treat null as invalid', () => {
const time = null
return test('{{ time | date: "%Y-%m-%dT%H:%M:%S" }}', { time }, '')
})
it('should treat nil as invalid', () => {
expect(liquid.parseAndRenderSync('{{ nil | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}')).toEqual('')
})
it('should treat undefined as invalid', () => {
expect(liquid.parseAndRenderSync('{{ num | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}', { num: undefined })).toEqual('')
+5
View File
@@ -77,5 +77,10 @@ describe('filters/html', function () {
it('should strip until empty', function () {
return test('{{"<br/><br />< p ></p></ p >" | strip_html }}', '')
})
it('should strip generic tags spanning ASCII newlines inside the tag', function () {
expect(liquid.parseAndRenderSync('{{"<img\nsrc=x\nonerror=alert(1)>" | strip_html}}')).toBe('')
expect(liquid.parseAndRenderSync('{{"<img\rsrc=x\ronerror=alert(1)>" | strip_html}}')).toBe('')
expect(liquid.parseAndRenderSync('{{"<svg\nonload=alert(1)>" | strip_html}}')).toBe('')
})
})
})
+2
View File
@@ -65,6 +65,8 @@ describe('filters/math', function () {
describe('round', function () {
it('should return "1" for 1.2', () => test('{{1.2|round}}', '1'))
it('should return "3" for 2.7', () => test('{{2.7|round}}', '3'))
it('should return "-3" for -2.5 (away from zero)', () => test('{{num|round}}', { num: -2.5 }, '-3'))
it('should return "-2" for -2.49 (closest integer)', () => test('{{num|round}}', { num: -2.49 }, '-2'))
it('should return "183.36" for 183.357,2',
() => test('{{183.357|round: 2}}', '183.36'))
it('should convert string to number', () => test('{{"2.7"|round}}', '3'))
+10
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 () => {
+18 -1
View File
@@ -109,6 +109,7 @@ describe('Liquid', function () {
})
})
describe('#renderFile', function () {
afterEach(restore)
it('should throw with lookup list when file not exist', function () {
const engine = new Liquid({
root: ['/boo', '/root/'],
@@ -116,6 +117,22 @@ describe('Liquid', function () {
})
return expect(engine.renderFile('/not/exist.html')).rejects.toThrow(/Failed to lookup "\/not\/exist.html" in "\/boo,\/root\/"/)
})
it('should reject absolute paths outside root', async function () {
mock({
'/safe/foo.html': 'safe',
'/etc/secret': 'SECRET'
})
const engine = new Liquid({ root: ['/safe'] })
await expect(engine.renderFile('/etc/secret')).rejects.toThrow(/Failed to lookup/)
})
it('should reject absolute paths outside root (sync)', function () {
mock({
'/safe/foo.html': 'safe',
'/etc/secret': 'SECRET'
})
const engine = new Liquid({ root: ['/safe'] })
expect(() => engine.renderFileSync('/etc/secret')).toThrow(/Failed to lookup/)
})
})
describe('#parseFile', function () {
it('should throw with lookup list when file not exist', function () {
@@ -127,7 +144,7 @@ describe('Liquid', function () {
})
it('should fallback to require.resolve in Node.js', async function () {
const engine = new Liquid({
root: ['/root/'],
root: [process.cwd()],
extname: '.html'
})
const tpls = await engine.parseFileSync('jest')
+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(() => {