<?php
namespace Twig\Tests;
/*
* 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\TestCase;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extension\EscaperExtension;
use Twig\Loader\LoaderInterface;
class Twig_Tests_Extension_EscaperTest extends TestCase
{
/**
* All character encodings supported by htmlspecialchars().
*/
protected $htmlSpecialChars = [
'\'' => ''',
'"' => '"',
'<' => '<',
'>' => '>',
'&' => '&',
];
protected $htmlAttrSpecialChars = [
'\'' => ''',
/* Characters beyond ASCII value 255 to unicode escape */
'Ā' => 'Ā',
'😀' => '😀',
/* Immune chars excluded */
',' => ',',
'.' => '.',
'-' => '-',
'_' => '_',
/* Basic alnums excluded */
'a' => 'a',
'A' => 'A',
'z' => 'z',
'Z' => 'Z',
'0' => '0',
'9' => '9',
/* Basic control characters and null */
"\r" => '
',
"\n" => '
',
"\t" => '	',
"\0" => '�', // should use Unicode replacement char
/* Encode chars as named entities where possible */
'<' => '<',
'>' => '>',
'&' => '&',
'"' => '"',
/* Encode spaces for quoteless attribute protection */
' ' => ' ',
];
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()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
foreach ($this->htmlSpecialChars as $key => $value) {
$this->assertEquals($value, twig_escape_filter($twig, $key, 'html'), 'Failed to escape: '.$key);
}
}
public function testHtmlAttributeEscapingConvertsSpecialChars()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
foreach ($this->htmlAttrSpecialChars as $key => $value) {
$this->assertEquals($value, twig_escape_filter($twig, $key, 'html_attr'), 'Failed to escape: '.$key);
}
}
public function testJavascriptEscapingConvertsSpecialChars()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
foreach ($this->jsSpecialChars as $key => $value) {
$this->assertEquals($value, twig_escape_filter($twig, $key, 'js'), 'Failed to escape: '.$key);
}
}
public function testJavascriptEscapingReturnsStringIfZeroLength()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$this->assertEquals('', twig_escape_filter($twig, '', 'js'));
}
public function testJavascriptEscapingReturnsStringIfContainsOnlyDigits()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$this->assertEquals('123', twig_escape_filter($twig, '123', 'js'));
}
public function testCssEscapingConvertsSpecialChars()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
foreach ($this->cssSpecialChars as $key => $value) {
$this->assertEquals($value, twig_escape_filter($twig, $key, 'css'), 'Failed to escape: '.$key);
}
}
public function testCssEscapingReturnsStringIfZeroLength()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$this->assertEquals('', twig_escape_filter($twig, '', 'css'));
}
public function testCssEscapingReturnsStringIfContainsOnlyDigits()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$this->assertEquals('123', twig_escape_filter($twig, '123', 'css'));
}
public function testUrlEscapingConvertsSpecialChars()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
foreach ($this->urlSpecialChars as $key => $value) {
$this->assertEquals($value, twig_escape_filter($twig, $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()
{
$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()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$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, twig_escape_filter($twig, $literal, 'js'));
} else {
$literal = $this->codepointToUtf8($chr);
if (\in_array($literal, $immune)) {
$this->assertEquals($literal, twig_escape_filter($twig, $literal, 'js'));
} else {
$this->assertNotEquals(
$literal,
twig_escape_filter($twig, $literal, 'js'),
"$literal should be escaped!");
}
}
}
}
public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$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, twig_escape_filter($twig, $literal, 'html_attr'));
} else {
$literal = $this->codepointToUtf8($chr);
if (\in_array($literal, $immune)) {
$this->assertEquals($literal, twig_escape_filter($twig, $literal, 'html_attr'));
} else {
$this->assertNotEquals(
$literal,
twig_escape_filter($twig, $literal, 'html_attr'),
"$literal should be escaped!");
}
}
}
}
public function testCssEscapingEscapesOwaspRecommendedRanges()
{
$twig = new Environment($this->createMock(LoaderInterface::class));
// 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, twig_escape_filter($twig, $literal, 'css'));
} else {
$literal = $this->codepointToUtf8($chr);
$this->assertNotEquals(
$literal,
twig_escape_filter($twig, $literal, 'css'),
"$literal should be escaped!");
}
}
}
/**
* @dataProvider provideCustomEscaperCases
*/
public function testCustomEscaper($expected, $string, $strategy)
{
$twig = new Environment($this->createMock(LoaderInterface::class));
$twig->getExtension(EscaperExtension::class)->setEscaper('foo', 'Twig\Tests\foo_escaper_for_test');
$this->assertSame($expected, twig_escape_filter($twig, $string, $strategy));
}
public function provideCustomEscaperCases()
{
return [
['fooUTF-8', 'foo', 'foo'],
['UTF-8', null, 'foo'],
['42UTF-8', 42, 'foo'],
];
}
public function testUnknownCustomEscaper()
{
$this->expectException(RuntimeError::class);
twig_escape_filter(new Environment($this->createMock(LoaderInterface::class)), 'foo', 'bar');
}
/**
* @dataProvider provideObjectsForEscaping
*/
public function testObjectEscaping(string $escapedHtml, string $escapedJs, array $safeClasses)
{
$obj = new Extension_TestClass();
$twig = new Environment($this->createMock(LoaderInterface::class));
$twig->getExtension('\Twig\Extension\EscaperExtension')->setSafeClasses($safeClasses);
$this->assertSame($escapedHtml, twig_escape_filter($twig, $obj, 'html', null, true));
$this->assertSame($escapedJs, twig_escape_filter($twig, $obj, 'js', null, true));
}
public function provideObjectsForEscaping()
{
return [
['<br />', '<br />', ['\Twig\Tests\Extension_TestClass' => ['js']]],
['<br />', '\u003Cbr\u0020\/\u003E', ['\Twig\Tests\Extension_TestClass' => ['html']]],
['<br />', '<br />', ['\Twig\Tests\Extension_SafeHtmlInterface' => ['js']]],
['<br />', '<br />', ['\Twig\Tests\Extension_SafeHtmlInterface' => ['all']]],
];
}
}
function foo_escaper_for_test(Environment $twig, $string, $charset)
{
return $string.$charset;
}
interface Extension_SafeHtmlInterface
{
}
class Extension_TestClass implements Extension_SafeHtmlInterface
{
public function __toString()
{
return '<br />';
}
}