CVE-2026-33916
Description
Handlebars provides the power necessary to let users build semantic templates. In versions 4.0.0 through 4.7.8, resolvePartial() in the Handlebars runtime resolves partial names via a plain property lookup on options.partials without guarding against prototype-chain traversal. When Object.prototype has been polluted with a string value whose key matches a partial reference in a template, the polluted string is used as the partial body and rendered without HTML escaping, resulting in reflected or stored XSS. Version 4.7.9 fixes the issue. Some workarounds are available. Apply Object.freeze(Object.prototype) early in application startup to prevent prototype pollution. Note: this may break other libraries, and/or use the Handlebars runtime-only build (handlebars/runtime), which does not compile templates and reduces the attack surface.
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
7- github.com/handlebars-lang/handlebars.js/commit/68d8df5a88e0a26fe9e6084c5c6aaebe67b07da2nvdPatchWEB
- github.com/handlebars-lang/handlebars.js/security/advisories/GHSA-2qvq-rjwj-gvw9nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-2qvq-rjwj-gvw9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-23369ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-23383ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33916ghsaADVISORY
- github.com/handlebars-lang/handlebars.js/releases/tag/v4.7.9nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.