Skip to content

fix(sanitize-html): emit transformTags text on empty tags when textFilter is set - #5494

Merged
boutell merged 1 commit into
apostrophecms:mainfrom
spokodev:fix/sanitize-html-transformtags-textfilter
Jun 30, 2026
Merged

boutell merged 1 commit into
apostrophecms:mainfrom
spokodev:fix/sanitize-html-transformtags-textfilter

Conversation

@spokodev

Copy link
Copy Markdown
Contributor

Summary

A transformTags handler that adds text to an allowed tag is silently dropped when the tag originally had no text content and an identity (no-op) textFilter is configured.

sanitizeHtml('<a></a>', {
  allowedTags: ['a'],
  textFilter: (t) => t,
  transformTags: { a: (tn, at) => ({ tagName: tn, attribs: at, text: 'INJECTED' }) }
})
// actual:   "<a></a>"
// expected: "<a>INJECTED</a>"

The control without a textFilter already yields the expected output:

sanitizeHtml('<a></a>', {
  allowedTags: ['a'],
  transformTags: { a: (tn, at) => ({ tagName: tn, attribs: at, text: 'INJECTED' }) }
})
// "<a>INJECTED</a>"

So the presence of an identity textFilter changes the result, which violates two documented contracts:

  • transformTags text: documented to "add or modify the text contents of a tag" (already covered by a passing test for the no-textFilter case).
  • textFilter: documented to "process all text content"; an identity filter is a no-op and must not change output.

This is a correctness bug, not a security bypass.

Root cause

In onopentag (packages/sanitize-html/index.js), the branch that emits frame.innerText for an allowed tag was guarded by !options.textFilter:

if (frame.innerText && !hasText && !options.textFilter) {
  result += escapeHtml(frame.innerText);
  addedText = true;
}

The !options.textFilter guard deferred emission to ontext so the filter could run there. But for an empty element htmlparser2 never calls ontext, so the injected text was emitted by neither branch and was lost.

Fix

Emit frame.innerText through options.textFilter here when one is set, mirroring the existing discard path that already filters frame.innerText:

if (frame.innerText && !hasText) {
  const escaped = escapeHtml(frame.innerText);
  if (options.textFilter) {
    result += options.textFilter(escaped, name);
  } else {
    result += escaped;
  }
  addedText = true;
}

addedText = true keeps ontext from double-emitting on the non-empty path (where htmlparser2 does fire ontext), so output for the existing non-empty cases is unchanged.

Tests

Added one test to packages/sanitize-html/test/test.js for the empty-tag + identity-textFilter case. It fails on the unpatched code ('<a></a>' instead of '<a>some new text</a>') and passes after the fix. The full package suite goes from 213 passing + 1 failing to 214 passing, with no regressions to the existing non-empty transformTags text + textFilter test.

Note: the standalone apostrophecms/sanitize-html repo was archived on 2026-02-26; this monorepo (packages/sanitize-html, v2.17.5) is the live home of the code.

…lter is set

When a transformTags handler adds text to an allowed tag that originally had
no text content, the injected text was silently dropped if any textFilter was
configured. The onopentag branch that emits frame.innerText was guarded by
!options.textFilter, deferring emission to ontext so the filter could run
there. For an empty element htmlparser2 never fires ontext, so the text was
emitted by neither branch.

Emit frame.innerText through options.textFilter here when present (mirroring
the discard path), so the transformTags text contract holds for empty tags
regardless of whether a textFilter is set.
@boutell
boutell self-requested a review June 29, 2026 18:51

@boutell boutell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for the fix!

@boutell
boutell merged commit aa2ae5a into apostrophecms:main Jun 30, 2026
boutell added a commit that referenced this pull request Jul 8, 2026
* Fix asset URLs when a site prefix is configured (#5448)

* fix: treat col as a self-closing tag (#5447)

* fix: treat col as a self-closing tag

* Make dateTime field responsive (css) (#5481)

* Fix/from rich text adds metatype (#5488)

* fromRichText adds metatype to new widget

* change

* nodemailer major bump (#5485)

* Harden and centralize the cache invalidation (#5493)

* fix(sanitize-html): emit transformTags text on empty tags when textFilter is set (#5494)

When a transformTags handler adds text to an allowed tag that originally had
no text content, the injected text was silently dropped if any textFilter was
configured. The onopentag branch that emits frame.innerText was guarded by
!options.textFilter, deferring emission to ontext so the filter could run
there. For an empty element htmlparser2 never fires ontext, so the text was
emitted by neither branch.

Emit frame.innerText through options.textFilter here when present (mirroring
the discard path), so the transformTags text contract holds for empty tags
regardless of whether a textFilter is set.

* changeset crediting spokodev for sanitize-html fix (#5498)

* Fix shortcut conflicts (#5499)

* Fix backspace after slash deleting a rich-text widget

* Fix copy/paste widget/text conflicts

* Fix astro redirects (#5500)

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* fix path traversal in import/export

* correct credits

* additional guards

* Merge commit from fork

* fix for </textarea/> vulnerability (#5501)

* wip

* fix for math/svg vulnerabilities

---------

Co-authored-by: Jinka Manohar <145598597+Manohar2503@users.noreply.github.com>
Co-authored-by: Vansh Parmar <vanshparmar8742@gmail.com>
Co-authored-by: Miro Yovchev <2827783+myovchev@users.noreply.github.com>
Co-authored-by: Stuart Romanek <stuart@apostrophecms.com>
Co-authored-by: spokodev <spoko.dev@gmail.com>
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.

2 participants