Problem/Motivation

The string normalization change shipped in 2.0.17 (SA-CONTRIB-2026-075) escapes values in the source and prop-type normalize() layer, instead of leaving escaping to the render layer. Components are Twig templates and Twig autoescapes output by default, so {{ prop }} is already safe unless the value is a MarkupInterface. Escaping again by hand and then wrapping the result in Markup::create() (to stop Twig re-escaping) re-does what Twig already does. It is a no-op when it works, and it breaks rendering when it does not.

This one root cause produces several separately reported bugs:

- #3610847: Menu link titles and field labels double-escaped (normalize() is not idempotent): normalization runs in the source and again at render, and hand-escaping is not idempotent, so an ampersand is escaped more than once. A menu link titled "Aufenthalt & Besuch" then shows a literal & where the ampersand should be. Field labels are pre-normalized the same way.
- #3610520: NumberPropType doesn't work with Markup values.: a decimal field value is a string type, so the field-property source wraps it in Markup before it reaches the number prop. NumberPropType::normalize() then does (float) $value on a Markup object, giving "Object of class Drupal\Core\Render\Markup could not be converted to float" and the wrong value 1.
- url props break. A query string double-encodes (?a=1&amp;b=2 renders as a broken a=1&amp;amp;b=2), and a route value becomes a fatal error. A link field set to <nolink> is escaped to route:&amp;lt;nolink&amp;gt;, and resolving that URL throws RouteNotFoundException: Route "&amp;lt" does not exist.

The original XSS was itself an instance of the same mistake: a Markup::create() on unescaped user input, which marks it safe and tells Twig not to escape it. The right lever is to not mark untrusted values safe, not to hand-escape them first.

Verified: rendered through the real pipeline, {{ prop }} gives byte-identical output for a plain string and for Markup::create(Html::escape($x)). They differ only under {{ prop|raw }}, and there the template author is explicitly asking for raw HTML. Not honoring |raw would arguably be a bug in itself.

Steps to reproduce

