Problem/Motivation

Since 2.0.17 (SA-CONTRIB-2026-075), LinksPropType::normalize() escapes plain-string link titles with Html::escape(). The method can legitimately run twice on the same value:

  • MenuSource::getMenuItems() calls LinksPropType::normalize() when building the prop value, escaping at the trust boundary (this behavior is pinned by MenuSourceTest::testPhishingTitleIsEscaped),
  • and TwigExtension::normalizeProps() (via the component node visitor) normalizes the prop value again at render time.

The second pass re-escapes the already escaped string, so a menu link titled Aufenthalt & Besuch renders as literal Aufenthalt & Besuch on the page.

FieldLabelSource::getPropValue() has the same defect for string props: it pre-normalizes with StringPropType::normalize(), so any field label containing &, < or " mapped through the [Field] Label source is double-escaped as well.

In 2.0.16 the double call was harmless because normalize() did not escape; since 2.0.17 it is no longer idempotent.

Steps to reproduce

  1. Create a menu link whose title contains an ampersand, e.g. Aufenthalt & Besuch.
  2. Map the menu to a links prop of any SDC via the Menu source (e.g. in a ui_patterns_blocks block).
  3. Render the component: the title shows a literal &.

Proposed resolution

Make the escaping idempotent instead of removing it:

  • In LinksPropType::normalizeLink(), wrap the escaped title in Markup::create(). A second normalize() pass then sees a MarkupInterface value and passes it through — which is already the documented contract for pre-escaped values.
  • Remove the string-blessing branch in LinksPropType::preprocess(). It becomes dead code with the change above, and worse: if preprocess() were ever called on a value that did not go through normalize(), it would mark a raw, never-escaped string as safe. Leaving plain strings alone lets Twig autoescape handle them.
  • Drop the redundant pre-normalization in FieldLabelSource::getPropValue(); the render-time pass escapes the label exactly once.

The escaping guarantee of SA-CONTRIB-2026-075 is preserved: all existing security tests (testPhishingTitleIsEscaped, testDangerousMarkupInPlainStringTitleIsEscaped, testXssInNestedBelowTitleIsFiltered, ...) still pass. The MR adds regression tests: normalize() applied twice escapes only once (including nested below titles), and a MenuSourceTest case that simulates the render-time pass on top of the source output.

Remaining tasks

  • Review MR.

API changes

None. LinksPropType::normalize() now returns titles as MarkupInterface instead of plain strings; string casts and Twig output are unaffected.

Disclosure

AI-Generated: Yes (Used Claude Code to help fixing the issue).

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

tfranz created an issue. See original summary.

just_like_good_vibes’s picture

Hello, and thank you very much for reporting.
would you like to work on that? if yes, please assign yourself to the issue.
if you prefer, we would take this in charge and provide a correction asap.
thank you in advance

tfranz’s picture

Issue summary: View changes
tfranz’s picture

@just_like_good_vibes Thank you for your feedback. Yes, i would prefer if you could take this in charge and provide a correction.

just_like_good_vibes’s picture

Assigned: Unassigned » just_like_good_vibes
herved’s picture

FWIW I've opened #3611167: Escape at render, not in sources (rely on Twig autoescape), it should fix this case as well I think.

just_like_good_vibes’s picture

Assigned: just_like_good_vibes » Unassigned
Status: Active » Closed (duplicate)

Thanks for the precise report and for !523.
The diagnosis was exactly right: normalize() is
not idempotent since 2.0.17, and MenuSource / FieldLabelSource run it a second time.

Rather than merging !523, I fixed the root cause in !525 over at #3611167: escaping now happens
once at render time via Twig autoescape, which makes normalize() idempotent for every prop type, so the double-escape disappears for menu titles, field labels, and any other pre-normalizing
source (there was a third, unreported one in ui_patterns_legacy).

Both halves of this report are covered by dedicated regression tests in !525: a full-pipeline
menu render test asserting "Tom & Jerry" is escaped exactly once, an ampersand field-label
test, and an idempotency test across all prop types that fails on 2.0.17.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.