Skip to content
Merged
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
34 changes: 27 additions & 7 deletions src/Escape.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
class Escape
{
/**
* Escapes strings for use everywhere inside HTML (except for comments) and concatenate it to string.
* Escapes strings for use inside HTML text and concatenate it to string.
* @param string|HtmlStringable|IHtmlString|mixed ...$data
* @return string
*
Expand All @@ -33,7 +33,9 @@ public static function html(...$data): string
if ($item instanceof HtmlStringable || $item instanceof IHtmlString) {
$output .= $item;
} else {
$output .= htmlspecialchars((string)$item, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE);
$str = htmlspecialchars((string)$item, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
$str = strtr($str, ['{{' => '{<!-- -->{', '{' => '&#123;']);
$output .= $str;
}
}

Expand All @@ -50,10 +52,28 @@ public static function html(...$data): string
public static function htmlAttr($data): string
{
$data = (string)$data;
if (strpos($data, '`') !== false && strpbrk($data, ' <>"\'') === false) {
$data .= ' '; // protection against innerHTML mXSS vulnerability nette/nette#1496
$data = htmlspecialchars($data, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
$data = str_replace('{', '&#123;', $data);
return $data;
}

/**
* Escapes JSON data for use inside HTML attribute value (especially for data attributes).
* @param array|mixed $data
* @return string
*/
public static function htmlJsonAttr($data): string
{
$json = json_encode(
$data,
JSON_HEX_AMP | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
);

if ($json === false) {
throw new RuntimeException('Failed to encode JSON for HTML attribute: ' . json_last_error_msg());
}
return self::html($data);

return self::htmlAttr($json);
}

/**
Expand Down Expand Up @@ -158,13 +178,13 @@ public static function url($data): string
*
* @link https://api.nette.org/2.4/source-Latte.Runtime.Filters.php.html#_safeUrl
*/
public static function safeUrl($data, bool $warning = false):string
public static function safeUrl($data, bool $warning = false): string
{
if (preg_match('~^(?:(?:https?|ftp)://[^@]+(?:/.*)?|(?:mailto|tel|sms):.+|[/?#].*|[^:]+)$~Di', (string)$data)) {
return (string)$data;
}

if($warning) {
if ($warning) {
trigger_error('URL was removed because is invalid or unsafe: ' . $data, E_USER_WARNING);
}

Expand Down
45 changes: 41 additions & 4 deletions tests/EscapeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public function getHtmlArgs(): array
['Hello &lt;World&gt;Hello <World>', ['Hello <World>', Html::fromHtml('Hello <World>')]],
['Hello <World>Hello &lt;World&gt;', [Html::fromHtml('Hello <World>'), 'Hello <World>']],
['Hello <World>Hello <World>', [Html::fromHtml('Hello <World>'), Html::fromHtml('Hello <World>')]],
['Hello &#123;my} lord', ['Hello {my} lord']],
['Hello {<!-- -->{my}} lord', ['Hello {{my}} lord']],
];
}

Expand All @@ -63,18 +65,19 @@ public function getHtmlAttrArgs(): array
['string', 'string'],
['&lt; &amp; &apos; &quot; &gt;', '< & \' " >'],
['&amp;quot;', '&quot;'],
['`hello ', '`hello'],
['`hello', '`hello'],
['`hello&quot;', '`hello"'],
['`hello&apos;', "`hello'"],
["foo \u{FFFD} bar", "foo \u{D800} bar"], // invalid codepoint high surrogates
["foo \u{FFFD}&quot; bar", "foo \xE3\x80\x22 bar"], // stripped UTF
['Hello World', 'Hello World'],
['Hello &lt;World&gt;', 'Hello <World>'],
['&quot; &apos; &lt; &gt; &amp; �', "\" ' < > & \x8F"],
['`hello` ', '`hello`'],
['``onmouseover=alert(1) ', '``onmouseover=alert(1)'],
['`hello`', '`hello`'],
['``onmouseover=alert(1)', '``onmouseover=alert(1)'],
['` &lt;br&gt; `', '` <br> `'],
['Foo&lt;br&gt;bar', Html::fromHtml('Foo<br>bar')]
['Foo&lt;br&gt;bar', Html::fromHtml('Foo<br>bar')],
['Hello &#123;&#123;my}} lord', 'Hello {{my}} lord'],
];
}

Expand All @@ -86,6 +89,40 @@ public function testHtmlAttr(string $expected, $data): void
Assert::same($expected, Escape::htmlAttr($data));
}

public function getHtmlJsonAttrArgs(): array
{
return [
['null', null],
['&quot;&quot;', ''],
['1', 1],
['&quot;string&quot;', 'string'],
['&quot;&lt;/tag&quot;', '</tag'],
['&quot;\u2028 \u2029 ]]&gt; &lt;!&quot;', "\u{2028} \u{2029} ]]> <!"],
['[0,1]', [0, 1]],
['[&quot;0&quot;,&quot;1&quot;]', ['0', '1']],
['&#123;&quot;a&quot;:&quot;0&quot;,&quot;b&quot;:&quot;1&quot;}', ['a' => '0', 'b' => '1']],
['&#123;&quot;a&quot;:&quot;Hello &#123;&#123;my}} world&quot;,&quot;b&quot;:&quot;1&quot;}', ['a' => 'Hello {{my}} world', 'b' => '1']],
['&quot;&lt;/script&gt;&quot;', '</script>'],
];
}

/**
* @dataProvider getHtmlJsonAttrArgs
*/
public function testHtmlJsonAttr($expected, $data): void
{
Assert::same($expected, Escape::htmlJsonAttr($data));
}

public function testInvalidHtmlJsonAttr(): void
{
$data = ['number' => NAN];

Assert::exception(static function () use ($data): void {
Escape::htmlJsonAttr($data);
}, RuntimeException::class, 'Failed to encode JSON for HTML attribute: Inf and NaN cannot be JSON encoded');
}

public function getHtmlHrefArgs(): array
{
return [
Expand Down
Loading