Double-escaping (#3610847: Menu link titles and field labels double-escaped (normalize() is not idempotent)):
1. Create a menu link whose title contains an ampersand, e.g. "Aufenthalt & Besuch".
2. Render it through a component that maps the menu to a links prop (or a field label with an ampersand to a string prop).
3. The title shows a literal &amp;amp; instead of an ampersand.

Number prop (#3610520: NumberPropType doesn't work with Markup values.):
1. Add a decimal field to a content type and give it a value, e.g. 1.5.
2. Map that field to a number prop via the field-property source.
3. The page logs "Object of class Drupal\Core\Render\Markup could not be converted to float" and renders 1 instead of 1.5.

url prop fatal error:
1. Take a core Link field. Show it with the "Component per item" formatter as, say, ui_suite_bootstrap:button, mapping the field URI to the url prop.
2. Set the link to <nolink> (or <button>, or <none>).
3. Open the page: fatal error. This hits stock ui_suite_bootstrap components (button, dropdown, list_group_item). A query-string URL on a url prop shows the double-encoding without the fatal error.

Proposed resolution

Escape at the render layer, not in the source or in normalize().

- Keep Markup::create() only for values that are genuinely safe: a MarkupInterface, or a render array rendered through Drupal's safe-HTML pipeline. Leave plain strings as plain strings so Twig autoescapes them once at render.
- Drop the escaping and Markup wrapping in the sources (FieldPropertySource, TokenSource string/URL branch, FieldLabelSource) and in StringPropType/LinksPropType. Sources return the raw value; the prop type decides trust and Twig escapes.
- Honor |raw as the template author's explicit opt-out, consistent with standard Twig.

The parts of the 2.0.17 fix that already sit at the right layer stay: SlotPropType routing a plain string through #plain_text (core escapes it), and the TwigExtension::include() override that returns trusted markup for nested developer-authored components.

Remaining tasks

todo

User interface changes

None

API changes

None

Data model changes

None.

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

herved created an issue. See original summary.

herved’s picture

Issue summary: View changes
herved’s picture

Issue summary: View changes
herved’s picture

Status: Active » Needs review

I realize this is a big change (especially the tests contain heavy machinery) but it is more unifying things and reorganizing tests.
I don't think we are losing test coverage here.

Moving to needs review to have maintainer's feedback.

Disclaimer: I relied on claude code for the issue summary and tests, but I think the approach is correct and sound.

herved’s picture

I hit the 3rd case (url prop fatal error) on a mega menu built from paragraphs, where each paragraph has a link field feeding a url prop. It crashes as soon as one column has a non-clickable (<nolink>) header.

just_like_good_vibes made their first commit to this issue’s fork.

just_like_good_vibes’s picture

Hello there, thank you for reporting.

And thanks for [!524]. The analysis was right. Escaping at render time via Twig autoescape is the correct place: it makes normalize() idempotent (which is the actual root cause of #3610847: Menu link titles and field labels double-escaped (normalize() is not idempotent), also fixed here), and I verified the rendered output stays identical to 2.0.17 on all the paths I was worried about (nested components, PHP render arrays, config sources, slots..etc).

I admit this change was a bit scary at first: the sources now return unfiltered values, so looking at a source output in isolation you see raw user input where 2.0.17 showed escaped text. But that raw value is untrusted by type (a plain string, never Markup), so nothing can print it unescaped without going through Twig autoescape. The filtering didn't disappear, it moved to the only place where it runs exactly once.

In short, what we gain:
- idempotent normalize() → the whole double-escape bug class is gone (by design), not just the reported cases;
- no more corruption of values that are not HTML (filters like |upper, numbers, plain-text contexts);
- same behavior as core/SDC, so less custom trust machinery to maintain;
- the prop type layer has more strengths : trust classification and all type treatments
(shapes, casting, enums, URLs, identifiers..etc) now live in exactly one place, with idempotency
enforced. Generic HTML escaping was the only treatment that could move
to Twig. Everything else is what normalize() was always really for.
- a bit less PHP code, more tests.

I pushed a rework on a fresh branch rather than amending !524, with two differences:

1. The XSS regression tests are kept, not deleted. I re-expressed them as assertions on the
rendered HTML, so they keep protecting the
SA-CONTRIB-2026-075 fix independently of where escaping happens.
2. Added: an idempotency test across all prop types (red on 2.0.17, green here), a
full-pipeline menu render test for #3610847, a TextProcessed-trust test, and a small test
documenting the |raw trade-off.

One correction to the issue summary: this does not fix the NumberPropType crash that needed
the separate cast from #3610520, already committed.

Disclaimer : i also use Claude Code to accelerate the analysis and various checks.

just_like_good_vibes’s picture

Status: Needs review » Fixed

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.

herved’s picture

Sorry for commenting on a closed issue, and thanks for landing this and for crediting the analysis.
I see we align on the production code side.

Two small follow-ups, neither a blocker.

1. Coherence in tests: the final suite mixes exact rendered-output assertions (good) with the older substring checks, and in places layers both, so some redundancy is left over. The clearest case is SlotPropTypeTest::testBareStringInSlotIsEscaped: nine substring assertions across form/svg/script/iframe to prove a single thing, that a plain string is escaped. Elsewhere the exact and substring styles double up on the same input, e.g. StringPropTypeTest::testDangerousMarkupInPlainStringPropIsEscaped asserts the full escaped string and then repeats a "not contains script" check (same shape in LinksPropTypeTest). A few names also describe the wrong mechanism: several ...IsStripped / ...IsFiltered tests actually escape the value (it survives as inert text), while ...IsStrippedByCore is the only place where a tag is really removed by Xss::filterAdmin. For an XSS suite that escape / strip / allow-list distinction is worth keeping precise. Nothing is broken and the tests pass; just a coherence note for whenever you consolidate.

2. Process, a mild one: the rework here was authored, merged, and the issue closed by the same maintainer, with no second review or RTBC. For a change on a security-sensitive path, another set of eyes before merge would give more confidence and is the more usual flow. Not asking to reopen anything, just flagging it for next time.

Thanks again.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.