Compare commits

..

3 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
7 changed files with 209 additions and 211 deletions
-1
View File
@@ -173,7 +173,6 @@ A full release via Docker may be completed with the following:
docker run --rm --interactive --tty \
--volume $PWD:/app \
--workdir /app \
--user $(id -u):$(id -g) \
ruby:3.2-slim bash
```
* Execute the following steps:
+2 -6
View File
@@ -1,7 +1,7 @@
{
"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.",
"homepage": "http://handlebarsjs.com",
"homepage": "https://handlebarsjs.com",
"license": "MIT",
"type": "component",
"keywords": [
@@ -11,13 +11,9 @@
],
"authors": [
{
"name": "Chris Wanstrath",
"homepage": "http://chriswanstrath.com"
"name": "Chris Wanstrath"
}
],
"require": {
"robloach/component-installer": "*"
},
"extra": {
"component": {
"name": "handlebars",
-67
View File
@@ -1,7 +1,6 @@
import parser from './parser';
import WhitespaceControl from './whitespace-control';
import * as Helpers from './helpers';
import Exception from '../exception';
import { extend } from '../utils';
export { parser };
@@ -12,9 +11,6 @@ extend(yy, Helpers);
export function parseWithoutProcessing(input, options) {
// Just return if an already-compiled AST was passed in.
if (input.type === 'Program') {
// When a pre-parsed AST is passed in, validate all node values to prevent
// code injection via type-confused literals.
validateInputAst(input);
return input;
}
@@ -36,66 +32,3 @@ export function parse(input, options) {
return strip.accept(ast);
}
function validateInputAst(ast) {
validateAstNode(ast);
}
function validateAstNode(node) {
if (node == null) {
return;
}
if (Array.isArray(node)) {
node.forEach(validateAstNode);
return;
}
if (typeof node !== 'object') {
return;
}
if (node.type === 'PathExpression') {
if (!isValidDepth(node.depth)) {
throw new Exception(
'Invalid AST: PathExpression.depth must be an integer'
);
}
if (!Array.isArray(node.parts)) {
throw new Exception('Invalid AST: PathExpression.parts must be an array');
}
for (let i = 0; i < node.parts.length; i++) {
if (typeof node.parts[i] !== 'string') {
throw new Exception(
'Invalid AST: PathExpression.parts must only contain strings'
);
}
}
} else if (node.type === 'NumberLiteral') {
if (typeof node.value !== 'number' || !isFinite(node.value)) {
throw new Exception('Invalid AST: NumberLiteral.value must be a number');
}
} else if (node.type === 'BooleanLiteral') {
if (typeof node.value !== 'boolean') {
throw new Exception(
'Invalid AST: BooleanLiteral.value must be a boolean'
);
}
}
Object.keys(node).forEach(propertyName => {
if (propertyName === 'loc') {
return;
}
validateAstNode(node[propertyName]);
});
}
function isValidDepth(depth) {
return (
typeof depth === 'number' &&
isFinite(depth) &&
Math.floor(depth) === depth &&
depth >= 0
);
}
+30 -21
View File
@@ -1,7 +1,13 @@
/* eslint-disable new-cap */
import Exception from '../exception';
import { isArray, indexOf, extend } from '../utils';
import {
isArray,
indexOf,
extend,
sanitizeDepth,
sanitizeParts
} from '../utils';
import AST from './ast';
const slice = [].slice;
@@ -243,7 +249,7 @@ Compiler.prototype = {
name = path.parts[0],
isBlock = program != null || inverse != null;
this.opcode('getContext', path.depth);
this.opcode('getContext', sanitizeDepth(path.depth));
this.opcode('pushProgram', program);
this.opcode('pushProgram', inverse);
@@ -288,29 +294,32 @@ Compiler.prototype = {
},
PathExpression: function(path) {
this.addDepth(path.depth);
this.opcode('getContext', path.depth);
// Sanitize untrusted AST values at the compiler boundary.
// 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),
blockParamId = !path.depth && !scoped && this.blockParamIndex(name);
blockParamId = !depth && !scoped && this.blockParamIndex(name);
if (blockParamId) {
this.opcode('lookupBlockParam', blockParamId, path.parts);
this.opcode(
'lookupBlockParam',
[Number(blockParamId[0]), Number(blockParamId[1])],
parts
);
} else if (!name) {
// Context reference, i.e. `{{foo .}}` or `{{foo ..}}`
this.opcode('pushContext');
} else if (path.data) {
this.options.data = true;
this.opcode('lookupData', path.depth, path.parts, path.strict);
this.opcode('lookupData', depth, parts, path.strict);
} else {
this.opcode(
'lookupOnContext',
path.parts,
path.falsy,
path.strict,
scoped
);
this.opcode('lookupOnContext', parts, path.falsy, path.strict, scoped);
}
},
@@ -319,11 +328,11 @@ Compiler.prototype = {
},
NumberLiteral: function(number) {
this.opcode('pushLiteral', number.value);
this.opcode('pushLiteral', Number(number.value));
},
BooleanLiteral: function(bool) {
this.opcode('pushLiteral', bool.value);
this.opcode('pushLiteral', bool.value === true ? 'true' : 'false');
},
UndefinedLiteral: function() {
@@ -410,16 +419,16 @@ Compiler.prototype = {
pushParam: function(val) {
let value = val.value != null ? val.value : val.original || '';
let depth = sanitizeDepth(val.depth);
if (this.stringParams) {
if (value.replace) {
value = value.replace(/^(\.?\.\/)*/g, '').replace(/\//g, '.');
}
if (val.depth) {
this.addDepth(val.depth);
if (depth) {
this.addDepth(depth);
}
this.opcode('getContext', val.depth || 0);
this.opcode('getContext', depth);
this.opcode('pushStringParam', value, val.type);
if (val.type === 'SubExpression') {
+27
View File
@@ -114,3 +114,30 @@ export function blockParams(params, ids) {
export function appendContextPath(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) : [];
}
+76 -107
View File
@@ -128,121 +128,90 @@ describe('compiler', function() {
);
});
it('should reject AST with invalid PathExpression depth', function() {
shouldThrow(
function() {
Handlebars.compile({
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
path: {
type: 'PathExpression',
data: false,
depth: '0',
parts: ['this'],
original: 'this'
},
params: []
}
]
})();
},
Error,
'Invalid AST: PathExpression.depth must be an integer'
);
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 reject AST with non-array PathExpression parts', function() {
shouldThrow(
function() {
Handlebars.compile({
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
path: {
type: 'PathExpression',
data: false,
depth: 0,
parts: 'this',
original: 'this'
},
params: []
}
]
})();
},
Error,
'Invalid AST: PathExpression.parts must be an array'
);
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 reject AST with non-string PathExpression part', function() {
shouldThrow(
function() {
Handlebars.compile({
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
path: {
type: 'PathExpression',
data: false,
depth: 0,
parts: [1],
original: 'this'
},
params: []
}
]
})();
},
Error,
'Invalid AST: PathExpression.parts must only contain strings'
);
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 reject AST with invalid BooleanLiteral value type', function() {
shouldThrow(
function() {
Handlebars.compile({
type: 'Program',
body: [
{
type: 'MustacheStatement',
escaped: true,
strip: { open: false, close: false },
path: {
type: 'PathExpression',
data: false,
depth: 0,
parts: ['if'],
original: 'if'
},
params: [
{
type: 'BooleanLiteral',
value: 'true',
original: true
}
]
}
]
})();
},
Error,
'Invalid AST: BooleanLiteral.value must be a boolean'
);
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 ignore loc metadata while validating AST nodes', function() {
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',
+74 -9
View File
@@ -450,6 +450,11 @@ describe('security issues', function() {
}
function createInjectedProgram() {
var loc = {
source: null,
start: { line: 1, column: 0 },
end: { line: 1, column: 20 }
};
return {
type: 'Program',
body: [
@@ -460,12 +465,14 @@ describe('security issues', function() {
open: false,
close: false
},
loc: loc,
path: {
type: 'PathExpression',
data: false,
depth: 0,
parts: ['lookup'],
original: 'lookup'
original: 'lookup',
loc: loc
},
params: [
{
@@ -473,12 +480,14 @@ describe('security issues', function() {
data: false,
depth: 0,
parts: [],
original: 'this'
original: 'this',
loc: loc
},
{
type: 'NumberLiteral',
value: '{},{})) + (Function) + (({}',
original: 1
original: 1,
loc: loc
}
]
}
@@ -486,11 +495,13 @@ describe('security issues', function() {
};
}
it('should reject AST NumberLiteral type confusion in compile()', function() {
expect(function() {
var template = Handlebars.compile(createInjectedProgram());
template({});
}).to.throw(/Invalid AST/);
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() {
@@ -499,7 +510,61 @@ describe('security issues', function() {
template({
payload: createInjectedProgram()
});
}).to.throw(/Invalid AST|could not be found/);
}).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();
});
});