Merge "Add class mw-editable in body element"
[lhc/web/wiklou.git] / tests / phpunit / includes / parser / SanitizerTest.php
1 <?php
2
3 /**
4 * @todo Tests covering decodeCharReferences can be refactored into a single
5 * method and dataprovider.
6 *
7 * @group Sanitizer
8 */
9 class SanitizerTest extends MediaWikiTestCase {
10
11 protected function tearDown() {
12 MWTidy::destroySingleton();
13 parent::tearDown();
14 }
15
16 /**
17 * @covers Sanitizer::decodeCharReferences
18 */
19 public function testDecodeNamedEntities() {
20 $this->assertEquals(
21 "\xc3\xa9cole",
22 Sanitizer::decodeCharReferences( '&eacute;cole' ),
23 'decode named entities'
24 );
25 }
26
27 /**
28 * @covers Sanitizer::decodeCharReferences
29 */
30 public function testDecodeNumericEntities() {
31 $this->assertEquals(
32 "\xc4\x88io bonas dans l'\xc3\xa9cole!",
33 Sanitizer::decodeCharReferences( "&#x108;io bonas dans l'&#233;cole!" ),
34 'decode numeric entities'
35 );
36 }
37
38 /**
39 * @covers Sanitizer::decodeCharReferences
40 */
41 public function testDecodeMixedEntities() {
42 $this->assertEquals(
43 "\xc4\x88io bonas dans l'\xc3\xa9cole!",
44 Sanitizer::decodeCharReferences( "&#x108;io bonas dans l'&eacute;cole!" ),
45 'decode mixed numeric/named entities'
46 );
47 }
48
49 /**
50 * @covers Sanitizer::decodeCharReferences
51 */
52 public function testDecodeMixedComplexEntities() {
53 $this->assertEquals(
54 "\xc4\x88io bonas dans l'\xc3\xa9cole! (mais pas &#x108;io dans l'&eacute;cole)",
55 Sanitizer::decodeCharReferences(
56 "&#x108;io bonas dans l'&eacute;cole! (mais pas &amp;#x108;io dans l'&#38;eacute;cole)"
57 ),
58 'decode mixed complex entities'
59 );
60 }
61
62 /**
63 * @covers Sanitizer::decodeCharReferences
64 */
65 public function testInvalidAmpersand() {
66 $this->assertEquals(
67 'a & b',
68 Sanitizer::decodeCharReferences( 'a & b' ),
69 'Invalid ampersand'
70 );
71 }
72
73 /**
74 * @covers Sanitizer::decodeCharReferences
75 */
76 public function testInvalidEntities() {
77 $this->assertEquals(
78 '&foo;',
79 Sanitizer::decodeCharReferences( '&foo;' ),
80 'Invalid named entity'
81 );
82 }
83
84 /**
85 * @covers Sanitizer::decodeCharReferences
86 */
87 public function testInvalidNumberedEntities() {
88 $this->assertEquals(
89 UtfNormal\Constants::UTF8_REPLACEMENT,
90 Sanitizer::decodeCharReferences( "&#88888888888888;" ),
91 'Invalid numbered entity'
92 );
93 }
94
95 /**
96 * @covers Sanitizer::removeHTMLtags
97 * @dataProvider provideHtml5Tags
98 *
99 * @param string $tag Name of an HTML5 element (ie: 'video')
100 * @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '&lt;video&gt;')
101 */
102 public function testRemovehtmltagsOnHtml5Tags( $tag, $escaped ) {
103 $this->hideDeprecated( 'disabling tidy' );
104 MWTidy::setInstance( false );
105
106 if ( $escaped ) {
107 $this->assertEquals( "&lt;$tag&gt;",
108 Sanitizer::removeHTMLtags( "<$tag>" )
109 );
110 } else {
111 $this->assertEquals( "<$tag></$tag>\n",
112 Sanitizer::removeHTMLtags( "<$tag>" )
113 );
114 }
115 }
116
117 /**
118 * Provide HTML5 tags
119 */
120 public static function provideHtml5Tags() {
121 $ESCAPED = true; # We want tag to be escaped
122 $VERBATIM = false; # We want to keep the tag
123 return [
124 [ 'data', $VERBATIM ],
125 [ 'mark', $VERBATIM ],
126 [ 'time', $VERBATIM ],
127 [ 'video', $ESCAPED ],
128 ];
129 }
130
131 function dataRemoveHTMLtags() {
132 return [
133 // former testSelfClosingTag
134 [
135 '<div>Hello world</div />',
136 '<div>Hello world</div>',
137 'Self-closing closing div'
138 ],
139 // Make sure special nested HTML5 semantics are not broken
140 // https://html.spec.whatwg.org/multipage/semantics.html#the-kbd-element
141 [
142 '<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>',
143 '<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>',
144 'Nested <kbd>.'
145 ],
146 // https://html.spec.whatwg.org/multipage/semantics.html#the-sub-and-sup-elements
147 [
148 '<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>',
149 '<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>',
150 'Nested <var>.'
151 ],
152 // https://html.spec.whatwg.org/multipage/semantics.html#the-dfn-element
153 [
154 '<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>',
155 '<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>',
156 '<abbr> inside <dfn>',
157 ],
158 ];
159 }
160
161 /**
162 * @dataProvider dataRemoveHTMLtags
163 * @covers Sanitizer::removeHTMLtags
164 */
165 public function testRemoveHTMLtags( $input, $output, $msg = null ) {
166 $this->hideDeprecated( 'disabling tidy' );
167 MWTidy::setInstance( false );
168 $this->assertEquals( $output, Sanitizer::removeHTMLtags( $input ), $msg );
169 }
170
171 /**
172 * @dataProvider provideTagAttributesToDecode
173 * @covers Sanitizer::decodeTagAttributes
174 */
175 public function testDecodeTagAttributes( $expected, $attributes, $message = '' ) {
176 $this->assertEquals( $expected,
177 Sanitizer::decodeTagAttributes( $attributes ),
178 $message
179 );
180 }
181
182 public static function provideTagAttributesToDecode() {
183 return [
184 [ [ 'foo' => 'bar' ], 'foo=bar', 'Unquoted attribute' ],
185 [ [ 'עברית' => 'bar' ], 'עברית=bar', 'Non-Latin attribute' ],
186 [ [ '६' => 'bar' ], '६=bar', 'Devanagari number' ],
187 [ [ '搭𨋢' => 'bar' ], '搭𨋢=bar', 'Non-BMP character' ],
188 [ [], 'ńgh=bar', 'Combining accent is not allowed' ],
189 [ [ 'foo' => 'bar' ], ' foo = bar ', 'Spaced attribute' ],
190 [ [ 'foo' => 'bar' ], 'foo="bar"', 'Double-quoted attribute' ],
191 [ [ 'foo' => 'bar' ], 'foo=\'bar\'', 'Single-quoted attribute' ],
192 [
193 [ 'foo' => 'bar', 'baz' => 'foo' ],
194 'foo=\'bar\' baz="foo"',
195 'Several attributes'
196 ],
197 [
198 [ 'foo' => 'bar', 'baz' => 'foo' ],
199 'foo=\'bar\' baz="foo"',
200 'Several attributes'
201 ],
202 [
203 [ 'foo' => 'bar', 'baz' => 'foo' ],
204 'foo=\'bar\' baz="foo"',
205 'Several attributes'
206 ],
207 [ [ ':foo' => 'bar' ], ':foo=\'bar\'', 'Leading :' ],
208 [ [ '_foo' => 'bar' ], '_foo=\'bar\'', 'Leading _' ],
209 [ [ 'foo' => 'bar' ], 'Foo=\'bar\'', 'Leading capital' ],
210 [ [ 'foo' => 'BAR' ], 'FOO=BAR', 'Attribute keys are normalized to lowercase' ],
211
212 # Invalid beginning
213 [ [], '-foo=bar', 'Leading - is forbidden' ],
214 [ [], '.foo=bar', 'Leading . is forbidden' ],
215 [ [ 'foo-bar' => 'bar' ], 'foo-bar=bar', 'A - is allowed inside the attribute' ],
216 [ [ 'foo-' => 'bar' ], 'foo-=bar', 'A - is allowed inside the attribute' ],
217 [ [ 'foo.bar' => 'baz' ], 'foo.bar=baz', 'A . is allowed inside the attribute' ],
218 [ [ 'foo.' => 'baz' ], 'foo.=baz', 'A . is allowed as last character' ],
219 [ [ 'foo6' => 'baz' ], 'foo6=baz', 'Numbers are allowed' ],
220
221 # This bit is more relaxed than XML rules, but some extensions use
222 # it, like ProofreadPage (see T29539)
223 [ [ '1foo' => 'baz' ], '1foo=baz', 'Leading numbers are allowed' ],
224 [ [], 'foo$=baz', 'Symbols are not allowed' ],
225 [ [], 'foo@=baz', 'Symbols are not allowed' ],
226 [ [], 'foo~=baz', 'Symbols are not allowed' ],
227 [
228 [ 'foo' => '1[#^`*%w/(' ],
229 'foo=1[#^`*%w/(',
230 'All kind of characters are allowed as values'
231 ],
232 [
233 [ 'foo' => '1[#^`*%\'w/(' ],
234 'foo="1[#^`*%\'w/("',
235 'Double quotes are allowed if quoted by single quotes'
236 ],
237 [
238 [ 'foo' => '1[#^`*%"w/(' ],
239 'foo=\'1[#^`*%"w/(\'',
240 'Single quotes are allowed if quoted by double quotes'
241 ],
242 [ [ 'foo' => '&"' ], 'foo=&amp;&quot;', 'Special chars can be provided as entities' ],
243 [ [ 'foo' => '&foobar;' ], 'foo=&foobar;', 'Entity-like items are accepted' ],
244 ];
245 }
246
247 /**
248 * @dataProvider provideDeprecatedAttributes
249 * @covers Sanitizer::fixTagAttributes
250 */
251 public function testDeprecatedAttributesUnaltered( $inputAttr, $inputEl, $message = '' ) {
252 $this->assertEquals( " $inputAttr",
253 Sanitizer::fixTagAttributes( $inputAttr, $inputEl ),
254 $message
255 );
256 }
257
258 public static function provideDeprecatedAttributes() {
259 /** [ <attribute>, <element>, [message] ] */
260 return [
261 [ 'clear="left"', 'br' ],
262 [ 'clear="all"', 'br' ],
263 [ 'width="100"', 'td' ],
264 [ 'nowrap="true"', 'td' ],
265 [ 'nowrap=""', 'td' ],
266 [ 'align="right"', 'td' ],
267 [ 'align="center"', 'table' ],
268 [ 'align="left"', 'tr' ],
269 [ 'align="center"', 'div' ],
270 [ 'align="left"', 'h1' ],
271 [ 'align="left"', 'p' ],
272 ];
273 }
274
275 /**
276 * @dataProvider provideCssCommentsFixtures
277 * @covers Sanitizer::checkCss
278 */
279 public function testCssCommentsChecking( $expected, $css, $message = '' ) {
280 $this->assertEquals( $expected,
281 Sanitizer::checkCss( $css ),
282 $message
283 );
284 }
285
286 public static function provideCssCommentsFixtures() {
287 /** [ <expected>, <css>, [message] ] */
288 return [
289 // Valid comments spanning entire input
290 [ '/**/', '/**/' ],
291 [ '/* comment */', '/* comment */' ],
292 // Weird stuff
293 [ ' ', '/****/' ],
294 [ ' ', '/* /* */' ],
295 [ 'display: block;', "display:/* foo */block;" ],
296 [ 'display: block;', "display:\\2f\\2a foo \\2a\\2f block;",
297 'Backslash-escaped comments must be stripped (T30450)' ],
298 [ '', '/* unfinished comment structure',
299 'Remove anything after a comment-start token' ],
300 [ '', "\\2f\\2a unifinished comment'",
301 'Remove anything after a backslash-escaped comment-start token' ],
302 [
303 '/* insecure input */',
304 'filter: progid:DXImageTransform.Microsoft.AlphaImageLoader'
305 . '(src=\'asdf.png\',sizingMethod=\'scale\');'
306 ],
307 [
308 '/* insecure input */',
309 '-ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader'
310 . '(src=\'asdf.png\',sizingMethod=\'scale\')";'
311 ],
312 [ '/* insecure input */', 'width: expression(1+1);' ],
313 [ '/* insecure input */', 'background-image: image(asdf.png);' ],
314 [ '/* insecure input */', 'background-image: -webkit-image(asdf.png);' ],
315 [ '/* insecure input */', 'background-image: -moz-image(asdf.png);' ],
316 [ '/* insecure input */', 'background-image: image-set("asdf.png" 1x, "asdf.png" 2x);' ],
317 [
318 '/* insecure input */',
319 'background-image: -webkit-image-set("asdf.png" 1x, "asdf.png" 2x);'
320 ],
321 [
322 '/* insecure input */',
323 'background-image: -moz-image-set("asdf.png" 1x, "asdf.png" 2x);'
324 ],
325 [ '/* insecure input */', 'foo: attr( title, url );' ],
326 [ '/* insecure input */', 'foo: attr( title url );' ],
327 ];
328 }
329
330 /**
331 * @dataProvider provideEscapeHtmlAllowEntities
332 * @covers Sanitizer::escapeHtmlAllowEntities
333 */
334 public function testEscapeHtmlAllowEntities( $expected, $html ) {
335 $this->assertEquals(
336 $expected,
337 Sanitizer::escapeHtmlAllowEntities( $html )
338 );
339 }
340
341 public static function provideEscapeHtmlAllowEntities() {
342 return [
343 [ 'foo', 'foo' ],
344 [ 'a¡b', 'a&#161;b' ],
345 [ 'foo&#039;bar', "foo'bar" ],
346 [ '&lt;script&gt;foo&lt;/script&gt;', '<script>foo</script>' ],
347 ];
348 }
349
350 /**
351 * Test Sanitizer::escapeId
352 *
353 * @dataProvider provideEscapeId
354 * @covers Sanitizer::escapeId
355 */
356 public function testEscapeId( $input, $output ) {
357 $this->assertEquals(
358 $output,
359 Sanitizer::escapeId( $input, [ 'noninitial', 'legacy' ] )
360 );
361 }
362
363 public static function provideEscapeId() {
364 return [
365 [ '+', '.2B' ],
366 [ '&', '.26' ],
367 [ '=', '.3D' ],
368 [ ':', ':' ],
369 [ ';', '.3B' ],
370 [ '@', '.40' ],
371 [ '$', '.24' ],
372 [ '-_.', '-_.' ],
373 [ '!', '.21' ],
374 [ '*', '.2A' ],
375 [ '/', '.2F' ],
376 [ '[]', '.5B.5D' ],
377 [ '<>', '.3C.3E' ],
378 [ '\'', '.27' ],
379 [ '§', '.C2.A7' ],
380 [ 'Test:A & B/Here', 'Test:A_.26_B.2FHere' ],
381 [ 'A&B&amp;C&amp;amp;D&amp;amp;amp;E', 'A.26B.26amp.3BC.26amp.3Bamp.3BD.26amp.3Bamp.3Bamp.3BE' ],
382 ];
383 }
384
385 /**
386 * Test escapeIdReferenceList for consistency with escapeIdForAttribute
387 *
388 * @dataProvider provideEscapeIdReferenceList
389 * @covers Sanitizer::escapeIdReferenceList
390 */
391 public function testEscapeIdReferenceList( $referenceList, $id1, $id2 ) {
392 $this->assertEquals(
393 Sanitizer::escapeIdReferenceList( $referenceList ),
394 Sanitizer::escapeIdForAttribute( $id1 )
395 . ' '
396 . Sanitizer::escapeIdForAttribute( $id2 )
397 );
398 }
399
400 public static function provideEscapeIdReferenceList() {
401 /** [ <reference list>, <individual id 1>, <individual id 2> ] */
402 return [
403 [ 'foo bar', 'foo', 'bar' ],
404 [ '#1 #2', '#1', '#2' ],
405 [ '+1 +2', '+1', '+2' ],
406 ];
407 }
408
409 /**
410 * @dataProvider provideIsReservedDataAttribute
411 * @covers Sanitizer::isReservedDataAttribute
412 */
413 public function testIsReservedDataAttribute( $attr, $expected ) {
414 $this->assertSame( $expected, Sanitizer::isReservedDataAttribute( $attr ) );
415 }
416
417 public static function provideIsReservedDataAttribute() {
418 return [
419 [ 'foo', false ],
420 [ 'data', false ],
421 [ 'data-foo', false ],
422 [ 'data-mw', true ],
423 [ 'data-ooui', true ],
424 [ 'data-parsoid', true ],
425 [ 'data-mw-foo', true ],
426 [ 'data-ooui-foo', true ],
427 [ 'data-mwfoo', true ], // could be false but this is how it's implemented currently
428 ];
429 }
430
431 /**
432 * @dataProvider provideEscapeIdForStuff
433 *
434 * @covers Sanitizer::escapeIdForAttribute()
435 * @covers Sanitizer::escapeIdForLink()
436 * @covers Sanitizer::escapeIdForExternalInterwiki()
437 * @covers Sanitizer::escapeIdInternal()
438 *
439 * @param string $stuff
440 * @param string[] $config
441 * @param string $id
442 * @param string|false $expected
443 * @param int|null $mode
444 */
445 public function testEscapeIdForStuff( $stuff, array $config, $id, $expected, $mode = null ) {
446 $func = "Sanitizer::escapeIdFor{$stuff}";
447 $iwFlavor = array_pop( $config );
448 $this->setMwGlobals( [
449 'wgFragmentMode' => $config,
450 'wgExternalInterwikiFragmentMode' => $iwFlavor,
451 ] );
452 $escaped = call_user_func( $func, $id, $mode );
453 self::assertEquals( $expected, $escaped );
454 }
455
456 public function provideEscapeIdForStuff() {
457 // Test inputs and outputs
458 $text = 'foo тест_#%!\'()[]:<>&&amp;&amp;amp;';
459 $legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E' .
460 '.26.26amp.3B.26amp.3Bamp.3B';
461 $html5Encoded = 'foo_тест_#%!\'()[]:<>&&amp;&amp;amp;';
462
463 // Settings: last element is $wgExternalInterwikiFragmentMode, the rest is $wgFragmentMode
464 $legacy = [ 'legacy', 'legacy' ];
465 $legacyNew = [ 'legacy', 'html5', 'legacy' ];
466 $newLegacy = [ 'html5', 'legacy', 'legacy' ];
467 $new = [ 'html5', 'legacy' ];
468 $allNew = [ 'html5', 'html5' ];
469
470 return [
471 // Pure legacy: how MW worked before 2017
472 [ 'Attribute', $legacy, $text, $legacyEncoded, Sanitizer::ID_PRIMARY ],
473 [ 'Attribute', $legacy, $text, false, Sanitizer::ID_FALLBACK ],
474 [ 'Link', $legacy, $text, $legacyEncoded ],
475 [ 'ExternalInterwiki', $legacy, $text, $legacyEncoded ],
476
477 // Transition to a new world: legacy links with HTML5 fallback
478 [ 'Attribute', $legacyNew, $text, $legacyEncoded, Sanitizer::ID_PRIMARY ],
479 [ 'Attribute', $legacyNew, $text, $html5Encoded, Sanitizer::ID_FALLBACK ],
480 [ 'Link', $legacyNew, $text, $legacyEncoded ],
481 [ 'ExternalInterwiki', $legacyNew, $text, $legacyEncoded ],
482
483 // New world: HTML5 links, legacy fallbacks
484 [ 'Attribute', $newLegacy, $text, $html5Encoded, Sanitizer::ID_PRIMARY ],
485 [ 'Attribute', $newLegacy, $text, $legacyEncoded, Sanitizer::ID_FALLBACK ],
486 [ 'Link', $newLegacy, $text, $html5Encoded ],
487 [ 'ExternalInterwiki', $newLegacy, $text, $legacyEncoded ],
488
489 // Distant future: no legacy fallbacks, but still linking to leagacy wikis
490 [ 'Attribute', $new, $text, $html5Encoded, Sanitizer::ID_PRIMARY ],
491 [ 'Attribute', $new, $text, false, Sanitizer::ID_FALLBACK ],
492 [ 'Link', $new, $text, $html5Encoded ],
493 [ 'ExternalInterwiki', $new, $text, $legacyEncoded ],
494
495 // Just before the heat death of universe: external interwikis are also HTML5 \m/
496 [ 'Attribute', $allNew, $text, $html5Encoded, Sanitizer::ID_PRIMARY ],
497 [ 'Attribute', $allNew, $text, false, Sanitizer::ID_FALLBACK ],
498 [ 'Link', $allNew, $text, $html5Encoded ],
499 [ 'ExternalInterwiki', $allNew, $text, $html5Encoded ],
500 ];
501 }
502
503 /**
504 * @dataProvider provideStripAllTags
505 *
506 * @covers Sanitizer::stripAllTags()
507 * @covers RemexStripTagHandler
508 *
509 * @param string $input
510 * @param string $expected
511 */
512 public function testStripAllTags( $input, $expected ) {
513 $this->assertEquals( $expected, Sanitizer::stripAllTags( $input ) );
514 }
515
516 public function provideStripAllTags() {
517 return [
518 [ '<p>Foo</p>', 'Foo' ],
519 [ '<p id="one">Foo</p><p id="two">Bar</p>', 'Foo Bar' ],
520 [ "<p>Foo</p>\n<p>Bar</p>", 'Foo Bar' ],
521 [ '<p>Hello &lt;strong&gt; wor&#x6c;&#100; caf&eacute;</p>', 'Hello <strong> world café' ],
522 [
523 '<p><small data-foo=\'bar"&lt;baz>quux\'><a href="./Foo">Bar</a></small> Whee!</p>',
524 'Bar Whee!'
525 ],
526 [ '1<span class="<?php">2</span>3', '123' ],
527 [ '1<span class="<?">2</span>3', '123' ],
528 ];
529 }
530
531 /**
532 * @expectedException InvalidArgumentException
533 * @covers Sanitizer::escapeIdInternal()
534 */
535 public function testInvalidFragmentThrows() {
536 $this->setMwGlobals( 'wgFragmentMode', [ 'boom!' ] );
537 Sanitizer::escapeIdForAttribute( 'This should throw' );
538 }
539
540 /**
541 * @expectedException UnexpectedValueException
542 * @covers Sanitizer::escapeIdForAttribute()
543 */
544 public function testNoPrimaryFragmentModeThrows() {
545 $this->setMwGlobals( 'wgFragmentMode', [ 666 => 'html5' ] );
546 Sanitizer::escapeIdForAttribute( 'This should throw' );
547 }
548
549 /**
550 * @expectedException UnexpectedValueException
551 * @covers Sanitizer::escapeIdForLink()
552 */
553 public function testNoPrimaryFragmentModeThrows2() {
554 $this->setMwGlobals( 'wgFragmentMode', [ 666 => 'html5' ] );
555 Sanitizer::escapeIdForLink( 'This should throw' );
556 }
557 }