Compare commits

...

19 Commits

Author SHA1 Message Date
Jakob Linskeseder dbe04946ce Improve security mitigations
Instead of validating the AST in the parser, fix the compiler instead by
handling the types in a safe way.
2026-03-30 00:31:02 +02:00
Jakob Linskeseder d069c1caf1 Fix Ruby component publishing documentation
Docker images has to use root because it tries
to write to `/.local` which would fail otherwise.
2026-03-26 22:26:07 +01:00
Jakob Linskeseder 6714e07a6a Fix Composer component definition
This aligns the Composer component with
https://github.com/components/handlebars.js/blob/master/composer.json
2026-03-26 22:15:07 +01:00
handlebars-lang dce542c9a6 v4.7.9 2026-03-26 20:44:05 +00:00
handlebars-lang 8a41389ba5 Update release notes 2026-03-26 20:43:42 +00:00
Jakob Linskeseder 68d8df5a88 Fix security issues
Fixes GHSA-2w6w-674q-4c4q, GHSA-xhpv-hc6g-r9c6, GHSA-3mfm-83xf-c92r, GHSA-2qvq-rjwj-gvw9, GHSA-9cx6-37pm-9jff, GHSA-7rx3-28cr-v5wh, GHSA-442j-39wm-28r2, GHSA-xjpj-3mr7-gcpf
2026-03-26 21:29:35 +01:00
Jakob Linskeseder b2a083136b Fix browser tests
Pinned the browser tests to the latest Playwright version that still works with Node 16.
We need Node 16 for a successful build.
2026-03-25 22:08:13 +01:00
Jakob Linskeseder 9f98c16298 Fix release script
We have to pin Grunt to 1.5.3, since this
is the latest version to support Node 10,
which is needed for the release process.

Also improved the Dockerfile and added
a warning related to "yo release".
2026-03-25 21:44:59 +01:00
Mohamed Akram 45443b4290 Revert "Improve partial indenting performance"
This reverts commit 08fddee033.

