Compare commits

...
20 Commits
Author SHA1 Message Date
semantic-release-bot 8f69a08399 chore(release): 10.25.3 [skip ci]
## [10.25.3](https://github.com/harttle/liquidjs/compare/v10.25.2...v10.25.3) (2026-04-06)

### Bug Fixes

* precise memoryLimit for string replace ([abc058b](https://github.com/harttle/liquidjs/commit/abc058be0f33d6372cd2216f4945183167abeb25))
* use realpath for fs.contains ([#867](https://github.com/harttle/liquidjs/issues/867)) ([529dd67](https://github.com/harttle/liquidjs/commit/529dd67eeb6b125637623d6a723601f0938d3613))
2026-04-06 06:45:50 +00:00
Yang JunandGitHub 529dd67eeb fix: use realpath for fs.contains (#867)
* fix: use realpath for fs.contains

* chore: reset file mode changes

Made-with: Cursor

* fix: Windows compat for contains/containsSync and toLiquidAsync arg order

Made-with: Cursor
2026-04-06 14:40:35 +08:00
Harttle abc058be0f fix: precise memoryLimit for string replace 2026-03-26 19:36:31 +08:00
semantic-release-bot 521177e3f6 chore(release): 10.25.2 [skip ci]
## [10.25.2](https://github.com/harttle/liquidjs/compare/v10.25.1...v10.25.2) (2026-03-25)

### Bug Fixes

* handle undefined replacement argument in replace filter ([#864](https://github.com/harttle/liquidjs/issues/864)) ([0ad2b11](https://github.com/harttle/liquidjs/commit/0ad2b11ab15e7da608a9ef936b2a00a6a6517038))
2026-03-25 17:20:39 +00:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
75e06eff92 docs: add joecottam as a contributor for code (#865)
* 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-03-26 01:19:19 +08:00
Joe CottamandGitHub 0ad2b11ab1 fix: handle undefined replacement argument in replace filter (#864) 2026-03-26 01:18:40 +08:00
semantic-release-bot 97d829116c chore(release): 10.25.1 [skip ci]
## [10.25.1](https://github.com/harttle/liquidjs/compare/v10.25.0...v10.25.1) (2026-03-22)

### Bug Fixes

* mem limiter for invalid ranges ([95ddefc](https://github.com/harttle/liquidjs/commit/95ddefc056a11a44d9e753fd47a39db2c241e578))
* treat args for replace_first as literal ([35d5230](https://github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d))
2026-03-22 14:18:52 +00:00
Harttle 35d5230263 fix: treat args for replace_first as literal 2026-03-22 22:16:45 +08:00
Harttle 94440a0653 chore: more strict mem limit for string filters 2026-03-22 22:10:39 +08:00
Yang JunandHarttle 95ddefc056 fix: mem limiter for invalid ranges 2026-03-22 10:24:03 +08:00
Yang JunandGitHub 1b85fdaa9c docs: update contact in security.md (#862) 2026-03-08 15:41:45 +08:00
semantic-release-bot 93c38c7c6d chore(release): 10.25.0 [skip ci]
# [10.25.0](https://github.com/harttle/liquidjs/compare/v10.24.0...v10.25.0) (2026-03-07)

### Bug Fixes

* path traversal vulnerability, [#851](https://github.com/harttle/liquidjs/issues/851) ([#855](https://github.com/harttle/liquidjs/issues/855)) ([3cd024d](https://github.com/harttle/liquidjs/commit/3cd024d652dc883c46307581e979fe32302adbac))

### Features

* export error types, resolving [#837](https://github.com/harttle/liquidjs/issues/837) ([#840](https://github.com/harttle/liquidjs/issues/840)) ([71aa1b1](https://github.com/harttle/liquidjs/commit/71aa1b1998a3a66e536af67c6ea8947a28616eaf))
2026-03-07 20:01:42 +00:00
Yang JunandGitHub c7a291b46b chore: update semantic-release dependencies (#861) 2026-03-08 04:00:26 +08:00
Yang JunandGitHub eb4683ee3f chore: update to NPM Trusted Release (#860) 2026-03-08 03:35:49 +08:00
Yang JunandGitHub f1fc573a65 docs: state differences regarding inspect array/hash, #852, #853 (#858) 2026-03-08 03:14:13 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
524cd92cfe docs: add peaktwilight as a contributor for code (#857)
* 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-03-08 02:40:16 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
0d9e797889 docs: add MorielHarush as a contributor for code (#856)
* 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-03-08 02:39:45 +08:00
3cd024d652 fix: path traversal vulnerability, #851 (#855)
* Fix Path Traversal fallback

* Update loader.ts

Fixed nested

* Update loader.ts

padding fix

* refactor: reuse root enforcing

* docs: update test case and docs

---------

Co-authored-by: MorielHarush <[email protected]>
2026-03-08 02:36:09 +08:00
Yang JunandGitHub 85233e0568 docs: update testmu sponsor link (#850) 2026-02-14 13:54:03 +08:00
Yang JunandGitHub 02403a1879 docs: Change LambdaTest to TestMu AI (#848) 2026-01-19 23:26:49 +08:00
35 changed files with 3697 additions and 2016 deletions
+27
View File
@@ -784,6 +784,33 @@
"contributions": [
"code"
]
},
{
"login": "MorielHarush",
"name": "MorielHarush",
"avatar_url": "https://avatars.githubusercontent.com/u/93482738?v=4",
"profile": "https://github.com/MorielHarush",
"contributions": [
"code"
]
},
{
"login": "peaktwilight",
"name": "Peak Twilight",
"avatar_url": "https://avatars.githubusercontent.com/u/77903714?v=4",
"profile": "https://doruk.ch",
"contributions": [
"code"
]
},
{
"login": "joecottam",
"name": "Joe Cottam",
"avatar_url": "https://avatars.githubusercontent.com/u/44173086?v=4",
"profile": "https://github.com/joecottam",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
+17
View File
@@ -0,0 +1,17 @@
---
description: Architecture overview for liquidjs internals
alwaysApply: true
---
## Async/sync duality via generators
All core logic is written once as a `Generator` function (`function *`). Use `yield` where you'd normally `await` a potentially async value.
- `toPromise(generator)` drives it **asynchronously** — awaits yielded promises.
- `toValueSync(generator)` drives it **synchronously** — passes yielded values through as-is.
Never duplicate logic into separate async and sync methods. A single generator serves both paths.
When wrapping an async+sync function pair (e.g. `contains`/`containsSync`, `exists`/`existsSync`, `readFile`/`readFileSync`), use `toLiquidAsync(asyncFn, syncFn?)` which returns a `LiquidAsync<F>` — one function that picks the sync or async implementation based on a leading `sync: boolean` arg. Then `yield` the result inside a generator to let the driver handle it in both modes.
See `src/util/async.ts`.
+6
View File
@@ -0,0 +1,6 @@
---
description: Project conventions for liquidjs
alwaysApply: true
---
- Keep edits minimal: change only what the task requires, match existing style.
+5
View File
@@ -4,6 +4,11 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v3
+35
View File
@@ -1,3 +1,38 @@
## [10.25.3](https://github.com/harttle/liquidjs/compare/v10.25.2...v10.25.3) (2026-04-06)
### Bug Fixes
* precise memoryLimit for string replace ([abc058b](https://github.com/harttle/liquidjs/commit/abc058be0f33d6372cd2216f4945183167abeb25))
* use realpath for fs.contains ([#867](https://github.com/harttle/liquidjs/issues/867)) ([529dd67](https://github.com/harttle/liquidjs/commit/529dd67eeb6b125637623d6a723601f0938d3613))
## [10.25.2](https://github.com/harttle/liquidjs/compare/v10.25.1...v10.25.2) (2026-03-25)
### Bug Fixes
* handle undefined replacement argument in replace filter ([#864](https://github.com/harttle/liquidjs/issues/864)) ([0ad2b11](https://github.com/harttle/liquidjs/commit/0ad2b11ab15e7da608a9ef936b2a00a6a6517038))
## [10.25.1](https://github.com/harttle/liquidjs/compare/v10.25.0...v10.25.1) (2026-03-22)
### Bug Fixes
* mem limiter for invalid ranges ([95ddefc](https://github.com/harttle/liquidjs/commit/95ddefc056a11a44d9e753fd47a39db2c241e578))
* treat args for replace_first as literal ([35d5230](https://github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d))
# [10.25.0](https://github.com/harttle/liquidjs/compare/v10.24.0...v10.25.0) (2026-03-07)
### Bug Fixes
* path traversal vulnerability, [#851](https://github.com/harttle/liquidjs/issues/851) ([#855](https://github.com/harttle/liquidjs/issues/855)) ([3cd024d](https://github.com/harttle/liquidjs/commit/3cd024d652dc883c46307581e979fe32302adbac))
### Features
* export error types, resolving [#837](https://github.com/harttle/liquidjs/issues/837) ([#840](https://github.com/harttle/liquidjs/issues/840)) ([71aa1b1](https://github.com/harttle/liquidjs/commit/71aa1b1998a3a66e536af67c6ea8947a28616eaf))
# [10.24.0](https://github.com/harttle/liquidjs/compare/v10.23.0...v10.24.0) (2025-10-27)
+4 -1
View File
@@ -81,7 +81,7 @@ If you personally love LiquidJS or it's benefiting your business, please conside
<a href="https://customer.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/1152079?v=4&s=100" height="80" style="vertical-align: middle;" alt="Customer IO" title="Customer IO"/></a>
<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.lambdatest.com/?utm_source=liquidjs&utm_medium=sponsor" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://www.lambdatest.com/blue-logo.png" width="240" style="vertical-align: middle;" alt="LambdaTest" title="LambdaTest"/></a>
<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://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>
@@ -217,6 +217,9 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rongjiecomputer"><img src="https://avatars.githubusercontent.com/u/13115060?v=4?s=100" width="100px;" alt="Loo Rong Jie"/><br /><sub><b>Loo Rong Jie</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=rongjiecomputer" title="Code">💻</a></td>
<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>
</tr>
</tbody>
</table>
+1 -1
View File
@@ -6,7 +6,7 @@ Only the latest major version is supported with security updates. It can be chan
## Reporting a Vulnerability
Please contact yangjvn@126.com to report a vulnerability or change request.
Please contact harttleharttle@gmail.com to report a vulnerability or change request.
- If the vulnerability in question affects common use cases, it will be treated as a bug and fixed very soon (typically within 1 week).
- Otherwise, it'll be scheduled in the same priority of feature request (which is lower than bugs).
+1 -1
View File
@@ -8,7 +8,7 @@
"test": "echo not implemented",
"start": "http-server -c-1 "
},
"author": "harttle <yangjvn@126.com>",
"author": "harttle <harttleharttle@gmail.com>",
"license": "ISC",
"dependencies": {
"http-server": "^0.11.1",
+1 -1
View File
@@ -8,7 +8,7 @@ const engine = new Liquid({
// layout files for `{% layout %}`
layouts: process.cwd() + '/layouts',
// partial files for `{% include %}` and `{% render %}`
partials: process.cwd() + '/partials'
partials: [process.cwd() + '/partials', 'node_modules']
})
const ctx = {
+1 -1
View File
@@ -1,3 +1,3 @@
set -ex
set -e
npm start | grep 'LiquidJS Demo'
+1 -1
View File
@@ -1,4 +1,4 @@
set -x
set -e
LOG_FILE=$(mktemp)
npm start > $LOG_FILE 2>&1 &
+1 -1
View File
@@ -1,3 +1,3 @@
set -ex
set -e
npm start | grep 'NodeJS Demo for LiquidJS'
+1 -1
View File
@@ -1,3 +1,3 @@
set -ex
set -e
npm start | grep '\[11:8] {{ todo }}'
+1 -1
View File
@@ -1,3 +1,3 @@
set -ex
set -e
npm run build && npm start | grep 'TypeScript Demo for LiquidJS'
+1 -1
View File
@@ -1,4 +1,4 @@
set -ex
set -e
npm run build
npm start | grep 'Webpack Demo for LiquidJS'
+2
View File
@@ -24,6 +24,7 @@ Though we're trying to be compatible with the Ruby version, there are still some
* Truthy and Falsy. All values except `undefined`, `null`, `false` are truthy, whereas in Ruby Liquid all except `nil` and `false` are truthy. See [#26][#26].
* Number. In JavaScript we cannot distinguish or convert between `float` and `integer`, see [#59][#59]. And when applied `size` filter, numbers always return 0, which is 8 for integer in ruby, cause they do not have a `length` property.
* Stringify: We've aligned string coercion for primitive types. While some differences remain; for example, in Shopify/liquid, `strip` returns the "inspected" string of an input array, whereas in LiquidJS, the `strip` filter simply stringifies the input array [#852][#852].
* [.to_liquid()](https://github.com/Shopify/liquid/wiki/Introduction-to-Drops) is replaced by `.toLiquid()`
* [.to_s()](https://www.rubydoc.info/gems/liquid/Liquid/Drop) is replaced by JavaScript `.toString()`
* Iteration order for objects. The iteration order of JavaScript objects, and thus LiquidJS objects, is a combination of the insertion order for string keys, and ascending order for number-like keys, while the iteration order of Ruby Hash is simply the insertion order.
@@ -47,6 +48,7 @@ Though we're trying to be compatible with the Ruby version, there are still some
[#236]: https://github.com/harttle/liquidjs/issues/236
[#414]: https://github.com/harttle/liquidjs/discussions/414
[#485]: https://github.com/harttle/liquidjs/discussions/485
[#852]: https://github.com/harttle/liquidjs/discussions/852
[sort]: https://liquidjs.com/filters/sort.html
[stable-sort]: https://v8.dev/features/stable-sort
[plugins]: ./plugins.html#Plugin-List
+8 -14
View File
@@ -45,26 +45,20 @@ It can be a string-typed path (see above example), or a list of root directories
```javascript
var engine = new Liquid({
root: ['views/', 'views/partials/'],
root: ['views/'],
partials: ['views/partials/'],
layouts: ['views/layouts/'],
extname: '.liquid'
});
```
{% note tip Relative Paths %}Relative paths in <code>root</code> will be resolved against <code>cwd()</code>.{% endnote %}
When `{% raw %}{% render "foo" %}{% endraw %}` is rendered or `liquid.renderFile('foo')` is called, the following files will be looked up and the first existing file will be used:
- When `parse()`, `render()` functions are called, for example `liquid.renderFile('foo')`, templates under `root` will be looked up.
- When a partial is requested, for example `{% raw %}{% render "foo" %}{% endraw %}`, templates under `partials` will be looked up.
- When a layout is requested, for example `{% raw %}{% layout "foo" %}{% endraw %}`, templates under `layouts` will be looked up.
- `cwd()`/views/foo.liquid
- `cwd()`/views/partials/foo.liquid
If none of the above files exists, an `ENOENT` error will be thrown. Here's a demo for Node.js: [demo/nodejs](https://github.com/harttle/liquidjs/tree/master/demo/nodejs).
When LiquidJS is used in browser, say current location is <https://example.com/bar/index.html>, only the first `root` will be used and the file to be fetched is:
- <https://example.com/bar/foo.liquid>
If fetch fails, a 404/500 error or network failures for example, an `ENOENT` error will be thrown.
Here's a demo for browsers: [demo/browser](https://github.com/harttle/liquidjs/tree/master/demo/browser).
When LiquidJS is used in browser, the paths will be resolved based on current location. Here's a demo for browsers: [demo/browser](https://github.com/harttle/liquidjs/tree/master/demo/browser).
## Abstract File System
@@ -98,7 +92,7 @@ var engine = new Liquid({
});
```
{% note warn Path Traversal Vulnerability %}The default value of <code>contains()</code> always returns true. That means when specifying an abstract file system, you'll need to provide a proper <code>contains()</code> to avoid expose such vulnerabilities.{% endnote %}
{% note warn Path Traversal Vulnerability %}The built-in Node <code>fs</code> implements <code>contains()</code> with realpath so templates cannot escape the root via symlinks. The browser bundle omits <code>contains</code> (loader treats paths as allowed). For a custom abstract <code>fs</code>, implement <code>contains</code> unless every resolved path is trusted.{% endnote %}
## In-memory Template
@@ -24,6 +24,7 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最
* 真和假。在 LiquidJS 中 `undefined`, `null`, `false` 是假,之外的都是真;在 Ruby 中 `nil``false` 是假,其他都是真。见 [#26][#26]。
* 数字。JavaScript 不区分浮点数和整数,因此缺失一部分整数算术,见 [#59][#59]。此外 `size` 过滤器作用于数字时总是返回零,而不是 Ruby 中的浮点数或整数的内存大小。
* 输出字符串。基本类型的输出已经和 Shopify/liquid 对齐,但是仍然存在一些区别。比如在 Shopify/liquid 中 `strip` 会返回 inspect 字符串,但 LiquidJS `strip` 只是简单地把输入转换为字符串 [#852][#852]。
* Drop 中的 [.to_liquid()](https://github.com/Shopify/liquid/wiki/Introduction-to-Drops) 替换为 `.toLiquid()`
* 数据的 [.to_s()](https://www.rubydoc.info/gems/liquid/Liquid/Drop) 替换为 `.toString()`
* 对象的迭代顺序。JavaScript 对象的迭代顺序是插入顺序和数字键递增顺序的组合,但 Ruby Hash 中只是插入顺序(JavaScript 字面量 Object 和 Ruby 字面量 Hash 的插入顺序解释也不同)。
@@ -46,6 +47,7 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最
[#236]: https://github.com/harttle/liquidjs/issues/236
[#414]: https://github.com/harttle/liquidjs/discussions/414
[#485]: https://github.com/harttle/liquidjs/discussions/485
[#852]: https://github.com/harttle/liquidjs/discussions/852
[sort]: https://liquidjs.com/filters/sort.html
[stable-sort]: https://v8.dev/features/stable-sort
[plugins]: ./plugins.html#插件列表
+3319 -1902
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "liquidjs",
"version": "10.24.0",
"version": "10.25.3",
"sideEffects": false,
"description": "A simple, expressive and safe Shopify / Github Pages compatible template engine in pure JavaScript.",
"main": "dist/liquid.node.js",
@@ -68,7 +68,7 @@
"@semantic-release/changelog": "^6.0.2",
"@semantic-release/commit-analyzer": "^9.0.2",
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "^9.0.2",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^10.0.3",
"@types/benchmark": "^1.0.31",
"@types/express": "^4.17.2",
@@ -100,7 +100,7 @@
"rollup-plugin-typescript2": "^0.31.1",
"rollup-plugin-uglify": "^6.0.4",
"rollup-plugin-version-injector": "^1.3.3",
"semantic-release": "^19.0.3",
"semantic-release": "^25.0.3",
"sinon": "^15.0.2",
"supertest": "^3.4.2",
"ts-jest": "^29.0.5",
@@ -151,6 +151,9 @@
]
]
},
"publishConfig": {
"provenance": true
},
"nyc": {
"extension": [
".ts"
+47 -25
View File
@@ -11,7 +11,7 @@
// Hiragana (Japanese): \u3040-\u309F
// Hangul (Korean): \uAC00-\uD7AF
import { FilterImpl } from '../template'
import { assert, escapeRegExp, stringify } from '../util'
import { assert, stringify } from '../util'
const rCJKWord = /[\u4E00-\u9FFF\uF900-\uFAFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/gu
@@ -38,10 +38,14 @@ export function lstrip (this: FilterImpl, v: string, chars?: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
if (chars) {
chars = escapeRegExp(stringify(chars))
return str.replace(new RegExp(`^[${chars}]+`, 'g'), '')
chars = stringify(chars)
this.context.memoryLimit.use(chars.length)
for (let i = 0, set = new Set(chars); i < str.length; i++) {
if (!set.has(str[i])) return str.slice(i)
}
return ''
}
return str.replace(/^\s+/, '')
return str.trimStart()
}
export function downcase (this: FilterImpl, v: string) {
@@ -58,20 +62,22 @@ export function upcase (this: FilterImpl, v: string) {
export function remove (this: FilterImpl, v: string, arg: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.split(stringify(arg)).join('')
arg = stringify(arg)
this.context.memoryLimit.use(str.length + arg.length)
return str.split(arg).join('')
}
export function remove_first (this: FilterImpl, v: string, l: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.replace(stringify(l), '')
l = stringify(l)
this.context.memoryLimit.use(str.length + l.length)
return str.replace(l, '')
}
export function remove_last (this: FilterImpl, v: string, l: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
const pattern = stringify(l)
this.context.memoryLimit.use(str.length + pattern.length)
const index = str.lastIndexOf(pattern)
if (index === -1) return str
return str.substring(0, index) + str.substring(index + pattern.length)
@@ -81,10 +87,14 @@ export function rstrip (this: FilterImpl, str: string, chars?: string) {
str = stringify(str)
this.context.memoryLimit.use(str.length)
if (chars) {
chars = escapeRegExp(stringify(chars))
return str.replace(new RegExp(`[${chars}]+$`, 'g'), '')
chars = stringify(chars)
this.context.memoryLimit.use(chars.length)
for (let i = str.length - 1, set = new Set(chars); i >= 0; i--) {
if (!set.has(str[i])) return str.slice(0, i + 1)
}
return ''
}
return str.replace(/\s+$/, '')
return str.trimEnd()
}
export function split (this: FilterImpl, v: string, arg: string) {
@@ -101,10 +111,13 @@ export function strip (this: FilterImpl, v: string, chars?: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
if (chars) {
chars = escapeRegExp(stringify(chars))
return str
.replace(new RegExp(`^[${chars}]+`, 'g'), '')
.replace(new RegExp(`[${chars}]+$`, 'g'), '')
const set = new Set(stringify(chars))
this.context.memoryLimit.use(set.size)
let i = 0
let j = str.length - 1
while (set.has(str[i])) i++
while (j >= i && set.has(str[j])) j--
return str.slice(i, j + 1)
}
return str.trim()
}
@@ -123,36 +136,44 @@ export function capitalize (this: FilterImpl, str: string) {
export function replace (this: FilterImpl, v: string, pattern: string, replacement: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.split(stringify(pattern)).join(replacement)
pattern = stringify(pattern)
replacement = stringify(replacement)
const parts = str.split(pattern)
const outputSize = str.length + (parts.length - 1) * (replacement.length - pattern.length)
this.context.memoryLimit.use(outputSize)
return parts.join(replacement)
}
export function replace_first (this: FilterImpl, v: string, arg1: string, arg2: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.replace(stringify(arg1), arg2)
arg1 = stringify(arg1)
arg2 = stringify(arg2)
this.context.memoryLimit.use(str.length + arg1.length + arg2.length)
return str.replace(arg1, () => arg2)
}
export function replace_last (this: FilterImpl, v: string, arg1: string, arg2: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
const pattern = stringify(arg1)
const replacement = stringify(arg2)
this.context.memoryLimit.use(str.length + pattern.length + replacement.length)
const index = str.lastIndexOf(pattern)
if (index === -1) return str
const replacement = stringify(arg2)
return str.substring(0, index) + replacement + str.substring(index + pattern.length)
}
export function truncate (this: FilterImpl, v: string, l = 50, o = '...') {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
o = stringify(o)
this.context.memoryLimit.use(str.length + o.length)
if (str.length <= l) return v
return str.substring(0, l - o.length) + o
}
export function truncatewords (this: FilterImpl, v: string, words = 15, o = '...') {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
o = stringify(o)
this.context.memoryLimit.use(str.length + o.length)
const arr = str.split(/\s+/)
if (words <= 0) words = 1
let ret = arr.slice(0, words).join(' ')
@@ -187,7 +208,8 @@ export function number_of_words (this: FilterImpl, input: string, mode?: 'cjk' |
}
export function array_to_sentence_string (this: FilterImpl, array: unknown[], connector = 'and') {
this.context.memoryLimit.use(array.length)
connector = stringify(connector)
this.context.memoryLimit.use(array.length + connector.length)
switch (array.length) {
case 0:
return ''
+36
View File
@@ -1,5 +1,9 @@
import * as fs from './fs-impl'
import * as path from 'path'
import { mkdtempSync, writeFileSync, symlinkSync, rmSync } from 'fs'
import { tmpdir } from 'os'
const { join } = path
describe('fs-impl', function () {
describe('.resolve()', function () {
@@ -50,4 +54,36 @@ describe('fs-impl', function () {
expect(content).toContain('should read content if exists')
})
})
describe('.contains()', () => {
const canSymlink = process.platform !== 'win32'
;(canSymlink ? it : it.skip)('should return false when path is a symlink to outside root', async () => {
const root = mkdtempSync(join(tmpdir(), 'liquid-contains-'))
const outside = join(tmpdir(), `secret-${Date.now()}.liquid`)
writeFileSync(outside, 'x')
const link = join(root, 'link.liquid')
symlinkSync(outside, link)
try {
expect(await fs.contains(root, link)).toBe(false)
} finally {
rmSync(root, { recursive: true, force: true })
rmSync(outside, { force: true })
}
})
})
describe('.containsSync()', () => {
const canSymlink = process.platform !== 'win32'
;(canSymlink ? it : it.skip)('should return false when path is a symlink to outside root', () => {
const root = mkdtempSync(join(tmpdir(), 'liquid-contains-'))
const outside = join(tmpdir(), `secret-${Date.now()}.liquid`)
writeFileSync(outside, 'x')
const link = join(root, 'link.liquid')
symlinkSync(outside, link)
try {
expect(fs.containsSync(root, link)).toBe(false)
} finally {
rmSync(root, { recursive: true, force: true })
rmSync(outside, { force: true })
}
})
})
})
+22 -5
View File
@@ -1,6 +1,6 @@
import { promisify } from '../util'
import { sep, resolve as nodeResolve, extname, dirname as nodeDirname } from 'path'
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync } from 'fs'
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync, realpath, realpathSync } from 'fs'
import { requireResolve } from './node-require'
type NodeReadFile = (file: string, encoding: string, cb: ((err: Error | null, result: string) => void)) => void
@@ -41,10 +41,27 @@ export function fallback (file: string) {
export function dirname (filepath: string) {
return nodeDirname(filepath)
}
export function contains (root: string, file: string) {
root = nodeResolve(root)
root = root.endsWith(sep) ? root : root + sep
return file.startsWith(root)
const realpathAsync = promisify(realpath)
export async function contains (root: string, file: string) {
try {
const realRoot = await realpathAsync(root)
const realFile = await realpathAsync(file)
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
return realFile.startsWith(prefix)
} catch {
return false
}
}
export function containsSync (root: string, file: string) {
try {
const realRoot = realpathSync(root)
const realFile = realpathSync(file)
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
return realFile.startsWith(prefix)
} catch {
return false
}
}
export { sep } from 'path'
+4 -2
View File
@@ -9,8 +9,10 @@ export interface FS {
readFileSync: (filepath: string) => string;
/** resolve a file against directory, for given `ext` option */
resolve: (dir: string, file: string, ext: string) => string;
/** check if file is contained in `root`, always return `true` by default. Warning: not setting this could expose path traversal vulnerabilities. */
contains?: (root: string, file: string) => boolean;
/** check if file is contained in `root`. Node default fs uses realpath; if omitted, loader assumes contained. */
contains?: (root: string, file: string) => Promise<boolean>;
/** sync check if file is contained in `root`, allows both renderSync and render. */
containsSync?: (root: string, file: string) => boolean;
/** defaults to "/" */
sep?: string;
/** required for relative path resolving */
+23 -20
View File
@@ -1,31 +1,34 @@
import * as fs from './fs-impl'
import { Loader } from './loader'
import { resolve } from 'path'
import { Loader, LookupType } from './loader'
import { toValueSync } from '../util/async'
describe('fs/loader', function () {
describe('.candidates()', function () {
it('should resolve relatively', async function () {
it('should resolve relatively', function () {
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current', true)]
expect(candidates).toContain('/root/foo/bar')
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current')]
expect(candidates).toContain(resolve('/root/foo/bar'))
})
it('should not include out of root candidates', async function () {
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
const candidates = [...loader.candidates('../foo/bar', ['/root'], '/root/current', true)]
expect(candidates).toHaveLength(0)
})
describe('.lookup()', function () {
it('should not include out of root candidates', function () {
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
expect(() => toValueSync(loader.lookup('../foo/bar', LookupType.Partials, true, '/root/current')))
.toThrow(/ENOENT/)
})
it('should treat root as a terminated path', async function () {
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
const candidates = [...loader.candidates('../root-dir/bar', ['/root'], '/root/current', true)]
expect(candidates).toHaveLength(0)
it('should treat root as a terminated path', function () {
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
expect(() => toValueSync(loader.lookup('../root-dir/bar', LookupType.Partials, true, '/root/current')))
.toThrow(/ENOENT/)
})
it('should default `.contains()` to () => true', async function () {
const customFs = {
...fs,
contains: undefined
}
const loader = new Loader({ relativeReference: true, fs: customFs, extname: '' } as any)
const candidates = [...loader.candidates('../foo/bar', ['/root'], '/root/current', true)]
expect(candidates).toContain('/foo/bar')
it('should use permissive contains when fs.contains is omitted', function () {
const mockFs = { ...fs, existsSync: () => true, exists: async () => true, contains: undefined, containsSync: undefined }
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
const result = toValueSync(loader.lookup('./foo/bar', LookupType.Partials, true, '/root/current'))
expect(result).toBe(resolve('/root/foo/bar'))
})
})
})
+29 -19
View File
@@ -1,5 +1,5 @@
import { FS } from './fs'
import { assert, escapeRegex } from '../util'
import { assert, LiquidAsync, toLiquidAsync } from '../util'
export interface LoaderOptions {
fs: FS;
@@ -17,48 +17,58 @@ export enum LookupType {
export class Loader {
public shouldLoadRelative: (referencedFile: string) => boolean
private options: LoaderOptions
private contains: (root: string, file: string) => boolean
private contains: LiquidAsync<NonNullable<FS['containsSync']>>
private exists: LiquidAsync<FS['existsSync']>
constructor (options: LoaderOptions) {
this.options = options
if (options.relativeReference) {
const sep = options.fs.sep
assert(sep, '`fs.sep` is required for relative reference')
const rRelativePath = new RegExp(['.' + sep, '..' + sep, './', '../'].map(prefix => escapeRegex(prefix)).join('|'))
this.shouldLoadRelative = (referencedFile: string) => rRelativePath.test(referencedFile)
const prefixes = ['.' + sep, '..' + sep, './', '../']
this.shouldLoadRelative = (referencedFile: string) => prefixes.some(prefix => referencedFile.startsWith(prefix))
} else {
this.shouldLoadRelative = (_referencedFile: string) => false
}
this.contains = this.options.fs.contains || (() => true)
const fs = options.fs
this.contains = toLiquidAsync(
fs.contains?.bind(fs) || (async () => true),
fs.containsSync?.bind(fs) || (() => true)
)
this.exists = toLiquidAsync(
fs.exists?.bind(fs) || (async () => false),
fs.existsSync?.bind(fs)
)
}
public * lookup (file: string, type: LookupType, sync?: boolean, currentFile?: string): Generator<unknown, string, string> {
const { fs } = this.options
const dirs = this.options[type]
for (const filepath of this.candidates(file, dirs, currentFile, type !== LookupType.Root)) {
if (sync ? fs.existsSync(filepath) : yield fs.exists(filepath)) return filepath
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
}
if (yield this.exists(!!sync, filepath)) return filepath
}
throw this.lookupError(file, dirs)
}
public * candidates (file: string, dirs: string[], currentFile?: string, enforceRoot?: boolean) {
public * candidates (file: string, dirs: string[], currentFile?: string) {
const { fs, extname } = this.options
if (this.shouldLoadRelative(file) && currentFile) {
const referenced = fs.resolve(this.dirname(currentFile), file, extname)
for (const dir of dirs) {
if (!enforceRoot || this.contains(dir, referenced)) {
// the relatively referenced file is within one of root dirs
yield referenced
break
}
}
yield referenced
}
for (const dir of dirs) {
const referenced = fs.resolve(dir, file, extname)
if (!enforceRoot || this.contains(dir, referenced)) {
yield referenced
}
yield referenced
}
if (fs.fallback !== undefined) {
const filepath = fs.fallback(file)
if (filepath !== undefined) yield filepath
+7 -2
View File
@@ -1,4 +1,4 @@
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError } from '../util'
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError, toLiquidAsync, LiquidAsync } from '../util'
import { Tokenizer } from './tokenizer'
import { ParseStream } from './parse-stream'
import { TopLevelToken, OutputToken } from '../tokens'
@@ -16,6 +16,7 @@ export class Parser {
private cache?: LiquidCache
private loader: Loader
private parseLimit: Limiter
private readFile: LiquidAsync<FS['readFileSync']>
public constructor (liquid: Liquid) {
this.liquid = liquid
@@ -24,6 +25,10 @@ export class Parser {
this.parseFile = this.cache ? this._parseFileCached : this._parseFile
this.loader = new Loader(this.liquid.options)
this.parseLimit = new Limiter('parse length', liquid.options.parseLimit)
this.readFile = toLiquidAsync(
this.fs.readFile?.bind(this.fs) || (async () => { throw new Error('readFile not implemented') }),
this.fs.readFileSync?.bind(this.fs)
)
}
public parse (html: string, filepath?: string): Template[] {
html = String(html)
@@ -82,6 +87,6 @@ export class Parser {
}
private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string): Generator<unknown, Template[], string> {
const filepath = yield this.loader.lookup(file, type, sync, currentFile)
return this.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath)
return this.parse(yield this.readFile(!!sync, filepath), filepath)
}
}
+13
View File
@@ -1,5 +1,18 @@
import { isPromise, isIterator } from './underscore'
export type LiquidAsync<F extends (...args: any[]) => any> =
(sync: boolean, ...args: Parameters<F>) => ReturnType<F> | Promise<ReturnType<F>>
export function toLiquidAsync<F extends (...args: any[]) => any> (
asyncFn: (...args: Parameters<F>) => Promise<ReturnType<F>>,
syncFn?: F
): LiquidAsync<F> {
const syncImpl = syncFn || asyncFn as any
return (sync: boolean, ...args: any[]) => {
return sync ? syncImpl(...args as Parameters<F>) : asyncFn(...args as Parameters<F>)
}
}
// convert an async iterator to a Promise
export async function toPromise<T> (val: Generator<unknown, T, unknown> | Promise<T> | T): Promise<T> {
if (!isIterator(val)) return val
+7 -5
View File
@@ -9,12 +9,14 @@ export class Limiter {
this.limit = limit
}
use (count: number) {
count = +count || 0
assert(this.base + count <= this.limit, this.message)
this.base += count
if (+count > 0) {
assert(this.base + +count <= this.limit, this.message)
this.base += +count
}
}
check (count: number) {
count = +count || 0
assert(count <= this.limit, this.message)
if (+count > 0) {
assert(+count <= this.limit, this.message)
}
}
}
-4
View File
@@ -22,10 +22,6 @@ export function isIterator (val: any): val is IterableIterator<any> {
return val && isFunction(val.next) && isFunction(val.throw) && isFunction(val.return)
}
export function escapeRegex (str: string) {
return str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
}
export function promisify<T1, T2> (fn: (arg1: T1, cb: (err: Error | null, result: T2) => void) => void): (arg1: T1) => Promise<T2>;
export function promisify<T1, T2, T3> (fn: (arg1: T1, arg2: T2, cb: (err: Error | null, result: T3) => void) => void): (arg1: T1, arg2: T2) => Promise<T3>;
export function promisify (fn: any) {
+20
View File
@@ -1,4 +1,6 @@
import { TopLevelToken, TagToken, Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError, IfTag } from '../..'
import { spawnSync } from 'child_process'
import { resolve as resolvePath } from 'path'
const LiquidUMD = require('../../dist/liquid.browser.umd.js').Liquid
describe('Issues', function () {
@@ -173,6 +175,24 @@ describe('Issues', function () {
const html = await engine.render(tpl, { my_variable: 'foo' })
expect(html).toBe('CONTENT for /tmp/prefix/foo-bar/suffix')
})
it('should prevent path traversal in dynamic include with restricted root, #851', () => {
const projectRoot = resolvePath(__dirname, '../..')
const poc = `
const { Liquid } = require('./dist/liquid.node.js');
const e = new Liquid({ root: ['/tmp'], partials: ['/tmp'], dynamicPartials: true });
e.parseAndRender('{% include page %}', { page: '../../../etc/passwd' })
.then(() => { console.log('OK'); })
.catch(err => { console.error('ERR:' + err.message); process.exit(1); });
`
const result = spawnSync(
process.execPath,
['-e', poc],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Failed to lookup')
})
it('Implement liquid/echo tags #428', () => {
const template = `{%- liquid
for value in array
+25
View File
@@ -1,4 +1,7 @@
import { Liquid } from '../..'
import { mkdtempSync, writeFileSync, symlinkSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
describe('.parseAndRender()', function () {
var engine: Liquid, strictEngine: Liquid
@@ -57,4 +60,26 @@ describe('.parseAndRender()', function () {
const html = await engine.parseAndRender(src)
expect(html).toBe('true')
})
const canSymlink = process.platform !== 'win32'
;(canSymlink ? describe : describe.skip)('symlink outside root', function () {
let root: string, secret: string
beforeAll(function () {
root = mkdtempSync(join(tmpdir(), 'liquid-e2e-root-'))
secret = join(tmpdir(), `liquid-e2e-secret-${Date.now()}.liquid`)
writeFileSync(secret, 'SECRET_OUTSIDE')
symlinkSync(secret, join(root, 'link.liquid'))
})
afterAll(function () {
rmSync(root, { recursive: true, force: true })
rmSync(secret, { force: true })
})
it('should not render a symlink partial whose target is outside root', async function () {
const e = new Liquid({ root: [root], extname: '.liquid', relativeReference: false })
await expect(e.parseAndRender('{% render "link" %}')).rejects.toThrow(/ENOENT|Failed to lookup/)
})
it('should not render a symlink partial via parseAndRenderSync', function () {
const e = new Liquid({ root: [root], extname: '.liquid', relativeReference: false })
expect(() => e.parseAndRenderSync('{% render "link" %}')).toThrow(/ENOENT|Failed to lookup/)
})
})
})
+8
View File
@@ -109,6 +109,14 @@ describe('filters/string', function () {
return test('{{ "Take my protein pills and put my helmet on" | replace: "my", "your" }}',
'Take your protein pills and put your helmet on')
})
it('should support replace with undefined replacement', function () {
return test('{{ "Take my protein pills and put my helmet on" | replace: "my" }}',
'Take protein pills and put helmet on')
})
it('should support replace with undefined variable as replacement', function () {
return test('{{ "Take my protein pills and put my helmet on" | replace: "my", missing_variable }}',
'Take protein pills and put helmet on')
})
it('should support replace_first', function () {
return test('{% assign my_string = "Take my protein pills and put my helmet on" %}\n' +
'{{ my_string | replace_first: "my", "your" }}',
+15 -3
View File
@@ -1,6 +1,6 @@
import { isString, forOwn } from '../../src/util/underscore'
import * as fs from '../../src/fs/fs-impl'
import { resolve } from 'path'
import { resolve, sep } from 'path'
interface FileDescriptor {
mode: string;
@@ -8,7 +8,7 @@ interface FileDescriptor {
}
let files: { [path: string]: FileDescriptor } = {}
const { readFile, exists, readFileSync, existsSync } = fs
const { readFile, exists, readFileSync, existsSync, contains, containsSync } = fs
export function mock (options: { [path: string]: (string | FileDescriptor) }) {
forOwn(options, (val, key) => {
@@ -30,6 +30,16 @@ export function mock (options: { [path: string]: (string | FileDescriptor) }) {
};
(fs as any).existsSync = function (path: string) {
return !!files[path]
};
(fs as any).contains = async (root: string, file: string) => {
root = resolve(root)
if (!root.endsWith(sep)) root += sep
return file.startsWith(root)
};
(fs as any).containsSync = (root: string, file: string) => {
root = resolve(root)
if (!root.endsWith(sep)) root += sep
return file.startsWith(root)
}
}
@@ -38,5 +48,7 @@ export function restore () {
(fs as any).readFileSync = readFileSync;
(fs as any).existsSync = existsSync;
(fs as any).readFile = readFile;
(fs as any).exists = exists
(fs as any).exists = exists;
(fs as any).contains = contains;
(fs as any).containsSync = containsSync
}
+1 -2
View File
@@ -10,8 +10,7 @@
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"downlevelIteration": true,
"strict": true,
"suppressImplicitAnyIndexErrors": true
"strict": true
},
"all": true
}