Skip to content

fix: prevent proto-access bypass via Symbol.toStringTag and escape bypass via toHTML#2148

Open
eddieran wants to merge 2 commits intohandlebars-lang:masterfrom
eddieran:fix/proto-access-escape-bypass
Open

fix: prevent proto-access bypass via Symbol.toStringTag and escape bypass via toHTML#2148
eddieran wants to merge 2 commits intohandlebars-lang:masterfrom
eddieran:fix/proto-access-escape-bypass

Conversation

@eddieran
Copy link
Copy Markdown

Summary

Fixes #2146. Replaces #2147 (auto-closed when fork was deleted).

Two security fixes in lib/handlebars/utils.js:

1. Proto-access control bypass via Symbol.toStringTag spoofing

isMap / isSet used Object.prototype.toString tag detection, which is spoofable via Symbol.toStringTag. An attacker can craft a plain object that passes the Map check, causing lookupProperty to skip all proto-access controls and return blocked properties like constructor.

Fix: Use instanceof Map / instanceof Set instead of toString tag comparison.

2. HTML escape bypass via toHTML duck-typing

escapeExpression treated any object with a toHTML property as a SafeString, skipping HTML escaping entirely. Attacker-controlled template data with a toHTML method could inject raw HTML into {{}} expressions.

Fix: Use instanceof SafeString instead of checking for the toHTML property.

Test plan

  • Verify {{value}} with { [Symbol.toStringTag]: 'Map', get: () => 'Function' } no longer bypasses proto-access controls
  • Verify {{value}} with { toHTML: () => '<img src=x onerror=alert(1)>' } now escapes the HTML output
  • Verify normal SafeString and Map usage continues to work correctly
  • Run existing test suite: npm test

…pass via toHTML

- Use instanceof Map/Set instead of Object.prototype.toString tag check,
  which can be spoofed via Symbol.toStringTag to bypass proto-access controls
- Use instanceof SafeString instead of duck-typing toHTML property check,
  which allows attacker-controlled data to bypass HTML escaping

Fixes handlebars-lang#2146
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Addresses two reported security issues by hardening type checks in Handlebars utility helpers to prevent spoofing-based bypasses.

Changes:

  • Replace isMap / isSet tag-based detection with instanceof Map / instanceof Set checks.
  • Replace escapeExpression’s toHTML duck-typing with an instanceof SafeString check.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/handlebars/utils.js Outdated
Comment on lines +46 to +50
@@ -45,8 +47,12 @@
}

export const isArray = Array.isArray;
export const isMap = testTag('Map');
export const isSet = testTag('Set');
export function isMap(value) {
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

testTag() is now unused after switching isMap/isSet to instanceof, so this helper (and its call sites that were removed) can be deleted to avoid dead code and keep utils.js focused on active helpers.

Copilot uses AI. Check for mistakes.
Comment thread lib/handlebars/utils.js
Comment on lines 68 to 72
if (typeof string !== 'string') {
// don't escape SafeStrings, since they're already safe
if (string && string.toHTML) {
if (string instanceof SafeString) {
return string.toHTML();
} else if (string == null) {
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

This change makes escapeExpression stop treating arbitrary objects with a toHTML property as safe. The existing unit test in spec/utils.js currently asserts that a plain object with toHTML() is not escaped, which will fail and also conflicts with the new security behavior—update that expectation and add a regression test that verifies attacker-controlled toHTML() output is escaped.

Copilot uses AI. Check for mistakes.
Comment thread lib/handlebars/utils.js
Comment on lines 49 to +52
export const isArray = Array.isArray;
export const isMap = testTag('Map');
export const isSet = testTag('Set');
export function isMap(value) {
return value instanceof Map;
}
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

Given isMap now gates proto-access control behavior in runtime.lookupProperty, it would be good to add a security regression test that a plain object spoofing [Symbol.toStringTag] = 'Map' is not treated as a Map and cannot be used to read blocked proto properties (e.g. constructor). No existing spec appears to cover the spoofing case.

Copilot uses AI. Check for mistakes.
Revert the escapeExpression change (instanceof SafeString) per
maintainer review: it's a breaking change to the documented
duck-typing contract where any object with .toHTML() is treated
as safe. Keep only the Map/Set Symbol.toStringTag spoofing fix.
Remove now-unused testTag helper that caused the lint failure.
@eddieran
Copy link
Copy Markdown
Author

Scope-reduced per @copilot-pull-request-reviewer's note that the instanceof SafeString change was a breaking change to the documented .toHTML duck-typing contract. Reverted that piece; the remaining change is just the Map/Set Symbol.toStringTag spoofing fix (issue #2146 first bullet), which has no API impact. CI should be green now.

@kibertoad
Copy link
Copy Markdown
Contributor

The PR title and description still claim two fixes, but commit 0e4b21 reverted the toHTML → SafeString portion.

The final diff is only the Map/Set tag-check fix in lib/handlebars/utils.js. Before merge, the title and body should be updated to match.

Comment thread lib/handlebars/utils.js
return value instanceof Set;
}

// Older IE versions do not directly support indexOf so we must implement our own, sadly.
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.

this needs a regression test, something like

it('does not treat Symbol.toStringTag-spoofed objects as Map', function () {
    expectTemplate('{{value.constructor}}')
      .withInput({ value: { [Symbol.toStringTag]: 'Map', get: () => 'pwned' } })
      .toCompileTo(''); // or whatever the proto-controlled path produces
  });

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.

Add one each for isMap / isSet at the unit level (spec/utils.js already has the happy-path tests) plus an end-to-end runtime.js lookup test.

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.

[Security] Proto-access control bypass via Map Symbol.toStringTag spoofing + HTML escape bypass

3 participants