Skip to content

Security improvements - #2185

Merged
jaylinski merged 9 commits into
4.xfrom
security-improvements
Oct 5, 2026
Merged

jaylinski merged 9 commits into
4.xfrom
security-improvements

Conversation

@jaylinski

Copy link
Copy Markdown
Member

🆕

Update the minimist dependency to the latest 1.2.x release. Also sync the
version in package-lock.json with package.json, which the v4.7.9 release
commit did not update.
The `--root` option only strips a prefix from template names, but its
description could be read as limiting which files the CLI reads. State
explicitly that it is not a filesystem boundary.
Instead of validating the AST in the parser, the compiler now checks every
AST value that it writes into generated code: path depths and parts,
literal values, content strings, hash pair keys and block params, including
in stringParams and trackIds mode. The checks now sit where the values are
used, so ASTs changed after parsing are covered too.

A missing `depth` is treated as 0 and a missing or partial `loc` no longer
breaks compilation or error reporting, because hand-built ASTs often omit
both.

The docs and type definitions now state that `compile` and `precompile`
turn an AST into code, so the AST must come from the application itself
and never from untrusted input.

Fixes GHSA-8r5x-fm3f-whwj.
`Compiler#accept` and `Visitor#accept` call the method named by
`node.type`. A crafted type such as `accept`, `constructor` or `pushParam`
could therefore call an unrelated method with the node as its argument.

The Compiler now accepts only node types the parser produces. A Visitor
also accepts types for which a subclass or instance defines its own
handler, but never methods of `Visitor` or `Object.prototype`.
`resolvePartial` treated any value with a truthy `call` property as a
compiled partial, so an object from context data could reach
`env.compile()` and be compiled as a template. Only functions are now
treated as compiled partials, and only strings are compiled at render
time. Anything else is rejected with an error.

A function partial that returns nothing now throws a clear error instead
of being passed on to the compiler.

Partials registered as an AST are no longer compiled; pass them through
`Handlebars.compile()` first.
@jaylinski jaylinski self-assigned this Oct 5, 2026
@jaylinski
jaylinski changed the base branch from master to 4.x October 5, 2026 21:27
@jaylinski
jaylinski requested a balanced review from Copilot October 5, 2026 21:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Generated JavaScript remains invalid for some ES5 inputs, and runtime edge cases can incorrectly mask errors or reject safe values.

Review effort: Balanced
Findings: 4 Medium severity

Open (4)
What changed in this PR

Hardens Handlebars compilation and runtime behavior against code injection, prototype access, unsafe partials, and denial-of-service cases.

Changes:

  • Validates AST values and restricts visitor/compiler dispatch.
  • Safely escapes generated JavaScript and improves runtime protections.
  • Adds security, performance, and iterator regression tests.
File Description
types/​index.d.ts Documents untrusted AST risks.
spec/​whitespace-control.js Tests linear whitespace processing.
spec/​visitor.js Tests safe custom-node dispatch.
spec/​utils.js Tests non-function toHTML handling.
spec/​security.js Adds security regression coverage.
spec/​precompiler.js Tests generated-output sanitization.
spec/​partials.js Tests partial validation and recursion.
spec/​expected/​help.menu.txt Updates CLI help fixture.
spec/​compiler.js Expands AST validation tests.
spec/​builtins.js Tests lazy iterator behavior and cleanup.
package.json Updates minimist.
package-lock.json Synchronizes dependency metadata.
lib/​precompiler.js Sanitizes names and source-map comments.
lib/​handlebars/​utils.js Tightens SafeString detection.
lib/​handlebars/​runtime.js Hardens partial and prototype handling.
lib/​handlebars/​internal/​proto-access.js Detects prototype constructors.
lib/​handlebars/​helpers/​each.js Streams iterables and closes them on failure.
lib/​handlebars/​exception.js Supports partial AST locations.
lib/​handlebars/​compiler/​whitespace-control.js Removes quadratic regex behavior.
lib/​handlebars/​compiler/​visitor.js Restricts node dispatch.
lib/​handlebars/​compiler/​node-types.js Defines allowed AST node types.
lib/​handlebars/​compiler/​javascript-compiler.js Escapes generated literals and locations.
lib/​handlebars/​compiler/​compiler.js Validates AST fields before code generation.
lib/​handlebars/​compiler/​code-gen.js Escapes script delimiters and strings.
lib/​handlebars/​compiler/​base.js Moves validation to the compiler boundary.
docs/​compiler-api.md Documents AST and output-safety requirements.
bin/​handlebars Clarifies --root behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/handlebars/compiler/javascript-compiler.js Outdated
Comment thread lib/handlebars/helpers/each.js
Comment thread lib/handlebars/internal/proto-access.js Outdated
Comment thread lib/precompiler.js Outdated
A prototype's own `constructor` (e.g. `Function.prototype.constructor`)
passed the own-property check in `lookupProperty`. With prototype method
access enabled (e.g. `allowProtoMethodsByDefault`), templates could reach
the `Function` constructor through `__proto__`. Such lookups now go through
the prototype-access checks, which deny `constructor` unless explicitly
allowed.

`escapeExpression` called `toHTML` on any value with such a property, so
context data with a non-function `toHTML` key, e.g. from parsed JSON, made
rendering throw. Only values with a callable `toHTML` are now treated as
SafeStrings.

Fixes GHSA-p8wg-vrv2-v86f.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Lone-surrogate escaping depends on ES2019 behavior despite the package’s declared support for older engines.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (4)

Comment thread lib/handlebars/compiler/code-gen.js
Precompiled templates are often inlined in `<script>` tags. Strings in
the generated code containing `</script` could end the script element
early, and `<!--` followed by `<script` could keep it from ending where
intended. `quotedString` now escapes the `<` of these sequences as
`\u003C`, and the JavaScript compiler uses it instead of
`JSON.stringify` for lookup names, partial indents and tracked ids.
Source locations, which include the `srcName` option, and the template
names written by the CLI are escaped the same way.

`quotedString` now builds on `JSON.stringify`, so it also escapes
control characters and surrogates, and it skips escaping entirely when a
string has nothing to escape, which is true of almost every lookup name.
Compilation doesn't slow down.

The `nameLookup` example in the compiler API docs now quotes with
`quotedString` as well, and the docs say that overrides must do the
same.

Fixes GHSA-xw65-4hp5-5hc7.
`#each` collected every value of an iterable before rendering the block.
It now renders each value as it goes, like `for...of`, and closes the
iterator if the block throws, so generators can run their cleanup code.
Values added to the iterable while the block renders, e.g. to a `Set`, are
now visited too, as with `for...of`.

The regexes that find trailing whitespace in whitespace control were
retried at every position of a whitespace run, which takes quadratic time
on long runs. They are now anchored so that matching takes linear time.
The CLI strips line terminators from the `--map` value before writing it
into the sourceMappingURL comment. With `--min`, uglify drops that comment
and writes its own from the raw value, so a newline in it ended the
comment and the rest of the value ran as JavaScript. The sanitized value
is now passed to uglify as well.

The comment also kept a raw "<", so a value containing `</script` or
`<!--` could end or change the parsing of an enclosing <script> element.
"<" is now percent-encoded, which keeps the URL pointing to the same file.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The broad security-sensitive compiler and runtime changes warrant final human validation.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@jaylinski
jaylinski merged commit f37c599 into 4.x Oct 5, 2026
11 checks passed
@jaylinski
jaylinski deleted the security-improvements branch October 5, 2026 22:09
@jaylinski jaylinski mentioned this pull request Oct 5, 2026
2 tasks
@kibertoad

Copy link
Copy Markdown
Contributor

@jaylinski did you assess perf impact of these changes? was it reasonable?

@jaylinski

Copy link
Copy Markdown
Member Author

@jaylinski did you assess perf impact of these changes? was it reasonable?

@kibertoad Yes, it was reasonable. But I fully relied on Opus 5.5 to assess it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants