Files
Twig/tests/Runtime/EscaperRuntimeTest.php
Fabien Potencier 0b6e824ea8 Merge branch '3.x' into 4.x
* 3.x:
  Add void return type hint even in tests
  Run php-cs-fixer sequentially so the void_return src-only customiser is applied
  Fix CHANGELOG
  [Intl] Add format_list filter using PHP 8.5's IntlListFormatter

# Conflicts:
#	.github/workflows/ci.yml
#	CHANGELOG
#	extra/cssinliner-extra/Tests/LegacyFunctionsTest.php
#	extra/html-extra/Tests/CvaTest.php
#	extra/html-extra/Tests/HtmlAttrMergeTest.php
#	extra/html-extra/Tests/HtmlAttrTest.php
#	extra/html-extra/Tests/LegacyFunctionsTest.php
#	extra/inky-extra/Tests/LegacyFunctionsTest.php
#	extra/markdown-extra/Tests/FunctionalTest.php
#	extra/markdown-extra/Tests/LegacyFunctionsTest.php
#	extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php
#	extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php
#	extra/twig-extra-bundle/TwigExtraBundle.php
#	src/Extension/CoreExtension.php
#	src/Extension/EscaperExtension.php
#	src/Node/CheckSecurityCallNode.php
#	src/Node/Expression/FunctionExpression.php
#	src/Node/ModuleNode.php
#	src/Node/Node.php
#	src/Node/TypesNode.php
#	src/Resources/core.php
#	src/Resources/debug.php
#	src/Test/IntegrationTestCase.php
#	tests/CustomExtensionTest.php
#	tests/EnvironmentTest.php
#	tests/ExpressionParserTest.php
#	tests/Extension/CoreTest.php
#	tests/Extension/EscaperTest.php
#	tests/Extension/LegacyDebugFunctionsTest.php
#	tests/Extension/LegacyStringLoaderFunctionsTest.php
#	tests/Extension/SandboxStateChangeTest.php
#	tests/Extension/SandboxTest.php
#	tests/LexerTest.php
#	tests/Node/Expression/CallTest.php
#	tests/Node/Expression/ConditionalTest.php
#	tests/Node/NodeTest.php
#	tests/Resources/LegacyCoreTest.php
#	tests/TemplateTest.php
#	tests/Util/CallableArgumentsExtractorTest.php
2026-07-12 13:55:37 +02:00

440 lines
14 KiB
PHP

