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()callsLinksPropType::normalize()when building the prop value, escaping at the trust boundary (this behavior is pinned byMenuSourceTest::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
- Create a menu link whose title contains an ampersand, e.g.
Aufenthalt & Besuch. - Map the menu to a
linksprop of any SDC via the Menu source (e.g. in a ui_patterns_blocks block). - Render the component: the title shows a literal
&amp;.
Proposed resolution
Make the escaping idempotent instead of removing it:
- In
LinksPropType::normalizeLink(), wrap the escaped title inMarkup::create(). A secondnormalize()pass then sees aMarkupInterfacevalue 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: ifpreprocess()were ever called on a value that did not go throughnormalize(), 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).
Issue fork ui_patterns-3610847
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
Comment #3
just_like_good_vibesHello, 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
Comment #4
tfranz commentedComment #5
tfranz commented@just_like_good_vibes Thank you for your feedback. Yes, i would prefer if you could take this in charge and provide a correction.
Comment #6
just_like_good_vibesComment #7
herved commentedFWIW I've opened #3611167: Escape at render, not in sources (rely on Twig autoescape), it should fix this case as well I think.
Comment #8
just_like_good_vibesThanks 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.