1<?php
2
3/**
4 * This class is adapted from code coming from Zend Framework.
5 *
6 * @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (https://www.zend.com)
7 * @license   https://framework.zend.com/license/new-bsd New BSD License
8 */
9class Twig_Test_EscapingTest extends \PHPUnit\Framework\TestCase
10{
11    /**
12     * All character encodings supported by htmlspecialchars().
13     */
14    protected $htmlSpecialChars = [
15        '\'' => '&#039;',
16        '"' => '&quot;',
17        '<' => '&lt;',
18        '>' => '&gt;',
19        '&' => '&amp;',
20    ];
21
22    protected $htmlAttrSpecialChars = [
23        '\'' => '&#x27;',
24        /* Characters beyond ASCII value 255 to unicode escape */
25        'Ā' => '&#x0100;',
26        '��' => '&#x1F600;',
27        /* Immune chars excluded */
28        ',' => ',',
29        '.' => '.',
30        '-' => '-',
31        '_' => '_',
32        /* Basic alnums excluded */
33        'a' => 'a',
34        'A' => 'A',
35        'z' => 'z',
36        'Z' => 'Z',
37        '0' => '0',
38        '9' => '9',
39        /* Basic control characters and null */
40        "\r" => '&#x0D;',
41        "\n" => '&#x0A;',
42        "\t" => '&#x09;',
43        "\0" => '&#xFFFD;', // should use Unicode replacement char
44        /* Encode chars as named entities where possible */
45        '<' => '&lt;',
46        '>' => '&gt;',
47        '&' => '&amp;',
48        '"' => '&quot;',
49        /* Encode spaces for quoteless attribute protection */
50        ' ' => '&#x20;',
51    ];
52
53    protected $jsSpecialChars = [
54        /* HTML special chars - escape without exception to hex */
55        '<' => '\\u003C',
56        '>' => '\\u003E',
57        '\'' => '\\u0027',
58        '"' => '\\u0022',
59        '&' => '\\u0026',
60        '/' => '\\/',
61        /* Characters beyond ASCII value 255 to unicode escape */
62        'Ā' => '\\u0100',
63        '��' => '\\uD83D\\uDE00',
64        /* Immune chars excluded */
65        ',' => ',',
66        '.' => '.',
67        '_' => '_',
68        /* Basic alnums excluded */
69        'a' => 'a',
70        'A' => 'A',
71        'z' => 'z',
72        'Z' => 'Z',
73        '0' => '0',
74        '9' => '9',
75        /* Basic control characters and null */
76        "\r" => '\r',
77        "\n" => '\n',
78        "\x08" => '\b',
79        "\t" => '\t',
80        "\x0C" => '\f',
81        "\0" => '\\u0000',
82        /* Encode spaces for quoteless attribute protection */
83        ' ' => '\\u0020',
84    ];
85
86    protected $urlSpecialChars = [
87        /* HTML special chars - escape without exception to percent encoding */
88        '<' => '%3C',
89        '>' => '%3E',
90        '\'' => '%27',
91        '"' => '%22',
92        '&' => '%26',
93        /* Characters beyond ASCII value 255 to hex sequence */
94        'Ā' => '%C4%80',
95        /* Punctuation and unreserved check */
96        ',' => '%2C',
97        '.' => '.',
98        '_' => '_',
99        '-' => '-',
100        ':' => '%3A',
101        ';' => '%3B',
102        '!' => '%21',
103        /* Basic alnums excluded */
104        'a' => 'a',
105        'A' => 'A',
106        'z' => 'z',
107        'Z' => 'Z',
108        '0' => '0',
109        '9' => '9',
110        /* Basic control characters and null */
111        "\r" => '%0D',
112        "\n" => '%0A',
113        "\t" => '%09',
114        "\0" => '%00',
115        /* PHP quirks from the past */
116        ' ' => '%20',
117        '~' => '~',
118        '+' => '%2B',
119    ];
120
121    protected $cssSpecialChars = [
122        /* HTML special chars - escape without exception to hex */
123        '<' => '\\3C ',
124        '>' => '\\3E ',
125        '\'' => '\\27 ',
126        '"' => '\\22 ',
127        '&' => '\\26 ',
128        /* Characters beyond ASCII value 255 to unicode escape */
129        'Ā' => '\\100 ',
130        /* Immune chars excluded */
131        ',' => '\\2C ',
132        '.' => '\\2E ',
133        '_' => '\\5F ',
134        /* Basic alnums excluded */
135        'a' => 'a',
136        'A' => 'A',
137        'z' => 'z',
138        'Z' => 'Z',
139        '0' => '0',
140        '9' => '9',
141        /* Basic control characters and null */
142        "\r" => '\\D ',
143        "\n" => '\\A ',
144        "\t" => '\\9 ',
145        "\0" => '\\0 ',
146        /* Encode spaces for quoteless attribute protection */
147        ' ' => '\\20 ',
148    ];
149
150    protected $env;
151
152    protected function setUp()
153    {
154        $this->env = new \Twig\Environment($this->getMockBuilder('\Twig\Loader\LoaderInterface')->getMock());
155    }
156
157    public function testHtmlEscapingConvertsSpecialChars()
158    {
159        foreach ($this->htmlSpecialChars as $key => $value) {
160            $this->assertEquals($value, twig_escape_filter($this->env, $key, 'html'), 'Failed to escape: '.$key);
161        }
162    }
163
164    public function testHtmlAttributeEscapingConvertsSpecialChars()
165    {
166        foreach ($this->htmlAttrSpecialChars as $key => $value) {
167            $this->assertEquals($value, twig_escape_filter($this->env, $key, 'html_attr'), 'Failed to escape: '.$key);
168        }
169    }
170
171    public function testJavascriptEscapingConvertsSpecialChars()
172    {
173        foreach ($this->jsSpecialChars as $key => $value) {
174            $this->assertEquals($value, twig_escape_filter($this->env, $key, 'js'), 'Failed to escape: '.$key);
175        }
176    }
177
178    public function testJavascriptEscapingReturnsStringIfZeroLength()
179    {
180        $this->assertEquals('', twig_escape_filter($this->env, '', 'js'));
181    }
182
183    public function testJavascriptEscapingReturnsStringIfContainsOnlyDigits()
184    {
185        $this->assertEquals('123', twig_escape_filter($this->env, '123', 'js'));
186    }
187
188    public function testCssEscapingConvertsSpecialChars()
189    {
190        foreach ($this->cssSpecialChars as $key => $value) {
191            $this->assertEquals($value, twig_escape_filter($this->env, $key, 'css'), 'Failed to escape: '.$key);
192        }
193    }
194
195    public function testCssEscapingReturnsStringIfZeroLength()
196    {
197        $this->assertEquals('', twig_escape_filter($this->env, '', 'css'));
198    }
199
200    public function testCssEscapingReturnsStringIfContainsOnlyDigits()
201    {
202        $this->assertEquals('123', twig_escape_filter($this->env, '123', 'css'));
203    }
204
205    public function testUrlEscapingConvertsSpecialChars()
206    {
207        foreach ($this->urlSpecialChars as $key => $value) {
208            $this->assertEquals($value, twig_escape_filter($this->env, $key, 'url'), 'Failed to escape: '.$key);
209        }
210    }
211
212    /**
213     * Range tests to confirm escaped range of characters is within OWASP recommendation.
214     */
215
216    /**
217     * Only testing the first few 2 ranges on this prot. function as that's all these
218     * other range tests require.
219     */
220    public function testUnicodeCodepointConversionToUtf8()
221    {
222        $expected = ' ~ޙ';
223        $codepoints = [0x20, 0x7e, 0x799];
224        $result = '';
225        foreach ($codepoints as $value) {
226            $result .= $this->codepointToUtf8($value);
227        }
228        $this->assertEquals($expected, $result);
229    }
230
231    /**
232     * Convert a Unicode Codepoint to a literal UTF-8 character.
233     *
234     * @param int $codepoint Unicode codepoint in hex notation
235     *
236     * @return string UTF-8 literal string
237     */
238    protected function codepointToUtf8($codepoint)
239    {
240        if ($codepoint < 0x80) {
241            return \chr($codepoint);
242        }
243        if ($codepoint < 0x800) {
244            return \chr($codepoint >> 6 & 0x3f | 0xc0)
245                .\chr($codepoint & 0x3f | 0x80);
246        }
247        if ($codepoint < 0x10000) {
248            return \chr($codepoint >> 12 & 0x0f | 0xe0)
249                .\chr($codepoint >> 6 & 0x3f | 0x80)
250                .\chr($codepoint & 0x3f | 0x80);
251        }
252        if ($codepoint < 0x110000) {
253            return \chr($codepoint >> 18 & 0x07 | 0xf0)
254                .\chr($codepoint >> 12 & 0x3f | 0x80)
255                .\chr($codepoint >> 6 & 0x3f | 0x80)
256                .\chr($codepoint & 0x3f | 0x80);
257        }
258        throw new \Exception('Codepoint requested outside of Unicode range.');
259    }
260
261    public function testJavascriptEscapingEscapesOwaspRecommendedRanges()
262    {
263        $immune = [',', '.', '_']; // Exceptions to escaping ranges
264        for ($chr = 0; $chr < 0xFF; ++$chr) {
265            if ($chr >= 0x30 && $chr <= 0x39
266            || $chr >= 0x41 && $chr <= 0x5A
267            || $chr >= 0x61 && $chr <= 0x7A) {
268                $literal = $this->codepointToUtf8($chr);
269                $this->assertEquals($literal, twig_escape_filter($this->env, $literal, 'js'));
270            } else {
271                $literal = $this->codepointToUtf8($chr);
272                if (\in_array($literal, $immune)) {
273                    $this->assertEquals($literal, twig_escape_filter($this->env, $literal, 'js'));
274                } else {
275                    $this->assertNotEquals(
276                        $literal,
277                        twig_escape_filter($this->env, $literal, 'js'),
278                        "$literal should be escaped!");
279                }
280            }
281        }
282    }
283
284    public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges()
285    {
286        $immune = [',', '.', '-', '_']; // Exceptions to escaping ranges
287        for ($chr = 0; $chr < 0xFF; ++$chr) {
288            if ($chr >= 0x30 && $chr <= 0x39
289            || $chr >= 0x41 && $chr <= 0x5A
290            || $chr >= 0x61 && $chr <= 0x7A) {
291                $literal = $this->codepointToUtf8($chr);
292                $this->assertEquals($literal, twig_escape_filter($this->env, $literal, 'html_attr'));
293            } else {
294                $literal = $this->codepointToUtf8($chr);
295                if (\in_array($literal, $immune)) {
296                    $this->assertEquals($literal, twig_escape_filter($this->env, $literal, 'html_attr'));
297                } else {
298                    $this->assertNotEquals(
299                        $literal,
300                        twig_escape_filter($this->env, $literal, 'html_attr'),
301                        "$literal should be escaped!");
302                }
303            }
304        }
305    }
306
307    public function testCssEscapingEscapesOwaspRecommendedRanges()
308    {
309        // CSS has no exceptions to escaping ranges
310        for ($chr = 0; $chr < 0xFF; ++$chr) {
311            if ($chr >= 0x30 && $chr <= 0x39
312            || $chr >= 0x41 && $chr <= 0x5A
313            || $chr >= 0x61 && $chr <= 0x7A) {
314                $literal = $this->codepointToUtf8($chr);
315                $this->assertEquals($literal, twig_escape_filter($this->env, $literal, 'css'));
316            } else {
317                $literal = $this->codepointToUtf8($chr);
318                $this->assertNotEquals(
319                    $literal,
320                    twig_escape_filter($this->env, $literal, 'css'),
321                    "$literal should be escaped!");
322            }
323        }
324    }
325}
326