Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions docs/internals/escaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ layers that must stay in sync**.
`(contentType, state, subType)`, emits a string that **calls** a runtime escaping
function (e.g. `LR\HtmlHelpers::escapeText(...)`, `LR\Helpers::escapeCss(...)`;
plain-text output goes through the `escape` filter instead). The subType is not
purely state-derived: `enterHtmlAttribute` infers `js` for `on*` and `css` for
`style` attributes, and `enterHtmlText` classifies a `<script>` by its `type`
attribute. `export()` serializes the state as a composite string
purely state-derived: `enterHtmlAttribute` infers `js` for `on*`, `css` for
`style` and `html` for `srcdoc` attributes, and `enterHtmlText` classifies a
`<script>` by its `type` attribute. `export()` serializes the state as a composite string
(`'html/attr/js'`) — the key format used for block escaping and the convertor
table below.
- **Runtime** — the actual escaping functions live in `Runtime\Helpers` (JS / CSS /
Expand All @@ -36,6 +36,23 @@ type or a changed escaping rule must be reflected in *both* `Compiler\Escaper` a
`Runtime\*` helpers, or generated templates will call an escaper that behaves
differently than the compiler assumed.

## `srcdoc` is escaped twice

The value of `srcdoc` is an HTML document: the browser attribute-decodes it and then
parses the result as HTML. Escaping it only as an attribute is therefore an XSS —
`&lt;script&gt;` decodes to a live `<script>`, and an iframe without `sandbox` shares
the parent's origin. `HtmlHelpers::escapeHtmlAttr` escapes a value as HTML text first
and as an attribute second; an `Html` object passes through `escapeText` as markup
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="<p>{$x}</p>"` 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
Expand All @@ -54,7 +71,7 @@ element's `restoreEscape()` and leak the context past the element.
A whole HTML attribute generated from an expression (`Html\ExpressionAttributeNode`)
is not escaped via `Escaper` at all. The node picks a
`Runtime\HtmlHelpers::format*Attribute` method at compile time — the attribute *type*
(`bool`/`tristate`/`valuedBool`/`list`/`data`/`aria`/`style`) comes from the attribute name via
(`bool`/`tristate`/`valuedBool`/`list`/`data`/`aria`/`style`/`srcdoc`) comes from the attribute name via
`classifyAttributeType`, or is forced by an attribute modifier: `|toggle` anywhere in
the chain, `|json` only as the **last** modifier (`json` never comes from the
attribute name). Any non-HTML content type falls back to
Expand Down
7 changes: 7 additions & 0 deletions src/Latte/Compiler/Escaper.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class Escaper
self::HtmlAttribute => 'HtmlHelpers::escapeAttr',
self::HtmlAttribute . '/' . self::JavaScript => 'HtmlHelpers::escapeAttr',
self::HtmlAttribute . '/' . self::Css => 'HtmlHelpers::escapeAttr',
self::HtmlAttribute . '/' . self::HtmlText => 'HtmlHelpers::escapeHtmlAttr',
self::HtmlComment => 'HtmlHelpers::escapeComment',
'xml' => 'XmlHelpers::escapeText',
'xml/attr' => 'XmlHelpers::escapeAttr',
Expand All @@ -48,13 +49,15 @@ class Escaper
self::HtmlText => 'HtmlHelpers::escapeText',
self::HtmlAttribute => 'HtmlHelpers::escapeAttr',
self::HtmlAttribute . '/' . self::JavaScript => 'HtmlHelpers::escapeAttr',
self::HtmlAttribute . '/' . self::HtmlText => 'HtmlHelpers::escapeHtmlAttr',
self::HtmlRawText . '/' . self::JavaScript => 'HtmlHelpers::convertJSToRawText',
self::HtmlComment => 'HtmlHelpers::escapeComment',
],
self::Css => [
self::HtmlText => 'HtmlHelpers::escapeText',
self::HtmlAttribute => 'HtmlHelpers::escapeAttr',
self::HtmlAttribute . '/' . self::Css => 'HtmlHelpers::escapeAttr',
self::HtmlAttribute . '/' . self::HtmlText => 'HtmlHelpers::escapeHtmlAttr',
self::HtmlRawText . '/' . self::Css => 'HtmlHelpers::convertJSToRawText',
self::HtmlComment => 'HtmlHelpers::escapeComment',
],
Expand All @@ -63,6 +66,7 @@ class Escaper
self::HtmlAttribute => 'HtmlHelpers::convertHtmlToAttr',
self::HtmlAttribute . '/' . self::JavaScript => 'HtmlHelpers::convertHtmlToAttr',
self::HtmlAttribute . '/' . self::Css => 'HtmlHelpers::convertHtmlToAttr',
self::HtmlAttribute . '/' . self::HtmlText => 'HtmlHelpers::escapeAttr',
self::HtmlComment => 'HtmlHelpers::escapeComment',
self::HtmlRawText . '/' . self::HtmlText => 'HtmlHelpers::convertHtmlToRawText',
],
Expand Down Expand Up @@ -155,6 +159,8 @@ public function enterHtmlAttribute(?string $name = null): void
$this->subType = self::JavaScript;
} elseif ($name === 'style') {
$this->subType = self::Css;
} elseif ($name === 'srcdoc') {
$this->subType = self::HtmlText;
}
}
}
Expand Down Expand Up @@ -182,6 +188,7 @@ public function escape(string $str): string
'' => 'LR\HtmlHelpers::escapeAttr(' . $str . ')',
self::JavaScript => 'LR\HtmlHelpers::escapeAttr(LR\Helpers::escapeJs(' . $str . '))',
self::Css => 'LR\HtmlHelpers::escapeAttr(LR\Helpers::escapeCss(' . $str . '))',
self::HtmlText => 'LR\HtmlHelpers::escapeHtmlAttr(' . $str . ')',
},
self::HtmlComment => 'LR\HtmlHelpers::escapeComment(' . $str . ')',
self::HtmlBogusTag => 'LR\HtmlHelpers::escapeTag(' . $str . ')',
Expand Down
32 changes: 31 additions & 1 deletion src/Latte/Runtime/HtmlHelpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ public static function escapeAttr(mixed $s): string
}


/**
* Escapes HTML for use inside attribute holding an HTML document (srcdoc).
*/
public static function escapeHtmlAttr(mixed $s): string
{
return self::escapeAttr(self::escapeText($s));
}


/**
* Escapes string for use inside HTML tag.
*/
Expand Down Expand Up @@ -175,7 +184,7 @@ public static function convertHtmlToText(string $s): string


/**
* Returns the type category of an HTML attribute: 'bool', 'tristate', 'valuedBool', 'list', 'data', 'aria', 'style', or ''.
* Returns the type category of an HTML attribute: 'bool', 'tristate', 'valuedBool', 'list', 'data', 'aria', 'style', 'srcdoc', or ''.
*/
public static function classifyAttributeType(string $name): string
{
Expand All @@ -188,6 +197,7 @@ public static function classifyAttributeType(string $name): string
str_starts_with($name, 'data-') => 'data',
str_starts_with($name, 'aria-') => 'aria',
$name === 'style' => 'style',
$name === 'srcdoc' => 'srcdoc',
default => '',
};
}
Expand Down Expand Up @@ -274,6 +284,26 @@ public static function formatStyleAttribute(string $namePart, mixed $value): str
}


/**
* Formats HTML attribute 'srcdoc', whose value is an HTML document.
*/
public static function formatSrcdocAttribute(string $namePart, mixed $value, bool $migrationWarnings = false): string
{
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) . '"';
}


/**
* Formats data-* HTML attribute.
*/
Expand Down
6 changes: 6 additions & 0 deletions tests/common/Engine.migrationWarnings.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -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('<iframe srcdoc="{=\'<b>x</b>\'}">'),
E_USER_WARNING,
'Behavior change for attribute \'srcdoc\' with string value: previously it was rendered as HTML, now it is escaped as text (wrap trusted HTML in Latte\Runtime\Html) (on line 1 at column 19)',
);

// |accept
Assert::same(
'<input>',
Expand Down
97 changes: 97 additions & 0 deletions tests/common/contentType.html.srcdoc.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php declare(strict_types=1);

/**
* Test: HTML document in srcdoc attribute
*/

use Latte\Runtime\Html;
use Tester\Assert;

require __DIR__ . '/../bootstrap.php';


$latte = createLatte();
$params = [
'x' => '<script>alert("1")</script> & {',
'html' => new Html('<b title="a&amp;b">bold</b>'),
];

// text is escaped for HTML and then for the attribute
Assert::same(
'<iframe srcdoc="<p>&amp;lt;script&amp;gt;alert(&quot;1&quot;)&amp;lt;/script&amp;gt; &amp;amp; &amp;#123;</p>"></iframe>',
$latte->renderToString('<iframe srcdoc="<p>{$x}</p>"></iframe>', $params),
);

Assert::same(
'<iframe srcdoc="&amp;lt;script&amp;gt;alert(&quot;1&quot;)&amp;lt;/script&amp;gt; &amp;amp; &amp;#123;"></iframe>',
$latte->renderToString("<iframe srcdoc='{\$x}'></iframe>", $params),
);

Assert::same(
'<iframe SRCDOC="a&amp;lt;script&amp;gt;alert(&quot;1&quot;)&amp;lt;/script&amp;gt; &amp;amp; &amp;#123;"></iframe>',
$latte->renderToString('<iframe SRCDOC=a{$x}></iframe>', $params),
);

// HTML is escaped only for the attribute
Assert::same(
'<iframe srcdoc="&lt;b title=&quot;a&amp;amp;b&quot;&gt;bold&lt;/b&gt;"></iframe>',
$latte->renderToString('<iframe srcdoc="{$html}"></iframe>', $params),
);

// |noescape escapes only quotes
Assert::same(
'<iframe srcdoc="<script>alert(&quot;1&quot;)</script> & {"></iframe>',
$latte->renderToString('<iframe srcdoc="{$x|noescape}"></iframe>', $params),
);

// dynamic attribute
Assert::same(
'<iframe srcdoc="&amp;lt;script&amp;gt;alert(&quot;1&quot;)&amp;lt;/script&amp;gt; &amp;amp; &amp;#123;"></iframe>',
$latte->renderToString('<iframe srcdoc={$x}></iframe>', $params),
);

Assert::same(
'<iframe srcdoc="&lt;b title=&quot;a&amp;amp;b&quot;&gt;bold&lt;/b&gt;"></iframe>',
$latte->renderToString('<iframe srcdoc={$html}></iframe>', $params),
);

Assert::same(
'<iframe></iframe>',
$latte->renderToString('<iframe srcdoc={$none}></iframe>', ['none' => null]),
);

// n:attr
Assert::same(
'<iframe srcdoc="&amp;lt;script&amp;gt;alert(&quot;1&quot;)&amp;lt;/script&amp;gt; &amp;amp; &amp;#123;"></iframe>',
$latte->renderToString('<iframe n:attr="srcdoc => $x"></iframe>', $params),
);

Assert::same(
'<iframe srcdoc="&lt;b title=&quot;a&amp;amp;b&quot;&gt;bold&lt;/b&gt;"></iframe>',
$latte->renderToString('<iframe n:attr="srcdoc => $html"></iframe>', $params),
);

// blocks
Assert::same(
'<iframe srcdoc="&lt;i&gt;&amp;lt;script&amp;gt;alert(&quot;1&quot;)&amp;lt;/script&amp;gt; &amp;amp; &amp;#123;&lt;/i&gt;"></iframe>',
$latte->renderToString('{define doc}<i>{$x}</i>{/define}<iframe srcdoc="{include doc}"></iframe>', $params),
);

$latte->setLoader(new Latte\Loaders\StringLoader([
'main' => '<iframe srcdoc="{include html.latte}"></iframe> <iframe srcdoc="{include text.latte}"></iframe> <iframe srcdoc="{include js.latte}"></iframe>',
'html.latte' => '<i> &amp; </i>',
'text.latte' => '{contentType text}<i> & "',
'js.latte' => '{contentType javascript}a < b',
]));

Assert::same(
'<iframe srcdoc="&lt;i&gt; &amp;amp; &lt;/i&gt;"></iframe> <iframe srcdoc="&amp;lt;i&amp;gt; &amp;amp; &quot;"></iframe> <iframe srcdoc="a &amp;lt; b"></iframe>',
$latte->renderToString('main'),
);

// XML has no srcdoc context
$latte->setLoader(new Latte\Loaders\StringLoader);
Assert::same(
'<iframe srcdoc="&lt;p&gt;&lt;script&gt;alert(&quot;1&quot;)&lt;/script&gt; &amp; {&lt;/p&gt;"></iframe>',
$latte->renderToString('{contentType xml}<iframe srcdoc="{=\'<p>\'}{$x}{=\'</p>\'}"></iframe>', $params),
);
17 changes: 17 additions & 0 deletions tests/runtime/HtmlHelpers.escapeHtmlAttr.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php declare(strict_types=1);

use Latte\Runtime\Html;
use Latte\Runtime\HtmlHelpers;
use Tester\Assert;

require __DIR__ . '/../bootstrap.php';


Assert::same('', HtmlHelpers::escapeHtmlAttr(null));
Assert::same('', HtmlHelpers::escapeHtmlAttr(''));
Assert::same('1', HtmlHelpers::escapeHtmlAttr(1));
Assert::same('&amp;lt;b&amp;gt; &amp;amp; &quot; &apos;', HtmlHelpers::escapeHtmlAttr('<b> & " \''));
Assert::same('&amp;#123; &#123;&lt;!-- --&gt;&#123;',HtmlHelpers::escapeHtmlAttr('{ {{'));
Assert::same('&amp;amp;amp;', HtmlHelpers::escapeHtmlAttr('&amp;'));
Assert::same('&lt;b title=&quot;x&quot;&gt;a &amp;amp; b&lt;/b&gt;', HtmlHelpers::escapeHtmlAttr(new Html('<b title="x">a &amp; b</b>')));
Assert::same('&#123;', HtmlHelpers::escapeHtmlAttr(new Html('{')));
45 changes: 45 additions & 0 deletions tests/runtime/HtmlHelpers.formatSrcdocAttribute.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php declare(strict_types=1);

use Latte\Runtime\Html;
use Latte\Runtime\HtmlHelpers;
use Tester\Assert;

require __DIR__ . '/../bootstrap.php';


class StringObject
{
public function __toString()
{
return 'one&<br>';
}
}


Assert::same('srcdoc', HtmlHelpers::classifyAttributeType('srcdoc'));
Assert::same('srcdoc', HtmlHelpers::classifyAttributeType('SrcDoc'));

// escaped by escapeHtmlAttr
Assert::same('srcdoc="&amp;lt;b&amp;gt;"', HtmlHelpers::formatSrcdocAttribute('srcdoc', '<b>'));
Assert::same(' srcdoc="&lt;b&gt;"', HtmlHelpers::formatSrcdocAttribute(' srcdoc', new Html('<b>')));

// migration warnings
foreach (['<b>x</b>', 'a &amp; b', new StringObject] as $value) {
Assert::error(
fn() => HtmlHelpers::formatSrcdocAttribute('srcdoc', $value, migrationWarnings: true),
E_USER_WARNING,
"Behavior change for attribute 'srcdoc' with string value: previously it was rendered as HTML, now it is escaped as text (wrap trusted HTML in Latte\\Runtime\\Html).",
);
}

foreach (['a & b > " {', 1, new Html('<b>x</b>')] as $value) { // renders the same document as before
Assert::noError(fn() => HtmlHelpers::formatSrcdocAttribute('srcdoc', $value, migrationWarnings: true));
}

// 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.",
);
Loading