The change caused performance regressions in some cases and performance
was not consistent across engines.
2026-03-25 20:31:53 +01:00
Jakob Linskeseder 8841a5f6d3 Fix CI errors with linting 2026-02-07 22:22:17 +01:00
Tyler Blackham e0137c26f2 fix: enable shell mode for spawn to resolve Windows EINVAL issue 2026-02-07 22:12:25 +01:00
Mohamed Akram e914d6037f Improve rendering performance
Avoid unnecessary copies via Utils.extend in hot paths.
2024-09-03 22:08:38 +02:00
Christian Clauss 7de4b41c34 Upgrade GitHub Actions checkout and setup-node on 4.x branch
Like #2024 but on another branch.
2023-12-14 00:07:16 +01:00
Benoit Vallée eab1d141cb Fix type "RuntimeOptions" also accepting string partials 2023-09-21 22:30:08 +02:00
Jonas Thelemann de4414d7fc feat(types): set hash to be a Record<string, any>
Resolves #2001
2023-09-12 23:23:59 +02:00
Mohamed Akram 08fddee033 Improve partial indenting performance 2023-09-07 22:42:30 +02:00
Mohamed Akram 4512766919 Fix non-contiguous program indices 2023-09-07 22:24:57 +02:00
Nils Knappmeier e497a35d7f refactor: rename i to startPartIndex 2023-08-05 19:47:17 +02:00
Nils Knappmeier 8c9f866655 update package-lock.json to correct version 2023-08-05 00:14:57 +02:00
40 changed files with 6417 additions and 14016 deletions
+9 -9
View File
@@ -12,10 +12,10 @@ jobs:
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: '16' node-version: '16'
@@ -33,16 +33,16 @@ jobs:
matrix: matrix:
operating-system: ['ubuntu-latest', 'windows-latest'] operating-system: ['ubuntu-latest', 'windows-latest']
# https://nodejs.org/en/about/releases/ # https://nodejs.org/en/about/releases/
node-version: ['10', '12', '14', '16', '18', '20'] node-version: ['16', '18', '20', '22']
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
@@ -54,7 +54,7 @@ jobs:
- name: Test (Integration) - name: Test (Integration)
# https://github.com/webpack/webpack/issues/14532 # https://github.com/webpack/webpack/issues/14532
if: ${{ matrix.node-version != '18' && matrix.node-version != '20' }} if: ${{ matrix.node-version == '16' }}
run: | run: |
cd ./tests/integration/rollup-test && ./test.sh && cd - cd ./tests/integration/rollup-test && ./test.sh && cd -
cd ./tests/integration/webpack-babel-test && ./test.sh && cd - cd ./tests/integration/webpack-babel-test && ./test.sh && cd -
@@ -62,15 +62,15 @@ jobs:
browser: browser:
name: Test (Browser) name: Test (Browser)
runs-on: 'ubuntu-20.04' runs-on: 'ubuntu-22.04'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: '16' node-version: '16'
+1
View File
@@ -15,5 +15,6 @@ node_modules
lib/handlebars/compiler/parser.js lib/handlebars/compiler/parser.js
/coverage/ /coverage/
/dist/ /dist/
/test-results/
/tests/integration/*/dist/ /tests/integration/*/dist/
/spec/tmp/* /spec/tmp/*
+8 -4
View File
@@ -132,9 +132,11 @@ A full release via Docker may be completed with the following:
# Generate config for yo generator-release: # Generate config for yo generator-release:
# https://github.com/kpdecker/generator-release#example # https://github.com/kpdecker/generator-release#example
# You have to add a valid GitHub OAuth token! # You have to add a valid GitHub access token! (Used for reading issues and pull requests.)
RUN echo "module.exports = {\n auth: 'oauth',\n token: 'GitHub OAuth token'\n};" > /home/node/.config/generator-release RUN echo "module.exports = {\n auth: 'oauth',\n token: 'GitHub personal access token'\n};" > /home/node/.config/generator-release
RUN chown -R node:node /home/node/.config RUN chown -R node:node /home/node/.config
RUN chown -R node:node /home/node/.ssh
RUN chown -R node:node /home/node/tmp
# Add the generated key to GitHub: https://github.com/settings/keys # Add the generated key to GitHub: https://github.com/settings/keys
RUN ssh-keygen -q -t ed25519 -N '' -f /home/node/.ssh/id_ed25519 -C "release@handlebarsjs.com" RUN ssh-keygen -q -t ed25519 -N '' -f /home/node/.ssh/id_ed25519 -C "release@handlebarsjs.com"
@@ -155,9 +157,12 @@ A full release via Docker may be completed with the following:
* Add GitHub API token: `vi /home/node/.config/generator-release` * Add GitHub API token: `vi /home/node/.config/generator-release`
* Execute the following steps: * Execute the following steps:
```bash ```bash
npm ci npm install
npm install -g yo@1 grunt@1 generator-release npm install -g yo@1 grunt@1 generator-release
npm run release npm run release
# Warning! This step will collect data from GitHub, bump the version,
# create a new commit, create a new tag and push it to GitHub.
# https://github.com/kpdecker/generator-release?tab=readme-ov-file#usage
yo release yo release
npm login npm login
npm publish npm publish
@@ -168,7 +173,6 @@ A full release via Docker may be completed with the following:
docker run --rm --interactive --tty \ docker run --rm --interactive --tty \
--volume $PWD:/app \ --volume $PWD:/app \
--workdir /app \ --workdir /app \
--user $(id -u):$(id -g) \
ruby:3.2-slim bash ruby:3.2-slim bash
``` ```
* Execute the following steps: * Execute the following steps:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "handlebars", "name": "handlebars",
"version": "4.7.8", "version": "4.7.9",
"main": "handlebars.js", "main": "handlebars.js",
"license": "MIT", "license": "MIT",
"dependencies": {} "dependencies": {}
+2 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "components/handlebars.js", "name": "components/handlebars.js",
"description": "Handlebars.js and Mustache are both logicless templating languages that keep the view and the code separated like we all know they should be.", "description": "Handlebars.js and Mustache are both logicless templating languages that keep the view and the code separated like we all know they should be.",
"homepage": "http://handlebarsjs.com", "homepage": "https://handlebarsjs.com",
"license": "MIT", "license": "MIT",
"type": "component", "type": "component",
"keywords": [ "keywords": [
@@ -11,13 +11,9 @@
], ],
"authors": [ "authors": [
{ {
"name": "Chris Wanstrath", "name": "Chris Wanstrath"
"homepage": "http://chriswanstrath.com"
} }
], ],
"require": {
"robloach/component-installer": "*"
},
"extra": { "extra": {
"component": { "component": {
"name": "handlebars", "name": "handlebars",
+1 -1
View File
@@ -2,7 +2,7 @@
<package> <package>
<metadata> <metadata>
<id>handlebars.js</id> <id>handlebars.js</id>
<version>4.7.8</version> <version>4.7.9</version>
<authors>handlebars.js Authors</authors> <authors>handlebars.js Authors</authors>
<licenseUrl>https://github.com/handlebars-lang/handlebars.js/blob/master/LICENSE</licenseUrl> <licenseUrl>https://github.com/handlebars-lang/handlebars.js/blob/master/LICENSE</licenseUrl>
<projectUrl>https://github.com/handlebars-lang/handlebars.js/</projectUrl> <projectUrl>https://github.com/handlebars-lang/handlebars.js/</projectUrl>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "handlebars", "name": "handlebars",
"version": "4.7.8", "version": "4.7.9",
"license": "MIT", "license": "MIT",
"jspm": { "jspm": {
"main": "handlebars", "main": "handlebars",
+1 -1
View File
@@ -5,7 +5,7 @@ import { registerDefaultDecorators } from './decorators';
import logger from './logger'; import logger from './logger';
import { resetLoggedProperties } from './internal/proto-access'; import { resetLoggedProperties } from './internal/proto-access';
export const VERSION = '4.7.8'; export const VERSION = '4.7.9';
export const COMPILER_REVISION = 8; export const COMPILER_REVISION = 8;
export const LAST_COMPATIBLE_COMPILER_REVISION = 7; export const LAST_COMPATIBLE_COMPILER_REVISION = 7;
+30 -21
View File
@@ -1,7 +1,13 @@
/* eslint-disable new-cap */ /* eslint-disable new-cap */
import Exception from '../exception'; import Exception from '../exception';
import { isArray, indexOf, extend } from '../utils'; import {
isArray,
indexOf,
extend,
sanitizeDepth,
sanitizeParts
} from '../utils';
import AST from './ast'; import AST from './ast';
const slice = [].slice; const slice = [].slice;
@@ -243,7 +249,7 @@ Compiler.prototype = {
name = path.parts[0], name = path.parts[0],
isBlock = program != null || inverse != null; isBlock = program != null || inverse != null;
this.opcode('getContext', path.depth); this.opcode('getContext', sanitizeDepth(path.depth));
this.opcode('pushProgram', program); this.opcode('pushProgram', program);
this.opcode('pushProgram', inverse); this.opcode('pushProgram', inverse);
@@ -288,29 +294,32 @@ Compiler.prototype = {
}, },
PathExpression: function(path) { PathExpression: function(path) {
this.addDepth(path.depth); // Sanitize untrusted AST values at the compiler boundary.
this.opcode('getContext', path.depth); // javascript-compiler.js trusts all opcode arguments to be safe.
const depth = sanitizeDepth(path.depth);
const parts = sanitizeParts(path.parts);
let name = path.parts[0], this.addDepth(depth);
this.opcode('getContext', depth);
let name = parts[0],
scoped = AST.helpers.scopedId(path), scoped = AST.helpers.scopedId(path),
blockParamId = !path.depth && !scoped && this.blockParamIndex(name); blockParamId = !depth && !scoped && this.blockParamIndex(name);
if (blockParamId) { if (blockParamId) {
this.opcode('lookupBlockParam', blockParamId, path.parts); this.opcode(
'lookupBlockParam',
[Number(blockParamId[0]), Number(blockParamId[1])],
parts
);
} else if (!name) { } else if (!name) {
// Context reference, i.e. `{{foo .}}` or `{{foo ..}}` // Context reference, i.e. `{{foo .}}` or `{{foo ..}}`
this.opcode('pushContext'); this.opcode('pushContext');
} else if (path.data) { } else if (path.data) {
this.options.data = true; this.options.data = true;
this.opcode('lookupData', path.depth, path.parts, path.strict); this.opcode('lookupData', depth, parts, path.strict);
} else { } else {
this.opcode( this.opcode('lookupOnContext', parts, path.falsy, path.strict, scoped);
'lookupOnContext',
path.parts,
path.falsy,
path.strict,
scoped
);
} }
}, },
@@ -319,11 +328,11 @@ Compiler.prototype = {
}, },
NumberLiteral: function(number) { NumberLiteral: function(number) {
this.opcode('pushLiteral', number.value); this.opcode('pushLiteral', Number(number.value));
}, },
BooleanLiteral: function(bool) { BooleanLiteral: function(bool) {
this.opcode('pushLiteral', bool.value); this.opcode('pushLiteral', bool.value === true ? 'true' : 'false');
}, },
UndefinedLiteral: function() { UndefinedLiteral: function() {
@@ -410,16 +419,16 @@ Compiler.prototype = {
pushParam: function(val) { pushParam: function(val) {
let value = val.value != null ? val.value : val.original || ''; let value = val.value != null ? val.value : val.original || '';
let depth = sanitizeDepth(val.depth);
if (this.stringParams) { if (this.stringParams) {
if (value.replace) { if (value.replace) {
value = value.replace(/^(\.?\.\/)*/g, '').replace(/\//g, '.'); value = value.replace(/^(\.?\.\/)*/g, '').replace(/\//g, '.');
} }
if (depth) {
if (val.depth) { this.addDepth(depth);
this.addDepth(val.depth);
} }
this.opcode('getContext', val.depth || 0); this.opcode('getContext', depth);
this.opcode('pushStringParam', value, val.type); this.opcode('pushStringParam', value, val.type);
if (val.type === 'SubExpression') { if (val.type === 'SubExpression') {
+28 -15
View File
@@ -165,12 +165,10 @@ JavaScriptCompiler.prototype = {
let { programs, decorators } = this.context; let { programs, decorators } = this.context;
for (i = 0, l = programs.length; i < l; i++) { for (i = 0, l = programs.length; i < l; i++) {
if (programs[i]) { ret[i] = programs[i];
ret[i] = programs[i]; if (decorators[i]) {
if (decorators[i]) { ret[i + '_d'] = decorators[i];
ret[i + '_d'] = decorators[i]; ret.useDecorators = true;
ret.useDecorators = true;
}
} }
} }
@@ -535,16 +533,22 @@ JavaScriptCompiler.prototype = {
this.resolvePath('data', parts, 0, true, strict); this.resolvePath('data', parts, 0, true, strict);
}, },
resolvePath: function(type, parts, i, falsy, strict) { resolvePath: function(type, parts, startPartIndex, falsy, strict) {
if (this.options.strict || this.options.assumeObjects) { if (this.options.strict || this.options.assumeObjects) {
this.push( this.push(
strictLookup(this.options.strict && strict, this, parts, i, type) strictLookup(
this.options.strict && strict,
this,
parts,
startPartIndex,
type
)
); );
return; return;
} }
let len = parts.length; let len = parts.length;
for (; i < len; i++) { for (let i = startPartIndex; i < len; i++) {
/* eslint-disable no-loop-func */ /* eslint-disable no-loop-func */
this.replaceStack(current => { this.replaceStack(current => {
let lookup = this.nameLookup(current, parts[i], type); let lookup = this.nameLookup(current, parts[i], type);
@@ -682,9 +686,18 @@ JavaScriptCompiler.prototype = {
let foundDecorator = this.nameLookup('decorators', name, 'decorator'), let foundDecorator = this.nameLookup('decorators', name, 'decorator'),
options = this.setupHelperArgs(name, paramSize); options = this.setupHelperArgs(name, paramSize);
// Store the resolved decorator in a variable and verify it is a function before
// calling it. Without this, unregistered decorators can cause an unhandled TypeError
// (calling undefined), which crashes the process — enabling Denial of Service.
this.decorators.push(['var decorator = ', foundDecorator, ';']);
this.decorators.push([
'if (typeof decorator !== "function") { throw new Error(',
this.quotedString('Missing decorator: "' + name + '"'),
'); }'
]);
this.decorators.push([ this.decorators.push([
'fn = ', 'fn = ',
this.decorators.functionCall(foundDecorator, '', [ this.decorators.functionCall('decorator', '', [
'fn', 'fn',
'props', 'props',
'container', 'container',
@@ -908,8 +921,8 @@ JavaScriptCompiler.prototype = {
let existing = this.matchExistingProgram(child); let existing = this.matchExistingProgram(child);
if (existing == null) { if (existing == null) {
this.context.programs.push(''); // Placeholder to prevent name conflicts for nested children // Placeholder to prevent name conflicts for nested children
let index = this.context.programs.length; let index = this.context.programs.push('') - 1;
child.index = index; child.index = index;
child.name = 'program' + index; child.name = 'program' + index;
this.context.programs[index] = compiler.compile( this.context.programs[index] = compiler.compile(
@@ -1263,14 +1276,14 @@ JavaScriptCompiler.isValidJavaScriptVariableName = function(name) {
); );
}; };
function strictLookup(requireTerminal, compiler, parts, i, type) { function strictLookup(requireTerminal, compiler, parts, startPartIndex, type) {
let stack = compiler.popStack(), let stack = compiler.popStack(),
len = parts.length; len = parts.length;
if (requireTerminal) { if (requireTerminal) {
len--; len--;
} }
for (; i < len; i++) { for (let i = startPartIndex; i < len; i++) {
stack = compiler.nameLookup(stack, parts[i], type); stack = compiler.nameLookup(stack, parts[i], type);
} }
@@ -1280,7 +1293,7 @@ function strictLookup(requireTerminal, compiler, parts, i, type) {
'(', '(',
stack, stack,
', ', ', ',
compiler.quotedString(parts[i]), compiler.quotedString(parts[len]),
', ', ', ',
JSON.stringify(compiler.source.currentLocation), JSON.stringify(compiler.source.currentLocation),
' )' ' )'
+2 -1
View File
@@ -20,7 +20,8 @@ export function moveHelperToHooks(instance, helperName, keepHelper) {
if (instance.helpers[helperName]) { if (instance.helpers[helperName]) {
instance.hooks[helperName] = instance.helpers[helperName]; instance.hooks[helperName] = instance.helpers[helperName];
if (!keepHelper) { if (!keepHelper) {
delete instance.helpers[helperName]; // Using delete is slow
instance.helpers[helperName] = undefined;
} }
} }
} }
@@ -1,11 +0,0 @@
import { extend } from '../utils';
/**
* Create a new object with "null"-prototype to avoid truthy results on prototype properties.
* The resulting object can be used with "object[property]" to check if a property exists
* @param {...object} sources a varargs parameter of source objects that will be merged
* @returns {object}
*/
export function createNewLookupObject(...sources) {
return extend(Object.create(null), ...sources);
}
+16 -17
View File
@@ -1,32 +1,31 @@
import { createNewLookupObject } from './create-new-lookup-object'; import { extend } from '../utils';
import logger from '../logger'; import logger from '../logger';
const loggedProperties = Object.create(null); const loggedProperties = Object.create(null);
export function createProtoAccessControl(runtimeOptions) { export function createProtoAccessControl(runtimeOptions) {
let defaultMethodWhiteList = Object.create(null); // Create an object with "null"-prototype to avoid truthy results on
defaultMethodWhiteList['constructor'] = false; // prototype properties.
defaultMethodWhiteList['__defineGetter__'] = false; const propertyWhiteList = Object.create(null);
defaultMethodWhiteList['__defineSetter__'] = false;
defaultMethodWhiteList['__lookupGetter__'] = false;
let defaultPropertyWhiteList = Object.create(null);
// eslint-disable-next-line no-proto // eslint-disable-next-line no-proto
defaultPropertyWhiteList['__proto__'] = false; propertyWhiteList['__proto__'] = false;
extend(propertyWhiteList, runtimeOptions.allowedProtoProperties);
const methodWhiteList = Object.create(null);
methodWhiteList['constructor'] = false;
methodWhiteList['__defineGetter__'] = false;
methodWhiteList['__defineSetter__'] = false;
methodWhiteList['__lookupGetter__'] = false;
methodWhiteList['__lookupSetter__'] = false;
extend(methodWhiteList, runtimeOptions.allowedProtoMethods);
return { return {
properties: { properties: {
whitelist: createNewLookupObject( whitelist: propertyWhiteList,
defaultPropertyWhiteList,
runtimeOptions.allowedProtoProperties
),
defaultValue: runtimeOptions.allowProtoPropertiesByDefault defaultValue: runtimeOptions.allowProtoPropertiesByDefault
}, },
methods: { methods: {
whitelist: createNewLookupObject( whitelist: methodWhiteList,
defaultMethodWhiteList,
runtimeOptions.allowedProtoMethods
),
defaultValue: runtimeOptions.allowProtoMethodsByDefault defaultValue: runtimeOptions.allowProtoMethodsByDefault
} }
}; };
+24 -22
View File
@@ -74,17 +74,10 @@ export function template(templateSpec, env) {
} }
partial = env.VM.resolvePartial.call(this, partial, context, options); partial = env.VM.resolvePartial.call(this, partial, context, options);
let extendedOptions = Utils.extend({}, options, { options.hooks = this.hooks;
hooks: this.hooks, options.protoAccessControl = this.protoAccessControl;
protoAccessControl: this.protoAccessControl
});
let result = env.VM.invokePartial.call( let result = env.VM.invokePartial.call(this, partial, context, options);
this,
partial,
context,
extendedOptions
);
if (result == null && env.compile) { if (result == null && env.compile) {
options.partials[options.name] = env.compile( options.partials[options.name] = env.compile(
@@ -92,7 +85,7 @@ export function template(templateSpec, env) {
templateSpec.compilerOptions, templateSpec.compilerOptions,
env env
); );
result = options.partials[options.name](context, extendedOptions); result = options.partials[options.name](context, options);
} }
if (result != null) { if (result != null) {
if (options.indent) { if (options.indent) {
@@ -145,7 +138,7 @@ export function template(templateSpec, env) {
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
let result = depths[i] && container.lookupProperty(depths[i], name); let result = depths[i] && container.lookupProperty(depths[i], name);
if (result != null) { if (result != null) {
return depths[i][name]; return result;
} }
} }
}, },
@@ -254,8 +247,9 @@ export function template(templateSpec, env) {
ret._setup = function(options) { ret._setup = function(options) {
if (!options.partial) { if (!options.partial) {
let mergedHelpers = Utils.extend({}, env.helpers, options.helpers); let mergedHelpers = {};
wrapHelpersToPassLookupProperty(mergedHelpers, container); addHelpers(mergedHelpers, env.helpers, container);
addHelpers(mergedHelpers, options.helpers, container);
container.helpers = mergedHelpers; container.helpers = mergedHelpers;
if (templateSpec.usePartial) { if (templateSpec.usePartial) {
@@ -355,21 +349,21 @@ export function wrapProgram(
export function resolvePartial(partial, context, options) { export function resolvePartial(partial, context, options) {
if (!partial) { if (!partial) {
if (options.name === '@partial-block') { if (options.name === '@partial-block') {
partial = options.data['partial-block']; partial = lookupOwnProperty(options.data, 'partial-block');
} else { } else {
partial = options.partials[options.name]; partial = lookupOwnProperty(options.partials, options.name);
} }
} else if (!partial.call && !options.name) { } else if (!partial.call && !options.name) {
// This is a dynamic partial that returned a string // This is a dynamic partial that returned a string
options.name = partial; options.name = partial;
partial = options.partials[partial]; partial = lookupOwnProperty(options.partials, partial);
} }
return partial; return partial;
} }
export function invokePartial(partial, context, options) { export function invokePartial(partial, context, options) {
// Use the current closure context to save the partial-block if this partial // Use the current closure context to save the partial-block if this partial
const currentPartialBlock = options.data && options.data['partial-block']; const currentPartialBlock = lookupOwnProperty(options.data, 'partial-block');
options.partial = true; options.partial = true;
if (options.ids) { if (options.ids) {
options.data.contextPath = options.ids[0] || options.data.contextPath; options.data.contextPath = options.ids[0] || options.data.contextPath;
@@ -410,6 +404,12 @@ export function noop() {
return ''; return '';
} }
function lookupOwnProperty(obj, name) {
if (obj && Object.prototype.hasOwnProperty.call(obj, name)) {
return obj[name];
}
}
function initData(context, data) { function initData(context, data) {
if (!data || !('root' in data)) { if (!data || !('root' in data)) {
data = data ? createFrame(data) : {}; data = data ? createFrame(data) : {};
@@ -435,9 +435,10 @@ function executeDecorators(fn, prog, container, depths, data, blockParams) {
return prog; return prog;
} }
function wrapHelpersToPassLookupProperty(mergedHelpers, container) { function addHelpers(mergedHelpers, helpers, container) {
Object.keys(mergedHelpers).forEach(helperName => { if (!helpers) return;
let helper = mergedHelpers[helperName]; Object.keys(helpers).forEach(helperName => {
let helper = helpers[helperName];
mergedHelpers[helperName] = passLookupPropertyOption(helper, container); mergedHelpers[helperName] = passLookupPropertyOption(helper, container);
}); });
} }
@@ -445,6 +446,7 @@ function wrapHelpersToPassLookupProperty(mergedHelpers, container) {
function passLookupPropertyOption(helper, container) { function passLookupPropertyOption(helper, container) {
const lookupProperty = container.lookupProperty; const lookupProperty = container.lookupProperty;
return wrapHelper(helper, options => { return wrapHelper(helper, options => {
return Utils.extend({ lookupProperty }, options); options.lookupProperty = lookupProperty;
return options;
}); });
} }
+27
View File
@@ -114,3 +114,30 @@ export function blockParams(params, ids) {
export function appendContextPath(contextPath, id) { export function appendContextPath(contextPath, id) {
return (contextPath ? contextPath + '.' : '') + id; return (contextPath ? contextPath + '.' : '') + id;
} }
/**
* Coerce an untrusted depth value to a safe non-negative integer.
* Returns `0` for any value that is not a finite, non-negative number.
*
* @param {unknown} depth - The depth value to sanitize.
* @returns {number} A non-negative integer.
*/
export function sanitizeDepth(depth) {
let number = Number(depth);
if (!Number.isFinite(number) || number < 0) {
return 0;
}
return Math.floor(number);
}
/**
* Return a sanitized copy of a PathExpression AST node's parts array.
* Coerces each element to a string, or returns an empty array if parts
* is not an array.
*
* @param {unknown} parts - The parts value to sanitize.
* @returns {string[]} A safe string array.
*/
export function sanitizeParts(parts) {
return Array.isArray(parts) ? parts.map(String) : [];
}
+45 -8
View File
@@ -196,16 +196,24 @@ module.exports.cli = function(opts) {
const objectName = opts.partial ? 'Handlebars.partials' : 'templates'; const objectName = opts.partial ? 'Handlebars.partials' : 'templates';
if (opts.namespace && !isValidNamespace(opts.namespace)) {
throw new Handlebars.Exception('Invalid namespace format');
}
let output = new SourceNode(); let output = new SourceNode();
if (!opts.simple) { if (!opts.simple) {
if (opts.amd) { if (opts.amd) {
const runtimeModulePath =
(opts.handlebarPath || '') + 'handlebars.runtime';
output.add( output.add(
"define(['" + 'define([' +
opts.handlebarPath + quoteForJavaScript(runtimeModulePath) +
'handlebars.runtime\'], function(Handlebars) {\n Handlebars = Handlebars["default"];' '], function(Handlebars) {\n Handlebars = Handlebars["default"];'
); );
} else if (opts.commonjs) { } else if (opts.commonjs) {
output.add('var Handlebars = require("' + opts.commonjs + '");'); output.add(
'var Handlebars = require(' + quoteForJavaScript(opts.commonjs) + ');'
);
} else { } else {
output.add('(function() {\n'); output.add('(function() {\n');
} }
@@ -255,9 +263,9 @@ module.exports.cli = function(opts) {
} }
output.add([ output.add([
objectName, objectName,
"['", '[',
template.name, quoteForJavaScript(template.name),
"'] = template(", '] = template(',
precompiled, precompiled,
');\n' ');\n'
]); ]);
@@ -277,7 +285,9 @@ module.exports.cli = function(opts) {
} }
if (opts.map) { if (opts.map) {
output.add('\n//# sourceMappingURL=' + opts.map + '\n'); output.add(
'\n//# sourceMappingURL=' + sanitizeSourceMapComment(opts.map) + '\n'
);
} }
output = output.toStringWithSourceMap(); output = output.toStringWithSourceMap();
@@ -307,6 +317,33 @@ function arrayCast(value) {
return value; return value;
} }
/*
* Safely quotes a value for embedding in generated JavaScript strings
*
* Uses JSON.stringify which handles all special characters.
*/
function quoteForJavaScript(value) {
return JSON.stringify(String(value));
}
/**
* Validates that a namespace is a legitimate dotted JavaScript identifier
* (e.g. "App.templates") to prevent arbitrary code injection
*/
function isValidNamespace(namespace) {
return /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(
namespace
);
}
/**
* Strips line terminators from source map URLs to prevent injection of new
* JavaScript lines via the sourceMappingURL comment
*/
function sanitizeSourceMapComment(value) {
return String(value).replace(/[\r\n\u2028\u2029]/g, '');
}
/** /**
* Run uglify to minify the compiled template, if uglify exists in the dependencies. * Run uglify to minify the compiled template, if uglify exists in the dependencies.
* *
+5739 -13846
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "handlebars", "name": "handlebars",
"barename": "handlebars", "barename": "handlebars",
"version": "4.7.8", "version": "4.7.9",
"description": "Handlebars provides the power necessary to let you build semantic templates effectively with no frustration", "description": "Handlebars provides the power necessary to let you build semantic templates effectively with no frustration",
"homepage": "https://handlebarsjs.com/", "homepage": "https://handlebarsjs.com/",
"keywords": [ "keywords": [
@@ -30,7 +30,7 @@
"uglify-js": "^3.1.4" "uglify-js": "^3.1.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.17.1", "@playwright/test": "1.44.1",
"aws-sdk": "^2.1.49", "aws-sdk": "^2.1.49",
"babel-loader": "^5.0.0", "babel-loader": "^5.0.0",
"babel-runtime": "^5.1.10", "babel-runtime": "^5.1.10",
@@ -39,7 +39,6 @@
"chai-diff": "^1.0.1", "chai-diff": "^1.0.1",
"concurrently": "^5.0.0", "concurrently": "^5.0.0",
"dirty-chai": "^2.0.1", "dirty-chai": "^2.0.1",
"dtslint": "^0.5.5",
"dustjs-linkedin": "^2.0.2", "dustjs-linkedin": "^2.0.2",
"eco": "~1.1.0-rc-3", "eco": "~1.1.0-rc-3",
"eslint": "^6.7.2", "eslint": "^6.7.2",
@@ -47,7 +46,7 @@
"eslint-plugin-compat": "^3.13.0", "eslint-plugin-compat": "^3.13.0",
"eslint-plugin-es5": "^1.4.1", "eslint-plugin-es5": "^1.4.1",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"grunt": "^1.0.4", "grunt": "1.5.3",
"grunt-babel": "^5.0.0", "grunt-babel": "^5.0.0",
"grunt-cli": "^1", "grunt-cli": "^1",
"grunt-contrib-clean": "^1", "grunt-contrib-clean": "^1",
@@ -87,7 +86,7 @@
"lint": "npm run lint:eslint && npm run lint:prettier && npm run lint:types", "lint": "npm run lint:eslint && npm run lint:prettier && npm run lint:types",
"lint:eslint": "eslint --max-warnings 0 .", "lint:eslint": "eslint --max-warnings 0 .",
"lint:prettier": "prettier --check '**/*.js'", "lint:prettier": "prettier --check '**/*.js'",
"lint:types": "dtslint types", "lint:types": "tsc --noEmit --project types",
"test": "npm run test:mocha", "test": "npm run test:mocha",
"test:mocha": "grunt build && grunt test", "test:mocha": "grunt build && grunt test",
"test:browser": "playwright test --config tests/browser/playwright.config.js tests/browser/spec.js", "test:browser": "playwright test --config tests/browser/playwright.config.js tests/browser/spec.js",
+11 -1
View File
@@ -2,7 +2,17 @@
## Development ## Development
[Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...master) [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.9...master)
## v4.7.9 - March 26th, 2026
- fix: enable shell mode for spawn to resolve Windows EINVAL issue - e0137c2
- fix type "RuntimeOptions" also accepting string partials - eab1d14
- feat(types): set `hash` to be a `Record<string, any>` - de4414d
- fix non-contiguous program indices - 4512766
- refactor: rename i to startPartIndex - e497a35
- security: fix security issues - 68d8df5
[Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...v4.7.9)
## v4.7.8 - July 27th, 2023 ## v4.7.8 - July 27th, 2023
+109
View File
@@ -128,6 +128,115 @@ describe('compiler', function() {
); );
}); });
function createPathExpressionAST(depth, parts) {
return {
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
path: {
type: 'PathExpression',
data: false,
depth: depth,
parts: parts,
original: 'this'
},
params: []
}
]
};
}
it('should safely handle AST with non-integer PathExpression depth', function() {
// depth '0' is coerced to 0 via Number(), compiles safely
var result = Handlebars.compile(createPathExpressionAST('0', ['this']))();
expect(result).to.be.a('string');
});
it('should safely handle AST with negative PathExpression depth', function() {
// Negative depth is clamped to 0
var result = Handlebars.compile(createPathExpressionAST(-1, ['this']))();
expect(result).to.be.a('string');
});
it('should safely handle AST with fractional PathExpression depth', function() {
// Fractional depth is floored to an integer
var result = Handlebars.compile(createPathExpressionAST(0.5, ['this']))();
expect(result).to.be.a('string');
});
it('should safely handle AST with non-array PathExpression parts', function() {
// Non-array parts are coerced to empty array, compiles safely
var result = Handlebars.compile(createPathExpressionAST(0, 'this'))();
expect(result).to.be.a('string');
});
it('should safely handle AST with non-string PathExpression part', function() {
// Non-string parts are coerced to strings via String()
var result = Handlebars.compile(createPathExpressionAST(0, [1]))();
expect(result).to.be.a('string');
});
it('should safely handle AST with non-boolean BooleanLiteral value type', function() {
// The compiler coerces BooleanLiteral.value via === true before
// emitting a pushLiteral opcode, so a non-boolean value like the
// string 'true' becomes the literal 'false'.
var loc = {
source: null,
start: { line: 1, column: 0 },
end: { line: 1, column: 10 }
};
var result = Handlebars.compile({
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
loc: loc,
path: {
type: 'BooleanLiteral',
value: 'true',
original: true,
loc: loc
},
params: []
}
]
})();
// 'true' !== true, so the compiler emits pushLiteral('false').
// Handlebars does not render falsy values, so the output is empty.
expect(result).to.equal('');
});
it('should ignore loc metadata in AST nodes', function() {
equal(
Handlebars.compile({
type: 'Program',
meta: null,
loc: { source: 'fake', start: { line: 1, column: 0 } },
body: [{ type: 'ContentStatement', value: 'Hello' }]
})(),
'Hello'
);
});
it('should accept AST with valid NumberLiteral values', function() {
equal(
Handlebars.compile(Handlebars.parse('{{lookup this 1}}'))(['a', 'b']),
'b'
);
});
it('should accept AST with valid BooleanLiteral values', function() {
equal(
Handlebars.compile(Handlebars.parse('{{#if true}}ok{{/if}}'))({}),
'ok'
);
});
it('can pass through an empty string', function() { it('can pass through an empty string', function() {
equal(Handlebars.compile('')(), ''); equal(Handlebars.compile('')(), '');
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates['bom'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { return templates["bom"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "a"; return "a";
},"useData":true}); },"useData":true});
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { return templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return ""; return "";
},"useData":true}); },"useData":true});
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = CustomNamespace.templates = CustomNamespace.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = CustomNamespace.templates = CustomNamespace.templates || {};
return templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { return templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return ""; return "";
},"useData":true}); },"useData":true});
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
(function() { (function() {
var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return ""; return "";
},"useData":true}); },"useData":true});
})(); })();
+3 -3
View File
@@ -1,9 +1,9 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
templates['firstTemplate'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { templates["firstTemplate"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>1</div>"; return "<div>1</div>";
},"useData":true}); },"useData":true});
templates['secondTemplate'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { templates["secondTemplate"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>2</div>"; return "<div>2</div>";
},"useData":true}); },"useData":true});
return templates; return templates;
+2 -2
View File
@@ -1,6 +1,6 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates['artifacts/partial.template'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { return templates["artifacts/partial.template"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>Test Partial</div>"; return "<div>Test Partial</div>";
},"useData":true}); },"useData":true});
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
define(['some-path/handlebars.runtime'], function(Handlebars) { define(["some-path/handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { return templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return ""; return "";
},"useData":true}); },"useData":true});
}); });
+3 -3
View File
@@ -1,9 +1,9 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = someNameSpace = someNameSpace || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = someNameSpace = someNameSpace || {};
templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return ""; return "";
},"useData":true}); },"useData":true});
templates['empty'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { templates["empty"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return ""; return "";
},"useData":true}); },"useData":true});
return templates; return templates;
+2 -2
View File
@@ -1,6 +1,6 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates['non.default.extension'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { return templates["non.default.extension"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>This is a test</div>"; return "<div>This is a test</div>";
},"useData":true}); },"useData":true});
}); });
+5 -5
View File
@@ -1,6 +1,6 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return templates['known.helpers'] = template({"1":function(container,depth0,helpers,partials,data) { return templates["known.helpers"] = template({"0":function(container,depth0,helpers,partials,data) {
var stack1, lookupProperty = container.lookupProperty || function(parent, propertyName) { var stack1, lookupProperty = container.lookupProperty || function(parent, propertyName) {
if (Object.prototype.hasOwnProperty.call(parent, propertyName)) { if (Object.prototype.hasOwnProperty.call(parent, propertyName)) {
return parent[propertyName]; return parent[propertyName];
@@ -8,8 +8,8 @@ return parent[propertyName];
return undefined return undefined
}; };
return " <div>Some known helper</div>\n" return " <div>Some known helper</div>\n"
+ ((stack1 = lookupProperty(helpers,"anotherHelper").call(depth0 != null ? depth0 : (container.nullContext || {}),true,{"name":"anotherHelper","hash":{},"fn":container.program(2, data, 0),"inverse":container.noop,"data":data,"loc":{"start":{"line":3,"column":4},"end":{"line":5,"column":22}}})) != null ? stack1 : ""); + ((stack1 = lookupProperty(helpers,"anotherHelper").call(depth0 != null ? depth0 : (container.nullContext || {}),true,{"name":"anotherHelper","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data,"loc":{"start":{"line":3,"column":4},"end":{"line":5,"column":22}}})) != null ? stack1 : "");
},"2":function(container,depth0,helpers,partials,data) { },"1":function(container,depth0,helpers,partials,data) {
return " <div>Another known helper</div>\n"; return " <div>Another known helper</div>\n";
},"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { },"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
var stack1, lookupProperty = container.lookupProperty || function(parent, propertyName) { var stack1, lookupProperty = container.lookupProperty || function(parent, propertyName) {
@@ -18,7 +18,7 @@ return parent[propertyName];
} }
return undefined return undefined
}; };
return ((stack1 = lookupProperty(helpers,"someHelper").call(depth0 != null ? depth0 : (container.nullContext || {}),true,{"name":"someHelper","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data,"loc":{"start":{"line":1,"column":0},"end":{"line":6,"column":15}}})) != null ? stack1 : ""); return ((stack1 = lookupProperty(helpers,"someHelper").call(depth0 != null ? depth0 : (container.nullContext || {}),true,{"name":"someHelper","hash":{},"fn":container.program(0, data, 0),"inverse":container.noop,"data":data,"loc":{"start":{"line":1,"column":0},"end":{"line":6,"column":15}}})) != null ? stack1 : "");
},"useData":true}); },"useData":true});
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
define(['handlebars.runtime'], function(Handlebars) { define(["handlebars.runtime"], function(Handlebars) {
Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; Handlebars = Handlebars["default"]; var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
return Handlebars.partials['partial.template'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) { return Handlebars.partials["partial.template"] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
return "<div>Test Partial</div>"; return "<div>Test Partial</div>";
},"useData":true}); },"useData":true});
}); });
+82 -1
View File
@@ -182,7 +182,7 @@ describe('precompiler', function() {
return 'amd'; return 'amd';
}; };
Precompiler.cli({ templates: [emptyTemplate], amd: true, partial: true }); Precompiler.cli({ templates: [emptyTemplate], amd: true, partial: true });
equal(/return Handlebars\.partials\['empty'\]/.test(log), true); equal(/return Handlebars\.partials\["empty"\]/.test(log), true);
equal(/template\(amd\)/.test(log), true); equal(/template\(amd\)/.test(log), true);
}); });
it('should output multiple amd partials', function() { it('should output multiple amd partials', function() {
@@ -405,4 +405,85 @@ describe('precompiler', function() {
}); });
}); });
}); });
describe('GHSA-xjpj-3mr7-gcpf: precompiler output escaping', function() {
var FullHandlebars = require('../dist/cjs/handlebars')['default'];
function runCliAndCaptureOutput(options) {
var output = '';
var oldLog = console.log;
console.log = function() {
output += Array.prototype.join.call(arguments, '');
};
try {
Precompiler.cli(options);
} finally {
console.log = oldLog;
}
return output;
}
it('should not inject raw template names into generated code', function() {
var output = runCliAndCaptureOutput({
templates: [
{
name: "evil'];global.__xjpjName=1;//",
source: ''
}
],
amd: true
});
expect(output).to.not.match(/\['evil'\];global\.__xjpjName=1/);
});
it('should not inject raw commonjs option values into generated code', function() {
var output = runCliAndCaptureOutput({
templates: [{ name: 'safe', source: '' }],
commonjs: 'handlebars");global.__xjpjCommon=1;//'
});
expect(output).to.not.match(
/require\("handlebars"\);global\.__xjpjCommon=1/
);
});
it('should reject invalid namespace expressions', function() {
expect(function() {
runCliAndCaptureOutput({
templates: [{ name: 'safe', source: '' }],
namespace: 'App.ns;global.__xjpjNamespace=1;//'
});
}).to.throw(/Invalid namespace/);
});
it('should sanitize sourceMappingURL comment values', function() {
var oldPrecompile = FullHandlebars.precompile;
var oldWriteFileSync = fs.writeFileSync;
FullHandlebars.precompile = function() {
return {
code: '""',
map: '{"version":3,"sources":[],"names":[],"mappings":""}'
};
};
fs.writeFileSync = function() {};
var output;
try {
output = runCliAndCaptureOutput({
templates: [{ name: 'safe', source: '' }],
map: 'good.js.map\n;global.__xjpjMap=1;//'
});
} finally {
FullHandlebars.precompile = oldPrecompile;
fs.writeFileSync = oldWriteFileSync;
}
expect(output).to.not.match(
/sourceMappingURL=[^\n]*\n;global\.__xjpjMap=1/
);
});
});
}); });
+10 -3
View File
@@ -54,6 +54,13 @@ describe('runtime', function() {
/Template was precompiled with an older version of Handlebars than the current runtime/ /Template was precompiled with an older version of Handlebars than the current runtime/
); );
}); });
it('should safely resolve missing partial map entries', function() {
equal(
Handlebars.VM.resolvePartial(undefined, {}, { name: 'missing' }),
undefined
);
});
}); });
describe('#child', function() { describe('#child', function() {
@@ -91,13 +98,13 @@ describe('runtime', function() {
it('should expose child template', function() { it('should expose child template', function() {
var template = Handlebars.compile('{{#foo}}bar{{/foo}}'); var template = Handlebars.compile('{{#foo}}bar{{/foo}}');
// Calling twice to hit the non-compiled case. // Calling twice to hit the non-compiled case.
equal(template._child(1)(), 'bar'); equal(template._child(0)(), 'bar');
equal(template._child(1)(), 'bar'); equal(template._child(0)(), 'bar');
}); });
it('should render depthed content', function() { it('should render depthed content', function() {
var template = Handlebars.compile('{{#foo}}{{../bar}}{{/foo}}'); var template = Handlebars.compile('{{#foo}}{{../bar}}{{/foo}}');
// Calling twice to hit the non-compiled case. // Calling twice to hit the non-compiled case.
equal(template._child(1, undefined, [], [{ bar: 'baz' }])(), 'baz'); equal(template._child(0, undefined, [], [{ bar: 'baz' }])(), 'baz');
}); });
}); });
+220
View File
@@ -133,11 +133,13 @@ describe('security issues', function() {
'{{__defineGetter__}}', '{{__defineGetter__}}',
'{{__defineSetter__}}', '{{__defineSetter__}}',
'{{__lookupGetter__}}', '{{__lookupGetter__}}',
'{{__lookupSetter__}}',
'{{__proto__}}', '{{__proto__}}',
'{{lookup this "constructor"}}', '{{lookup this "constructor"}}',
'{{lookup this "__defineGetter__"}}', '{{lookup this "__defineGetter__"}}',
'{{lookup this "__defineSetter__"}}', '{{lookup this "__defineSetter__"}}',
'{{lookup this "__lookupGetter__"}}', '{{lookup this "__lookupGetter__"}}',
'{{lookup this "__lookupSetter__"}}',
'{{lookup this "__proto__"}}' '{{lookup this "__proto__"}}'
]; ];
@@ -422,6 +424,224 @@ describe('security issues', function() {
.toCompileTo('c'); .toCompileTo('c');
}); });
}); });
describe('GHSA-2qvq-rjwj-gvw9: partial resolution must not use polluted prototypes', function() {
if (!Handlebars.compile) {
return;
}
afterEach(function() {
delete Object.prototype.widget;
});
it('should not resolve partial names from Object.prototype', function() {
// eslint-disable-next-line no-extend-native
Object.prototype.widget = '<img src=x onerror="alert(1)">';
expect(function() {
Handlebars.compile('<div>{{> widget}}</div>')({});
}).to.throw(/could not be found/);
});
});
describe('GHSA-2w6w-674q-4c4q, GHSA-xhpv-hc6g-r9c6, GHSA-3mfm-83xf-c92r: untrusted AST inputs', function() {
if (!Handlebars.compile) {
return;
}
function createInjectedProgram() {
var loc = {
source: null,
start: { line: 1, column: 0 },
end: { line: 1, column: 20 }
};
return {
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: {
open: false,
close: false
},
loc: loc,
path: {
type: 'PathExpression',
data: false,
depth: 0,
parts: ['lookup'],
original: 'lookup',
loc: loc
},
params: [
{
type: 'PathExpression',
data: false,
depth: 0,
parts: [],
original: 'this',
loc: loc
},
{
type: 'NumberLiteral',
value: '{},{})) + (Function) + (({}',
original: 1,
loc: loc
}
]
}
]
};
}
it('should neutralize AST NumberLiteral type confusion in compile()', function() {
// The compiler coerces NumberLiteral.value via Number() before
// emitting a pushLiteral opcode, so a type-confused string value
// becomes NaN, preventing code injection.
var template = Handlebars.compile(createInjectedProgram());
var result = template({});
expect(result).to.not.contain('Function');
});
it('should reject AST objects passed via dynamic partial lookup', function() {
expect(function() {
var template = Handlebars.compile('{{> (lookup . "payload")}}');
template({
payload: createInjectedProgram()
});
}).to.throw(/could not be found/);
});
it('should sanitize param depth in stringParams mode', function() {
// pushParam passes val.depth directly to the getContext opcode.
// In stringParams mode, getContext stores the depth in lastContext,
// which contextName interpolates into generated code as
// 'depths[' + depth + ']'. A malicious depth string can escape the
// bracket expression and inject arbitrary code at template runtime.
//
// With sanitization the depth becomes 0, producing 'depth0' (safe).
// Without sanitization the injected expression executes and throws.
var loc = {
source: null,
start: { line: 1, column: 0 },
end: { line: 1, column: 20 }
};
var maliciousAST = {
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
loc: loc,
path: {
type: 'PathExpression',
data: false,
depth: 0,
parts: ['lookup'],
original: 'lookup',
loc: loc
},
params: [
{
type: 'PathExpression',
data: false,
depth: 'function(){throw new Error("INJECTION")}()',
parts: [],
original: '',
loc: loc
}
]
}
]
};
var template = Handlebars.compile(maliciousAST, {
stringParams: true
});
// After sanitization the depth is 0, so the template runs without
// executing the injected throw expression.
expect(function() {
template({});
}).to.not.throw();
});
});
describe('GHSA-442j-39wm-28r2: lookup must return checked value', function() {
it('should use the validated value from lookupProperty() in compat mode', function() {
var input = { child: {} };
var readCount = 0;
Object.defineProperty(input, 'unstable', {
enumerable: true,
get: function() {
readCount++;
return readCount === 1 ? 'first-read' : 'second-read';
}
});
expectTemplate('{{#with child}}{{unstable}}{{/with}}')
.withInput(input)
.withCompileOptions({ compat: true })
.toCompileTo('first-read');
});
});
describe('GHSA-9cx6-37pm-9jff: malformed decorators should fail safely', function() {
if (!Handlebars.compile) {
return;
}
it('should throw a controlled error for unknown decorators', function() {
var template = Handlebars.compile('{{*notRegistered}}');
expect(function() {
template({});
}).to.throw(/Missing decorator|not registered/);
});
});
describe('GHSA-new: @partial-block must not resolve from polluted prototype', function() {
if (!Handlebars.compile) {
return;
}
afterEach(function() {
delete Object.prototype['partial-block'];
});
it('should not resolve @partial-block from Object.prototype', function() {
// eslint-disable-next-line no-extend-native
Object.prototype['partial-block'] = '<img src=x onerror="alert(1)">';
expect(function() {
Handlebars.compile('{{> @partial-block}}')({});
}).to.throw(/could not be found/);
});
it('should not resolve @partial-block from Object.prototype inside a partial', function() {
// eslint-disable-next-line no-extend-native
Object.prototype['partial-block'] = '<img src=x onerror="alert(1)">';
Handlebars.registerPartial('testPartial', '{{> @partial-block}}');
try {
expect(function() {
Handlebars.compile('{{> testPartial}}')({});
}).to.throw(/could not be found/);
} finally {
Handlebars.unregisterPartial('testPartial');
}
});
it('should still render legitimate @partial-block content', function() {
Handlebars.registerPartial('wrapper', '<div>{{> @partial-block}}</div>');
try {
var result = Handlebars.compile('{{#> wrapper}}hello{{/wrapper}}')({});
expect(result).to.equal('<div>hello</div>');
} finally {
Handlebars.unregisterPartial('wrapper');
}
});
});
}); });
function wrapToAdjustContainer(precompiledTemplateFunction) { function wrapToAdjustContainer(precompiledTemplateFunction) {
+2 -1
View File
@@ -23,7 +23,8 @@ async function execFileWithInheritedOutput(command, args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const resolvedCommand = preferLocalDependencies(command); const resolvedCommand = preferLocalDependencies(command);
const child = childProcess.spawn(resolvedCommand, args, { const child = childProcess.spawn(resolvedCommand, args, {
stdio: 'inherit' stdio: 'inherit',
shell: process.platform === 'win32' // Workaround for CVE-2024-27980
}); });
child.on('exit', code => { child.on('exit', code => {
if (code !== 0) { if (code !== 0) {
+1 -2
View File
@@ -9,6 +9,5 @@ Execute the following commands in the project root:
```bash ```bash
npm install npm install
npx grunt prepare npx grunt prepare
docker pull mcr.microsoft.com/playwright:focal docker run -it --rm --volume $(pwd):/srv/app --workdir /srv/app --ipc=host mcr.microsoft.com/playwright:v1.44.1-jammy npm run test:browser
docker run -it --rm --volume $(pwd):/srv/app --workdir /srv/app --ipc=host mcr.microsoft.com/playwright:focal npm run test:browser
``` ```
+1
View File
@@ -2,6 +2,7 @@ const { devices } = require('@playwright/test');
/** @type {import('@playwright/test').PlaywrightTestConfig} */ /** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = { const config = {
testMatch: ['spec.js'],
projects: [ projects: [
{ {
name: 'chromium', name: 'chromium',
+10 -10
View File
@@ -7,27 +7,27 @@ async function waitForMochaAndAssertResult(page) {
expect(mochaResults.failures).toBe(0); expect(mochaResults.failures).toBe(0);
} }
test('Spec handlebars.js', async ({ page, baseURL }) => { test('Spec handlebars.js', async ({ page }) => {
await page.goto(`${baseURL}/spec/?headless=true`); await page.goto(`/spec/?headless=true`);
await waitForMochaAndAssertResult(page); await waitForMochaAndAssertResult(page);
}); });
test('Spec handlebars.amd.js (AMD)', async ({ page, baseURL }) => { test('Spec handlebars.amd.js (AMD)', async ({ page }) => {
await page.goto(`${baseURL}/spec/amd.html?headless=true`); await page.goto(`/spec/amd.html?headless=true`);
await waitForMochaAndAssertResult(page); await waitForMochaAndAssertResult(page);
}); });
test('Spec handlebars.runtime.amd.js (AMD)', async ({ page, baseURL }) => { test('Spec handlebars.runtime.amd.js (AMD)', async ({ page }) => {
await page.goto(`${baseURL}/spec/amd-runtime.html?headless=true`); await page.goto(`/spec/amd-runtime.html?headless=true`);
await waitForMochaAndAssertResult(page); await waitForMochaAndAssertResult(page);
}); });
test('Spec handlebars.js (UMD)', async ({ page, baseURL }) => { test('Spec handlebars.js (UMD)', async ({ page }) => {
await page.goto(`${baseURL}/spec/umd.html?headless=true`); await page.goto(`/spec/umd.html?headless=true`);
await waitForMochaAndAssertResult(page); await waitForMochaAndAssertResult(page);
}); });
test('Spec handlebars.runtime.js (UMD)', async ({ page, baseURL }) => { test('Spec handlebars.runtime.js (UMD)', async ({ page }) => {
await page.goto(`${baseURL}/spec/umd-runtime.html?headless=true`); await page.goto(`/spec/umd-runtime.html?headless=true`);
await waitForMochaAndAssertResult(page); await waitForMochaAndAssertResult(page);
}); });
+3 -3
View File
@@ -25,7 +25,7 @@ declare namespace Handlebars {
partial?: boolean; partial?: boolean;
depths?: any[]; depths?: any[];
helpers?: { [name: string]: Function }; helpers?: { [name: string]: Function };
partials?: { [name: string]: HandlebarsTemplateDelegate }; partials?: { [name: string]: Template };
decorators?: { [name: string]: Function }; decorators?: { [name: string]: Function };
data?: any; data?: any;
blockParams?: any[]; blockParams?: any[];
@@ -39,7 +39,7 @@ declare namespace Handlebars {
export interface HelperOptions { export interface HelperOptions {
fn: TemplateDelegate; fn: TemplateDelegate;
inverse: TemplateDelegate; inverse: TemplateDelegate;
hash: any; hash: Record<string, any>;
data?: any; data?: any;
} }
@@ -60,7 +60,7 @@ declare namespace Handlebars {
export function unregisterHelper(name: string): void; export function unregisterHelper(name: string): void;
export function registerPartial(name: string, fn: Template): void; export function registerPartial(name: string, fn: Template): void;
export function registerPartial(spec: { [name: string]: HandlebarsTemplateDelegate }): void; export function registerPartial(spec: { [name: string]: Template }): void;
export function unregisterPartial(name: string): void; export function unregisterPartial(name: string): void;
// TODO: replace Function with actual signature // TODO: replace Function with actual signature
+3
View File
@@ -249,6 +249,9 @@ function testProtoAccessControlControlOptions() {
allowedProtoProperties: { allowedProperty: true, forbiddenProperty: false }, allowedProtoProperties: { allowedProperty: true, forbiddenProperty: false },
allowProtoMethodsByDefault: true, allowProtoMethodsByDefault: true,
allowProtoPropertiesByDefault: false, allowProtoPropertiesByDefault: false,
partials: {
link: '<a href="/people/{{id}}">{{name}}</a>'
}
} }
); );
} }