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¶
- "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 namedtemplate,layout,render,contenton a server-side Node app. And because Handlebars itself documents AST input, the "structured input is out of scope" defense never holds. - Validator coverage ≠ compiler consumption — the validator-incomplete rule. The 4.7.9 fix walked the AST but checked values only on
PathExpression,NumberLiteral, andBooleanLiteral; the advisory enumerates the other write-into-code positions the compiler reads unchecked (blockParams.length, non-PathExpressiondepth, non-stringStringLiteral.value, non-stringPathExpression.original— the last three gated behindstringParams: 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. - 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, whilehasOwnPropertyhits return early.constructorexists as an own property on every prototype and isFunctiononFunction.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 walkx.__proto__→constructorfirst, then check whether the block happens after or before the own-property return. Also flip config both ways: Leg B only fires withallowProtoMethodsByDefault: true, so your test matrix must include both settings. - 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 containingsafe</script><svg onload=marker><script>as a static string (no template syntax at all), then raw-curlthe 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. - 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.jsonProgram 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 whetherconstructorresolves via__proto__lookup. Marker =require('os').userInfo()output, lab user only. - Leg C:
npx handlebars@4.7.9 precompilelocally 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.10escapes) 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¶
- JS sandbox-escape corpus and allow-list grammar drift: Oct 1 vm2 wave and Oct 5 vm2 incomplete-fix family.
- Generated-source-as-output injection genealogy (comment break-out, spec→codegen): Jan 31 Orval page and codegen untrusted specs.
- Framework-owns-the-parser trust inversion: same shape as the RESTEasy message-body-reader fold on the May 4 Quarkus page — the vulnerable consumer is inside the library, invisible to app-level hardening.
Sources¶
- GHSA-8r5x-fm3f-whwj / CVE-2026-106446 — validator bypass, includes full AST PoC
- GHSA-p8wg-vrv2-v86f / CVE-2026-106445 — own-property deny-list bypass
- GHSA-xw65-4hp5-5hc7 / CVE-2026-106444 — inline precompile script breakout
- Fix release: handlebars 4.7.10