Skip to content

fix: escape ancestor closing tags inside escapable raw text elements (<textarea>, <title>) - #43

Open
hsuan0223x wants to merge 1 commit into
angular:mainfrom
hsuan0223x:fix/escapable-raw-text-ancestor-escaping
Open

fix: escape ancestor closing tags inside escapable raw text elements (<textarea>, <title>)#43
hsuan0223x wants to merge 1 commit into
angular:mainfrom
hsuan0223x:fix/escapable-raw-text-ancestor-escaping

Conversation

@hsuan0223x

Copy link
Copy Markdown

The August hardening of fallbackRawContentTags() covers the HTML raw text elements
(<iframe>, <noembed>, <noscript>, <noframes>), but not the escapable raw text elements —
<textarea> and <title>.

Those two are terminated the same way: only by their own closing tag. So anything the serializer
emits verbatim underneath one of them — comment data, processing-instruction data, or the
serialization of a nested raw-text element — can close the element early and turn the remainder of
the payload into live markup.

At 7df6545, eight variants of this produce an executing <img onerror> after a normal HTML
reparse:

payload host before
comment <textarea> <textarea><!--</textarea><img src=x onerror=alert(1)>--></textarea>
comment <title> <title><!--</title><img src=x onerror=alert(1)>--></title>
nested <style> / <script> / <xmp> <textarea> <textarea><style></textarea><img src=x onerror=alert(1)></style></textarea>
nested <style> / <script> / <xmp> <title> <title><style></title><img src=x onerror=alert(1)></style></title>

Processing instructions happen to survive today, because escapeProcessingInstructionContent()
rewrites the > and </textarea& is not a valid end tag — but that is incidental rather than
intended, so the tests below cover it as well.

The change

fallbackRawContentTags() already collects the ancestor tags whose closing sequence must be escaped
out of nested verbatim data; it just never sees <textarea> or <title>, because they are not in
hasRawContentFallback.

They do not belong in that object either — its own comment says the text in those elements "is inert
for browser parsing", which is exactly what is not true of RCDATA: <textarea> and <title> text
is escaped for character references. Overloading the set would make the two families
indistinguishable for any future change that reads it for its documented meaning.

So this adds a separate hasEscapableRawContent set, named after the spec
(escapable raw text elements),
and has the ancestor walk consult both:

var hasEscapableRawContent = {
  TEXTAREA: true,
  TITLE: true
};
-      if (node.namespaceURI === NAMESPACE.HTML && hasRawContentFallback[node.tagName]) {
+      if (node.namespaceURI === NAMESPACE.HTML &&
+          (hasRawContentFallback[node.tagName] ||
+           hasEscapableRawContent[node.tagName])) {
         tags.push(node.localName);
       }

That is the whole behavioural change. hasEscapableRawContent is deliberately not consulted by
the text-node path at NodeUtils.js:322-328 or the element path at :300: both are gated on
hasRawContent, and <textarea>/<title> text must keep going through escape() as it does today.

Tests

Three new cases in test/xss.js, following the existing fallbackRawText* structure and using the
same puppeteer alertFired() oracle:

  • escapableRawTextCommentNodeEscapesAncestorClosingTag — comment as a direct child and as a
    descendant, for both tags
  • escapableRawTextProcessingInstructionEscapesAncestorClosingTag
  • escapableRawTextNestedRawTextElementsEscapeAncestorClosingTag
$ npx mocha test/xss.js test/domino.js test/parsing.js test/readonly.js
  1686 passing        # 1683 before, +3 new

Each new test fails on the unpatched tree, e.g.:

AssertionError: expected '<textarea><!--</textarea><img src=x onerror=alert(1)>--></textarea>'
              to be '<textarea><!--&lt;/textarea><img src=x onerror=alert(1)>--></textarea>'

Provenance

Reported to the Google VRP first (issue 555531833). It was closed on 2026-09-04 as not meeting their
internal escalation threshold, with "Please feel free to publicly disclose this issue on GitHub as a
public issue."
— hence this PR.

The same serializer obligation has been accepted twice before, in GHSA-j3r3-mxqp-r2p4 (PI data not
escaping an enclosing text-only element's closing tag) and GHSA-v3p8-whq6-r5jg (ancestor walk
terminating early at a DocumentFragment boundary). Both corrected how the walk traverses; this
corrects what it recognises.

angular/angular pins this repo at 7df65450b833… in package.json, so the path reaches Angular
SSR output via platform-server.

The fallbackRawContentTags() hardening covers HTML's raw text elements
(<iframe>, <noembed>, <noscript>, <noframes>) but not the escapable raw
text elements, <textarea> and <title>.

Those are terminated the same way -- only by their own closing tag -- so
anything the serializer emits verbatim underneath one of them can close
the element early and turn the rest of the payload into live markup.
Eight variants produce an executing <img onerror> after a normal reparse:
a comment inside <textarea> or <title>, and a nested <style>, <script> or
<xmp> inside either.

<textarea> and <title> do not belong in hasRawContentFallback: that
object's own comment says the text in those elements is inert for browser
parsing, which is exactly what is not true of RCDATA -- their text IS
escaped for character references. So this adds a separate
hasEscapableRawContent set, named after the spec, and has the ancestor
walk consult both. The text-node path at NodeUtils.js:322-328 and the
element path at :300 are unaffected: both are gated on hasRawContent, and
<textarea>/<title> text must keep going through escape().

Adds three cases to test/xss.js following the existing fallbackRawText*
structure and using the same puppeteer alertFired() oracle. Each fails on
the unpatched tree.
@google-cla

This comment was marked as outdated.

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.

1 participant