diff --git a/lib/handlebars/compiler/base.js b/lib/handlebars/compiler/base.js index 1dd5af1a..7ce7d70a 100644 --- a/lib/handlebars/compiler/base.js +++ b/lib/handlebars/compiler/base.js @@ -1,6 +1,7 @@ import parser from './parser'; import WhitespaceControl from './whitespace-control'; import * as Helpers from './helpers'; +import Exception from '../exception'; import { extend } from '../utils'; export { parser }; @@ -11,6 +12,9 @@ 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; } @@ -32,3 +36,66 @@ 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 + ); +} diff --git a/lib/handlebars/compiler/javascript-compiler.js b/lib/handlebars/compiler/javascript-compiler.js index c928bab7..83c53dad 100644 --- a/lib/handlebars/compiler/javascript-compiler.js +++ b/lib/handlebars/compiler/javascript-compiler.js @@ -686,9 +686,18 @@ JavaScriptCompiler.prototype = { let foundDecorator = this.nameLookup('decorators', name, 'decorator'), 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([ 'fn = ', - this.decorators.functionCall(foundDecorator, '', [ + this.decorators.functionCall('decorator', '', [ 'fn', 'props', 'container', diff --git a/lib/handlebars/internal/proto-access.js b/lib/handlebars/internal/proto-access.js index 56cf12e6..b1bb05e0 100644 --- a/lib/handlebars/internal/proto-access.js +++ b/lib/handlebars/internal/proto-access.js @@ -16,6 +16,7 @@ export function createProtoAccessControl(runtimeOptions) { methodWhiteList['__defineGetter__'] = false; methodWhiteList['__defineSetter__'] = false; methodWhiteList['__lookupGetter__'] = false; + methodWhiteList['__lookupSetter__'] = false; extend(methodWhiteList, runtimeOptions.allowedProtoMethods); return { diff --git a/lib/handlebars/runtime.js b/lib/handlebars/runtime.js index f1117649..12d60c64 100644 --- a/lib/handlebars/runtime.js +++ b/lib/handlebars/runtime.js @@ -138,7 +138,7 @@ export function template(templateSpec, env) { for (let i = 0; i < len; i++) { let result = depths[i] && container.lookupProperty(depths[i], name); if (result != null) { - return depths[i][name]; + return result; } } }, @@ -349,21 +349,21 @@ export function wrapProgram( export function resolvePartial(partial, context, options) { if (!partial) { if (options.name === '@partial-block') { - partial = options.data['partial-block']; + partial = lookupOwnProperty(options.data, 'partial-block'); } else { - partial = options.partials[options.name]; + partial = lookupOwnProperty(options.partials, options.name); } } else if (!partial.call && !options.name) { // This is a dynamic partial that returned a string options.name = partial; - partial = options.partials[partial]; + partial = lookupOwnProperty(options.partials, partial); } return partial; } export function invokePartial(partial, context, options) { // 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; if (options.ids) { options.data.contextPath = options.ids[0] || options.data.contextPath; @@ -404,6 +404,12 @@ export function noop() { return ''; } +function lookupOwnProperty(obj, name) { + if (obj && Object.prototype.hasOwnProperty.call(obj, name)) { + return obj[name]; + } +} + function initData(context, data) { if (!data || !('root' in data)) { data = data ? createFrame(data) : {}; diff --git a/lib/precompiler.js b/lib/precompiler.js index ab605693..029a25fb 100644 --- a/lib/precompiler.js +++ b/lib/precompiler.js @@ -196,16 +196,24 @@ module.exports.cli = function(opts) { const objectName = opts.partial ? 'Handlebars.partials' : 'templates'; + if (opts.namespace && !isValidNamespace(opts.namespace)) { + throw new Handlebars.Exception('Invalid namespace format'); + } + let output = new SourceNode(); if (!opts.simple) { if (opts.amd) { + const runtimeModulePath = + (opts.handlebarPath || '') + 'handlebars.runtime'; output.add( - "define(['" + - opts.handlebarPath + - 'handlebars.runtime\'], function(Handlebars) {\n Handlebars = Handlebars["default"];' + 'define([' + + quoteForJavaScript(runtimeModulePath) + + '], function(Handlebars) {\n Handlebars = Handlebars["default"];' ); } else if (opts.commonjs) { - output.add('var Handlebars = require("' + opts.commonjs + '");'); + output.add( + 'var Handlebars = require(' + quoteForJavaScript(opts.commonjs) + ');' + ); } else { output.add('(function() {\n'); } @@ -255,9 +263,9 @@ module.exports.cli = function(opts) { } output.add([ objectName, - "['", - template.name, - "'] = template(", + '[', + quoteForJavaScript(template.name), + '] = template(', precompiled, ');\n' ]); @@ -277,7 +285,9 @@ module.exports.cli = function(opts) { } if (opts.map) { - output.add('\n//# sourceMappingURL=' + opts.map + '\n'); + output.add( + '\n//# sourceMappingURL=' + sanitizeSourceMapComment(opts.map) + '\n' + ); } output = output.toStringWithSourceMap(); @@ -307,6 +317,33 @@ function arrayCast(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. * diff --git a/spec/compiler.js b/spec/compiler.js index fe394b72..d51f2f1b 100644 --- a/spec/compiler.js +++ b/spec/compiler.js @@ -128,6 +128,146 @@ 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' + ); + }); + + 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 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 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 ignore loc metadata while validating 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() { equal(Handlebars.compile('')(), ''); }); diff --git a/spec/expected/bom.amd.js b/spec/expected/bom.amd.js index 906989fe..736bd420 100644 --- a/spec/expected/bom.amd.js +++ b/spec/expected/bom.amd.js @@ -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 || {}; - 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"; },"useData":true}); }); \ No newline at end of file diff --git a/spec/expected/empty.amd.js b/spec/expected/empty.amd.js index 9728609e..336f1e62 100644 --- a/spec/expected/empty.amd.js +++ b/spec/expected/empty.amd.js @@ -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 || {}; -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 ""; },"useData":true}); }); diff --git a/spec/expected/empty.amd.namespace.js b/spec/expected/empty.amd.namespace.js index 1972fb43..995469e0 100644 --- a/spec/expected/empty.amd.namespace.js +++ b/spec/expected/empty.amd.namespace.js @@ -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 || {}; -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 ""; },"useData":true}); }); diff --git a/spec/expected/empty.common.js b/spec/expected/empty.common.js index 099f2f2d..06f26f3e 100644 --- a/spec/expected/empty.common.js +++ b/spec/expected/empty.common.js @@ -1,6 +1,6 @@ (function() { 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 ""; },"useData":true}); })(); \ No newline at end of file diff --git a/spec/expected/empty.name.amd.js b/spec/expected/empty.name.amd.js index 13c377f6..33f1a449 100644 --- a/spec/expected/empty.name.amd.js +++ b/spec/expected/empty.name.amd.js @@ -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 || {}; -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 "