CVE-2026-33940
Description
Handlebars provides the power necessary to let users build semantic templates. In versions 4.0.0 through 4.7.8, a crafted object placed in the template context can bypass all conditional guards in resolvePartial() and cause invokePartial() to return undefined. The Handlebars runtime then treats the unresolved partial as a source that needs to be compiled, passing the crafted object to env.compile(). Because the object is a valid Handlebars AST containing injected code, the generated JavaScript executes arbitrary commands on the server. The attack requires the adversary to control a value that can be returned by a dynamic partial lookup. Version 4.7.9 fixes the issue. Some workarounds are available. First, use the runtime-only build (require('handlebars/runtime')). Without compile(), the fallback compilation path in invokePartial is unreachable. Second, sanitize context data before rendering: Ensure no value in the context is a non-primitive object that could be passed to a dynamic partial. Third, avoid dynamic partial lookups ({{> (lookup ...)}}) when context data is user-controlled.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
handlebarsnpm | >= 4.0.0, < 4.7.9 | 4.7.9 |
Affected products
1Patches
168d8df5a88e0Fix security issues
20 files changed · +541 −38
lib/handlebars/compiler/base.js+67 −0 modified@@ -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 + ); +}
lib/handlebars/compiler/javascript-compiler.js+10 −1 modified@@ -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',
lib/handlebars/internal/proto-access.js+1 −0 modified@@ -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 {
lib/handlebars/runtime.js+11 −5 modified@@ -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) : {};
lib/precompiler.js+45 −8 modified@@ -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. *
spec/compiler.js+140 −0 modified@@ -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('')(), ''); });
spec/expected/bom.amd.js+2 −2 modified@@ -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
spec/expected/empty.amd.js+2 −2 modified@@ -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}); });
spec/expected/empty.amd.namespace.js+2 −2 modified@@ -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}); });
spec/expected/empty.common.js+1 −1 modified@@ -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
spec/expected/empty.name.amd.js+3 −3 modified@@ -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 "<div>1</div>"; },"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>"; },"useData":true}); return templates;
spec/expected/empty.root.amd.js+2 −2 modified@@ -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['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>"; },"useData":true}); }); \ No newline at end of file
spec/expected/handlebar.path.amd.js+2 −2 modified@@ -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 || {}; - 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}); }); \ No newline at end of file
spec/expected/namespace.amd.js+3 −3 modified@@ -1,9 +1,9 @@ -define(['handlebars.runtime'], function(Handlebars) { +define(["handlebars.runtime"], function(Handlebars) { 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 ""; },"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 ""; },"useData":true}); return templates;
spec/expected/non.default.extension.amd.js+2 −2 modified@@ -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['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>"; },"useData":true}); }); \ No newline at end of file
spec/expected/non.empty.amd.known.helper.js+2 −2 modified@@ -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['known.helpers'] = template({"0":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) { if (Object.prototype.hasOwnProperty.call(parent, propertyName)) { return parent[propertyName];
spec/expected/partial.template.js+2 −2 modified@@ -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 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>"; },"useData":true}); }); \ No newline at end of file
spec/precompiler.js+82 −1 modified@@ -182,7 +182,7 @@ describe('precompiler', function() { return 'amd'; }; 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); }); 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/ + ); + }); + }); });
spec/runtime.js+7 −0 modified@@ -54,6 +54,13 @@ describe('runtime', function() { /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() {
spec/security.js+155 −0 modified@@ -133,11 +133,13 @@ describe('security issues', function() { '{{__defineGetter__}}', '{{__defineSetter__}}', '{{__lookupGetter__}}', + '{{__lookupSetter__}}', '{{__proto__}}', '{{lookup this "constructor"}}', '{{lookup this "__defineGetter__"}}', '{{lookup this "__defineSetter__"}}', '{{lookup this "__lookupGetter__"}}', + '{{lookup this "__lookupSetter__"}}', '{{lookup this "__proto__"}}' ]; @@ -422,6 +424,159 @@ describe('security issues', function() { .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() { + return { + type: 'Program', + body: [ + { + type: 'MustacheStatement', + escaped: true, + strip: { + open: false, + close: false + }, + path: { + type: 'PathExpression', + data: false, + depth: 0, + parts: ['lookup'], + original: 'lookup' + }, + params: [ + { + type: 'PathExpression', + data: false, + depth: 0, + parts: [], + original: 'this' + }, + { + type: 'NumberLiteral', + value: '{},{})) + (Function) + (({}', + original: 1 + } + ] + } + ] + }; + } + + it('should reject AST NumberLiteral type confusion in compile()', function() { + expect(function() { + var template = Handlebars.compile(createInjectedProgram()); + template({}); + }).to.throw(/Invalid AST/); + }); + + it('should reject AST objects passed via dynamic partial lookup', function() { + expect(function() { + var template = Handlebars.compile('{{> (lookup . "payload")}}'); + template({ + payload: createInjectedProgram() + }); + }).to.throw(/Invalid AST|could not be found/); + }); + }); + + 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) {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/handlebars-lang/handlebars.js/commit/68d8df5a88e0a26fe9e6084c5c6aaebe67b07da2nvdPatchWEB
- github.com/handlebars-lang/handlebars.js/security/advisories/GHSA-xhpv-hc6g-r9c6nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-xhpv-hc6g-r9c6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33940ghsaADVISORY
- github.com/handlebars-lang/handlebars.js/releases/tag/v4.7.9nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.