Skip to content

Handlebars wave (3 GHSAs): AST-input validator bypass gives pre-render RCE

Wave in one line: Template-injection triple: AST-input validator bypass gives pre-render RCE, Function.prototype.constructor own-property defeats the sandbox deny-list, and precompile output breaks out of inline <script>

Source: hourly offensive-security scan of GitHub Security Advisories, 2026-10-08 (wave published 2026-10-08T17:52Z, three advisories for npm handlebars >=4.0.0 <=4.7.9, all fixed in 4.7.10).

Handlebars is embedded across enormous JS surface area (email renderers, CMS templates, config-driven UIs, codegen pipelines), and all three legs turn template input into JavaScript execution at different choke points. Two are critical, one of them 9.8, and each carries a generalizable audit rule that applies to every template/sandbox/codegen product you test.

Advisory table

Leg GHSA CVE Sev Boundary
compile()/precompile() accept an attacker-shaped AST; the 4.7.9 validator checks only 3 of the many node-value positions the compiler writes into generated code GHSA-8r5x-fm3f-whwj CVE-2026-106446 9.8 A JSON request body with "type":"Program" reaches compile() → Program.blockParams.length (an object property with no type, skipped by the walker) is written raw into container.program(<index>, data, <length>) in the generated function — code runs at render time even if the block's helper does not exist, because the helper-options object is built before helper lookup. precompile() variant: the payload rides the generated source into every consumer.
Sandbox deny-list bypass: Function reachable via own property constructor on Function.prototype GHSA-p8wg-vrv2-v86f CVE-2026-106445 critical lookupProperty returns a value whenever hasOwnProperty is true on the prototype object — before consulting the deny-list. Function.prototype.constructor === Function is an own property, so the deny-list entry constructor: false is never evaluated. With allowProtoMethodsByDefault: true and any function in context: {{lookup fn "__proto__"}} → constructor → Function → build + apply attacker body → RCE as the Node user (advisory PoC prints uid=1000(node)).
precompile() emits </script> unescaped in generated JS source GHSA-xw65-4hp5-5hc7 CVE-2026-106444 med quotedString() JS-quotes but never HTML-escapes; HTML script-data state terminates on </script> inside a JS string literal. Applications that precompile attacker-controlled templates and inline the output in a <script> element execute attacker markup. External .js delivery and normal SSR are unaffected.

Why this is an operator page

  1. "The API accepts strings" is a lie you can test in one request. Leg A's precondition is just: does a value you control reach compile()/precompile() as an object? JSON APIs that render "templates" from request bodies hand you this by construction — send {"type":"Program",...} where a string is expected and diff behavior against a string control. The general fingerprint: any parameter named template, layout, render, content on a server-side Node app. And because Handlebars itself documents AST input, the "structured input is out of scope" defense never holds.
  2. Validator coverage ≠ compiler consumption — the validator-incomplete rule. The 4.7.9 fix walked the AST but checked values only on PathExpression, NumberLiteral, and BooleanLiteral; the advisory enumerates the other write-into-code positions the compiler reads unchecked (blockParams.length, non-PathExpression depth, non-string StringLiteral.value, non-string PathExpression.original — the last three gated behind stringParams: true, the first requires only defaults). Audit rule for every "we added input validation to the parser" fix you meet on an engagement: enumerate what the code generator reads, not what the validator checks; one unconsumed-but-written field is enough.
  3. Deny-lists over prototype lookups die at own-property twins. Leg B is the generic shape: the guard is if dangerous-name in deny-list: block, applied to inherited lookups, while hasOwnProperty hits return early. constructor exists as an own property on every prototype and is Function on Function.prototype. This is the Handlebars instance of the sandbox-escape corpus on the Oct 1 vm2 wave page and the Oct 1/3 devalue __proto__ legs: when auditing any JS template/sandbox (Handlebars, Nunjucks, EJS locals, JSONata, jq wrappers), run the prototype walk x.__proto__ → constructor first, then check whether the block happens after or before the own-property return. Also flip config both ways: Leg B only fires with allowProtoMethodsByDefault: true, so your test matrix must include both settings.
  4. Escaping correctness is per embedding context — JS quoting is not HTML safety. Leg C is the smallest leg and the most portable rule: any tool that emits source code as output (template precompilers, minifiers, JSON-in-<script> embedders, i18n bundlers) must be tested against the embedding state machine, not the language grammar. </script> inside a JS string is the canonical case; the battery extends to <!--, </textarea>, and </noscript> states. Black-box probe: render/ingest a template containing safe</script><svg onload=marker><script> as a static string (no template syntax at all), then raw-curl the page that embeds the precompiled output and grep the response bytes for the raw </script> — DevTools DOM inspection hides the breakout because the parser already committed.
  5. These legs stack into a chain worth trying in order on any Handlebars render endpoint: structured-AST RCE (no config needed) → template-string sandbox escape (needs permissive proto config) → precompile-inline XSS (needs the precompile-and-inline build pattern, common in email/CMS admin tooling). Negative controls matter: string-only input + default options + external precompiled delivery = genuinely unaffected, so prove which precondition the target actually meets before claiming impact.

Replayable validation boundaries (lab-only)

  • Leg A: run the advisory's express harness locally (or an owned lab app), POST the poc.json Program AST with a marker payload (throw new Error('ran-as-' + process.getuid()) — inert, proof-of-execution only). Record the response/error-page marker as the evidence artifact. Never do this against third-party render endpoints outside authorized scope.
  • Leg B: local Node harness compiling untrusted templates with and without allowProtoMethodsByDefault; assert whether constructor resolves via __proto__ lookup. Marker = require('os').userInfo() output, lab user only.
  • Leg C: npx handlebars@4.7.9 precompile locally on a template whose static text contains </script>; confirm raw emission (grep -c '</script>' on output), then embed in a local HTML page and show the parser breakout in your own browser. Fix-version differential (4.7.10 escapes) is the version fingerprint: if you can trigger a precompile-and-inline render on an owned/authorized page, the raw-vs-escaped byte check in the HTML response is a passive version oracle.
  • All proofs are local by construction — no target needed to build the evidence kit.

Cross-references

Sources