<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Tests\Runtime;
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Error\RuntimeError;
use Twig\Runtime\EscaperRuntime;
class EscaperRuntimeTest extends TestCase
{
/**
* All character encodings supported by htmlspecialchars().
*/
protected $htmlSpecialChars = [
'\'' => '&#039;',
'"' => '&quot;',
'<' => '&lt;',
'>' => '&gt;',
'&' => '&amp;',
];
protected $htmlAttrSpecialChars = [
'\'' => '&#x27;',
/* Characters beyond ASCII value 255 to unicode escape */
'Ā' => '&#x0100;',
'😀' => '&#x1F600;',
/* Immune chars excluded */
',' => ',',
'.' => '.',
'-' => '-',
'_' => '_',
/* Basic alnums excluded */
'a' => 'a',
'A' => 'A',
'z' => 'z',
'Z' => 'Z',
'0' => '0',
'9' => '9',
/* Basic control characters and null */
"\r" => '&#x0D;',
"\n" => '&#x0A;',
"\t" => '&#x09;',
"\0" => '&#xFFFD;', // should use Unicode replacement char
/* Encode chars as named entities where possible */
'<' => '&lt;',
'>' => '&gt;',
'&' => '&amp;',
'"' => '&quot;',
/* Encode spaces for quoteless attribute protection */
' ' => '&#x20;',
];
protected $jsSpecialChars = [
/* HTML special chars - escape without exception to hex */
'<' => '\\u003C',
'>' => '\\u003E',
'\'' => '\\u0027',
'"' => '\\u0022',
'&' => '\\u0026',
'/' => '\\/',
/* Characters beyond ASCII value 255 to unicode escape */
'Ā' => '\\u0100',
'😀' => '\\uD83D\\uDE00',
/* Immune chars excluded */
',' => ',',
'.' => '.',
'_' => '_',
/* Basic alnums excluded */
'a' => 'a',
'A' => 'A',
'z' => 'z',
'Z' => 'Z',
'0' => '0',
'9' => '9',
/* Basic control characters and null */
"\r" => '\r',
"\n" => '\n',
"\x08" => '\b',
"\t" => '\t',
"\x0C" => '\f',
"\0" => '\\u0000',
/* Encode spaces for quoteless attribute protection */
' ' => '\\u0020',
];
protected $urlSpecialChars = [
/* HTML special chars - escape without exception to percent encoding */
'<' => '%3C',
'>' => '%3E',
'\'' => '%27',
'"' => '%22',
'&' => '%26',
/* Characters beyond ASCII value 255 to hex sequence */
'Ā' => '%C4%80',
/* Punctuation and unreserved check */
',' => '%2C',
'.' => '.',
'_' => '_',
'-' => '-',
':' => '%3A',
';' => '%3B',
'!' => '%21',
/* Basic alnums excluded */
'a' => 'a',
'A' => 'A',
'z' => 'z',
'Z' => 'Z',
'0' => '0',
'9' => '9',
/* Basic control characters and null */
"\r" => '%0D',
"\n" => '%0A',
"\t" => '%09',
"\0" => '%00',
/* PHP quirks from the past */
' ' => '%20',
'~' => '~',
'+' => '%2B',
];
protected $cssSpecialChars = [
/* HTML special chars - escape without exception to hex */
'<' => '\\3C ',
'>' => '\\3E ',
'\'' => '\\27 ',
'"' => '\\22 ',
'&' => '\\26 ',
/* Characters beyond ASCII value 255 to unicode escape */
'Ā' => '\\100 ',
/* Immune chars excluded */
',' => '\\2C ',
'.' => '\\2E ',
'_' => '\\5F ',
/* Basic alnums excluded */
'a' => 'a',
'A' => 'A',
'z' => 'z',
'Z' => 'Z',
'0' => '0',
'9' => '9',
/* Basic control characters and null */
"\r" => '\\D ',
"\n" => '\\A ',
"\t" => '\\9 ',
"\0" => '\\0 ',
/* Encode spaces for quoteless attribute protection */
' ' => '\\20 ',
];
public function testHtmlEscapingConvertsSpecialChars(): void
{
foreach ($this->htmlSpecialChars as $key => $value) {
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html'), 'Failed to escape: '.$key);
}
}
public function testHtmlAttributeEscapingConvertsSpecialChars(): void
{
foreach ($this->htmlAttrSpecialChars as $key => $value) {
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr'), 'Failed to escape: '.$key);
}
}
public function testHtmlAttributeRelaxedEscapingConvertsSpecialChars(): void
{
foreach ($this->htmlAttrSpecialChars as $key => $value) {
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr_relaxed'), 'Failed to escape: '.$key);
}
}
public function testJavascriptEscapingConvertsSpecialChars(): void
{
foreach ($this->jsSpecialChars as $key => $value) {
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'js'), 'Failed to escape: '.$key);
}
}
public function testJavascriptEscapingConvertsSpecialCharsWithInternalEncoding(): void
{
$previousInternalEncoding = mb_internal_encoding();
try {
mb_internal_encoding('ISO-8859-1');
foreach ($this->jsSpecialChars as $key => $value) {
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'js'), 'Failed to escape: '.$key);
}
} finally {
if (false !== $previousInternalEncoding) {
mb_internal_encoding($previousInternalEncoding);
}
}
}
public function testJavascriptEscapingReturnsStringIfZeroLength(): void
{
$this->assertEquals('', (new EscaperRuntime())->escape('', 'js'));
}
public function testJavascriptEscapingReturnsStringIfContainsOnlyDigits(): void
{
$this->assertEquals('123', (new EscaperRuntime())->escape('123', 'js'));
}
public function testCssEscapingConvertsSpecialChars(): void
{
foreach ($this->cssSpecialChars as $key => $value) {
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'css'), 'Failed to escape: '.$key);
}
}
public function testCssEscapingReturnsStringIfZeroLength(): void
{
$this->assertEquals('', (new EscaperRuntime())->escape('', 'css'));
}
public function testCssEscapingReturnsStringIfContainsOnlyDigits(): void
{
$this->assertEquals('123', (new EscaperRuntime())->escape('123', 'css'));
}
public function testUrlEscapingConvertsSpecialChars(): void
{
foreach ($this->urlSpecialChars as $key => $value) {
$this->assertEquals($value, (new EscaperRuntime())->escape($key, 'url'), 'Failed to escape: '.$key);
}
}
/**
* Range tests to confirm escaped range of characters is within OWASP recommendation.
*/
/**
* Only testing the first few 2 ranges on this prot. function as that's all these
* other range tests require.
*/
public function testUnicodeCodepointConversionToUtf8(): void
{
$expected = ' ~ޙ';
$codepoints = [0x20, 0x7E, 0x799];
$result = '';
foreach ($codepoints as $value) {
$result .= $this->codepointToUtf8($value);
}
$this->assertEquals($expected, $result);
}
/**
* Convert a Unicode Codepoint to a literal UTF-8 character.
*
* @param int $codepoint Unicode codepoint in hex notation
*
* @return string UTF-8 literal string
*/
protected function codepointToUtf8($codepoint)
{
if ($codepoint < 0x80) {
return \chr($codepoint);
}
if ($codepoint < 0x800) {
return \chr($codepoint >> 6 & 0x3F | 0xC0)
.\chr($codepoint & 0x3F | 0x80);
}
if ($codepoint < 0x10000) {
return \chr($codepoint >> 12 & 0x0F | 0xE0)
.\chr($codepoint >> 6 & 0x3F | 0x80)
.\chr($codepoint & 0x3F | 0x80);
}
if ($codepoint < 0x110000) {
return \chr($codepoint >> 18 & 0x07 | 0xF0)
.\chr($codepoint >> 12 & 0x3F | 0x80)
.\chr($codepoint >> 6 & 0x3F | 0x80)
.\chr($codepoint & 0x3F | 0x80);
}
throw new \Exception('Codepoint requested outside of Unicode range.');
}
public function testJavascriptEscapingEscapesOwaspRecommendedRanges(): void
{
$immune = [',', '.', '_']; // Exceptions to escaping ranges
for ($chr = 0; $chr < 0xFF; ++$chr) {
if ($chr >= 0x30 && $chr <= 0x39
|| $chr >= 0x41 && $chr <= 0x5A
|| $chr >= 0x61 && $chr <= 0x7A) {
$literal = $this->codepointToUtf8($chr);
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'js'));
} else {
$literal = $this->codepointToUtf8($chr);
if (\in_array($literal, $immune)) {
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'js'));
} else {
$this->assertNotEquals(
$literal,
(new EscaperRuntime())->escape($literal, 'js'),
"$literal should be escaped!");
}
}
}
}
public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges(): void
{
$immune = [',', '.', '-', '_']; // Exceptions to escaping ranges
for ($chr = 0; $chr < 0xFF; ++$chr) {
if ($chr >= 0x30 && $chr <= 0x39
|| $chr >= 0x41 && $chr <= 0x5A
|| $chr >= 0x61 && $chr <= 0x7A) {
$literal = $this->codepointToUtf8($chr);
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr'));
} else {
$literal = $this->codepointToUtf8($chr);
if (\in_array($literal, $immune)) {
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr'));
} else {
$this->assertNotEquals(
$literal,
(new EscaperRuntime())->escape($literal, 'html_attr'),
"$literal should be escaped!");
}
}
}
}
public function testHtmlAttributeRelaxedEscapingEscapesOwaspRecommendedRanges(): void
{
$immune = [',', '.', '-', '_', ':', '@', '[', ']']; // Exceptions to escaping ranges
for ($chr = 0; $chr < 0xFF; ++$chr) {
if ($chr >= 0x30 && $chr <= 0x39
|| $chr >= 0x41 && $chr <= 0x5A
|| $chr >= 0x61 && $chr <= 0x7A) {
$literal = $this->codepointToUtf8($chr);
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'));
} else {
$literal = $this->codepointToUtf8($chr);
if (\in_array($literal, $immune)) {
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'));
} else {
$this->assertNotEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'), "$literal should be escaped!");
}
}
}
}
public function testCssEscapingEscapesOwaspRecommendedRanges(): void
{
// CSS has no exceptions to escaping ranges
for ($chr = 0; $chr < 0xFF; ++$chr) {
if ($chr >= 0x30 && $chr <= 0x39
|| $chr >= 0x41 && $chr <= 0x5A
|| $chr >= 0x61 && $chr <= 0x7A) {
$literal = $this->codepointToUtf8($chr);
$this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'css'));
} else {
$literal = $this->codepointToUtf8($chr);
$this->assertNotEquals(
$literal,
(new EscaperRuntime())->escape($literal, 'css'),
"$literal should be escaped!");
}
}
}
public function testUnknownCustomEscaper(): void
{
$this->expectException(RuntimeError::class);
(new EscaperRuntime())->escape('foo', 'bar');
}
#[DataProvider('provideCustomEscaperCases')]
public function testCustomEscaper($expected, $string, $strategy, $charset): void
{
$escaper = new EscaperRuntime();
$escaper->setEscaper('foo', escaper(...));
$this->assertSame($expected, $escaper->escape($string, $strategy, $charset));
}
public static function provideCustomEscaperCases()
{
return [
['foo**ISO-8859-1', 'foo', 'foo', 'ISO-8859-1'],
['**ISO-8859-1', null, 'foo', 'ISO-8859-1'],
['42**UTF-8', 42, 'foo', null],
];
}
#[DataProvider('provideObjectsForEscaping')]
public function testObjectEscaping(string $escapedHtml, string $escapedJs, array $safeClasses): void
{
$obj = new ExtensionTestClass();
$escaper = new EscaperRuntime();
$escaper->setSafeClasses($safeClasses);
$this->assertSame($escapedHtml, $escaper->escape($obj, 'html', null, true));
$this->assertSame($escapedJs, $escaper->escape($obj, 'js', null, true));
}
public static function provideObjectsForEscaping()
{
return [
['&lt;br /&gt;', '<br />', [ExtensionTestClass::class => ['js']]],
['<br />', '\u003Cbr\u0020\/\u003E', [ExtensionTestClass::class => ['html']]],
['&lt;br /&gt;', '<br />', [ExtensionSafeHtmlInterface::class => ['js']]],
['<br />', '<br />', [ExtensionSafeHtmlInterface::class => ['all']]],
];
}
}
function escaper($string, $charset)
{
return $string.'**'.$charset;
}
interface ExtensionSafeHtmlInterface
{
}
class ExtensionTestClass implements ExtensionSafeHtmlInterface
{
public function __toString()
{
return '<br />';
}
}