From 941d851c9b03ff9293863feaa5a414a716e892e7 Mon Sep 17 00:00:00 2001 From: Jakub Vrana Date: Mon, 28 Sep 2026 19:05:02 +0200 Subject: [PATCH 1/2] Escaper: srcdoc attribute is escaped as an HTML document [security] (BC break) The value of srcdoc is an HTML document: the browser attribute-decodes it and then parses the result as HTML. Latte escaped it like any other attribute, so ', + $latte->renderToString('', $params), +); + +Assert::same( + '', + $latte->renderToString("", $params), +); + +Assert::same( + '', + $latte->renderToString('', $params), +); + +// HTML is escaped only for the attribute +Assert::same( + '', + $latte->renderToString('', $params), +); + +// |noescape escapes only quotes +Assert::same( + '', + $latte->renderToString('', $params), +); + +// dynamic attribute +Assert::same( + '', + $latte->renderToString('', $params), +); + +Assert::same( + '', + $latte->renderToString('', $params), +); + +Assert::same( + '', + $latte->renderToString('', ['none' => null]), +); + +// n:attr +Assert::same( + '', + $latte->renderToString('', $params), +); + +Assert::same( + '', + $latte->renderToString('', $params), +); + +// blocks +Assert::same( + '', + $latte->renderToString('{define doc}{$x}{/define}', $params), +); + +$latte->setLoader(new Latte\Loaders\StringLoader([ + 'main' => ' ', + 'html.latte' => ' & ', + 'text.latte' => '{contentType text} & "', + 'js.latte' => '{contentType javascript}a < b', +])); + +Assert::same( + ' ', + $latte->renderToString('main'), +); + +// XML has no srcdoc context +$latte->setLoader(new Latte\Loaders\StringLoader); +Assert::same( + '', + $latte->renderToString('{contentType xml}', $params), +); diff --git a/tests/runtime/HtmlHelpers.escapeHtmlAttr.phpt b/tests/runtime/HtmlHelpers.escapeHtmlAttr.phpt new file mode 100644 index 000000000..ac2569bdd --- /dev/null +++ b/tests/runtime/HtmlHelpers.escapeHtmlAttr.phpt @@ -0,0 +1,17 @@ + & " \'')); +Assert::same('&#123; {<!-- -->{',HtmlHelpers::escapeHtmlAttr('{ {{')); +Assert::same('&amp;amp;', HtmlHelpers::escapeHtmlAttr('&')); +Assert::same('<b title="x">a &amp; b</b>', HtmlHelpers::escapeHtmlAttr(new Html('a & b'))); +Assert::same('{', HtmlHelpers::escapeHtmlAttr(new Html('{'))); diff --git a/tests/runtime/HtmlHelpers.formatSrcdocAttribute.phpt b/tests/runtime/HtmlHelpers.formatSrcdocAttribute.phpt new file mode 100644 index 000000000..102be57f0 --- /dev/null +++ b/tests/runtime/HtmlHelpers.formatSrcdocAttribute.phpt @@ -0,0 +1,23 @@ +')); +Assert::same(' srcdoc="<b>"', HtmlHelpers::formatSrcdocAttribute(' srcdoc', new Html(''))); + +// null & invalid +Assert::same('', HtmlHelpers::formatSrcdocAttribute('srcdoc', null)); +Assert::error( + fn() => Assert::same('', HtmlHelpers::formatSrcdocAttribute('srcdoc', [])), + E_USER_WARNING, + "Invalid value for attribute 'srcdoc': array is not allowed.", +); From 23afe30905d3cda987ae1779bbde398b16b33268 Mon Sep 17 00:00:00 2001 From: Jakub Vrana Date: Mon, 28 Sep 2026 19:18:53 +0200 Subject: [PATCH 2/2] HtmlHelpers: srcdoc triggers a migration warning for a value previously rendered as HTML With Feature::MigrationWarnings, a plain srcdoc value that renders as a different document now than before (it contains '<' or a character reference) triggers a warning suggesting to wrap trusted HTML in Latte\Runtime\Html. Text such as 'a & b' renders the same document as before and stays quiet; so does an Html object, whose tags were stripped before and are kept now. |accept silences it. It covers dynamic attributes (srcdoc={$x}, srcdoc="{$x}") and n:attr. A mixed value like srcdoc="

{$x}

" is escaped by code the Escaper emits, which has no access to feature flags, so it does not warn. Co-Authored-By: Claude Opus 5.5 --- docs/internals/escaping.md | 6 +++++ src/Latte/Runtime/HtmlHelpers.php | 16 ++++++++++---- tests/common/Engine.migrationWarnings.phpt | 6 +++++ .../HtmlHelpers.formatSrcdocAttribute.phpt | 22 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/docs/internals/escaping.md b/docs/internals/escaping.md index c9cb88aa8..6b2f2bb32 100644 --- a/docs/internals/escaping.md +++ b/docs/internals/escaping.md @@ -47,6 +47,12 @@ and is encoded only once, so trusted HTML keeps its tags. All three places route `srcdoc` through it: the `html/attr/html` state in `escape()` and in the `Convertors`, and `formatSrcdocAttribute` for dynamic attributes and `n:attr`. +With `Feature::MigrationWarnings`, `formatSrcdocAttribute` warns about a plain value +whose document differs from its text (it contains `<` or a character reference), +i.e. one that used to render as markup. Only the formatter paths warn: a mixed value +like `srcdoc="

{$x}

"` is escaped by code `Escaper::escape()` emits, and +`Escaper` has no access to feature flags. + ## How nodes obtain the context: the `PrintContext` escaper stack At print time the current escaping context lives in a **stack of `Escaper`s inside diff --git a/src/Latte/Runtime/HtmlHelpers.php b/src/Latte/Runtime/HtmlHelpers.php index 9d2b7d6c1..197b71de6 100644 --- a/src/Latte/Runtime/HtmlHelpers.php +++ b/src/Latte/Runtime/HtmlHelpers.php @@ -289,10 +289,18 @@ public static function formatStyleAttribute(string $namePart, mixed $value): str */ public static function formatSrcdocAttribute(string $namePart, mixed $value, bool $migrationWarnings = false): string { - return match (true) { - is_string($value), is_int($value), is_float($value), $value instanceof \Stringable => $namePart . '="' . self::escapeHtmlAttr($value) . '"', - default => self::formatAttribute($namePart, $value, $migrationWarnings), - }; + if (!is_string($value) && !is_int($value) && !is_float($value) && !$value instanceof \Stringable) { + return self::formatAttribute($namePart, $value, $migrationWarnings); + } + + if ($migrationWarnings + && !$value instanceof HtmlStringable + && !$value instanceof Nette\HtmlStringable + && (str_contains($s = (string) $value, '<') || html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8') !== $s) // the document differs from the text + ) { + self::triggerMigrationWarning(trim($namePart), 'string value: previously it was rendered as HTML, now it is escaped as text (wrap trusted HTML in Latte\Runtime\Html)'); + } + return $namePart . '="' . self::escapeHtmlAttr($value) . '"'; } diff --git a/tests/common/Engine.migrationWarnings.phpt b/tests/common/Engine.migrationWarnings.phpt index 28b096480..109806868 100644 --- a/tests/common/Engine.migrationWarnings.phpt +++ b/tests/common/Engine.migrationWarnings.phpt @@ -39,6 +39,12 @@ Assert::error( 'Behavior change for attribute \'contenteditable\' with value false: previously it rendered as contenteditable="", now it renders as contenteditable="false" (on line 1 at column 25)', ); +Assert::error( + fn() => $latte->renderToString('