Skip to content

fix(core): escape comment markers which share characters#69829

Open
arshsmith1 wants to merge 1 commit into
angular:mainfrom
arshsmith1:comment-marker-overlap
Open

fix(core): escape comment markers which share characters#69829
arshsmith1 wants to merge 1 commit into
angular:mainfrom
arshsmith1:comment-marker-overlap

Conversation

@arshsmith1

Copy link
Copy Markdown
Contributor

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe:

What is the current behavior?

Issue Number: N/A

escapeCommentText matches each disallowed marker as a whole with a single global regex. Since String.replace with /g moves lastIndex past each match, a marker that overlaps one already consumed is never seen, and its delimiters stay unescaped.

<!--!> is the shortest case: the scanner takes <!-- at offset 0 and resumes at offset 4, so the --!> sitting at offset 2 (it shares the --) is skipped. Only the leading < gets wrapped:

escapeCommentText('<!--!>');  // '​<​!--!>'  <- still ends the comment
escapeCommentText('<!--->');  // '​<​!--->'  <- same thing via '-->'

Walking the tokenizer over <!-- + ​<​!--!><img src=x onerror=alert(1)> + -->, the !> puts it in comment-end-bang and then back to the data state, so the comment closes early and the <img> after it parses as a live element. That is the round-trip the docstring on this function says it protects.

The existing spec covers each marker on its own ('>', '->', '<!--', '-->', '--!>', '<!-') but never two that share characters, which is why this slipped through.

What is the new behavior?

The pattern matches the </> delimiter along with its surrounding context rather than the whole marker, so nothing a later marker needs is consumed and overlapping markers are all escaped:

escapeCommentText('<!--!>');  // '​<​!--!​>​'
escapeCommentText('<!--->');  // '​<​!---​>​'

The accepted language is unchanged, so output for every case already in dom_spec.ts is byte-identical, including the ones that must stay untouched (.>, .->, <!-.). The three new expectations fail on main and pass here.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

The reachable call site today is the ngReflect binding comment in shared.ts, which is dev-mode and opt-in, so the live blast radius is small. Fixing it in escapeCommentText anyway since the escaping contract is what the rest of the comment-node path relies on.

@pullapprove
pullapprove Bot requested a review from JeanMeche July 17, 2026 06:17
@angular-robot angular-robot Bot added the area: core Issues related to the framework runtime label Jul 17, 2026
@ngbot ngbot Bot added this to the Backlog milestone Jul 17, 2026
@angular angular deleted a comment Jul 17, 2026
@JeanMeche

Copy link
Copy Markdown
Member

We tried landing this in #69343. It was breaking.

@arshsmith1

Copy link
Copy Markdown
Contributor Author

Yeah, and that revert was the right call. This is a different fix though, and it avoids the thing that broke.

What landed in #69343 was the version with the ^ anchors dropped:

/>|->|<!--|-->|--!>|<!-$/g

That escapes a bare > or -> anywhere in the comment text, so any comment containing a plain > starts serializing differently. That is the output churn the G3 targets tripped on.

This PR keeps the anchors and matches the delimiter together with its context instead:

/(^-?|--!?)>|<(?=!--|!-$)/g

The accepted language is unchanged, so non-overlapping text comes out byte-identical. Running both against every case already in dom_spec.ts (@ = ):

input main #69343 (reverted) this PR
.> .> .@>@ ⚠️ .>
.-> .-> .@>@ ⚠️ .->
a>b a>b a@>@b ⚠️ a>b
a->b a->b a@>@b ⚠️ a->b
<!--!> @<@!--!> @<@!--!@>@ @<@!--!@>@
<!---> @<@!---> @<@!---@>@ @<@!---@>@

So: zero pre-existing cases change output here (the reverted one changed four), and the overlap is still closed. All 16 existing dom_spec expectations pass untouched plus the 3 new ones.

Worth saying the anchored regex was never the bug. The bug is only that String.replace with /g advances lastIndex past each match, so in <!--!> the --!> at offset 2 shares the -- with the <!-- at offset 0 and never gets seen. Matching the delimiter rather than the whole marker means nothing a later marker needs gets consumed.

Happy to run it through a TGP if that helps derisk it.

@JeanMeche JeanMeche added the action: global presubmit The PR is in need of a google3 global presubmit label Jul 17, 2026
@JeanMeche

Copy link
Copy Markdown
Member

Can we add to the tests the cases that were broken in the previous PR ?

I'll run this change through a TGP to confirm we're good this time.

escapeCommentText matched each disallowed marker as a whole, so a marker that overlaps an already matched one was skipped by the scanner and left unescaped. Match the delimiter together with its context instead, which keeps the matched language identical for the existing cases.
@arshsmith1
arshsmith1 force-pushed the comment-marker-overlap branch from 1cfe73f to 59b4171 Compare July 17, 2026 16:08
@arshsmith1

Copy link
Copy Markdown
Contributor Author

Added those. New it('should not escape a bare ">" or "->" in the middle of the text') block pins a>b, a->b, .> and .-> all coming out untouched, which is exactly what the dropped-anchor version changed. Green here. Thanks for running the TGP.

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

Labels

action: global presubmit The PR is in need of a google3 global presubmit area: core Issues related to the framework runtime

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants