wiki.techinc.nl/tests/phpunit/includes/parser/SanitizerTest.php

374 lines
12 KiB
PHP
Raw Normal View History

<?php
use MediaWiki\MainConfigNames;
use Wikimedia\TestingAccessWrapper;
/**
* @group Sanitizer
*/
class SanitizerTest extends MediaWikiIntegrationTestCase {
/**
* @covers Sanitizer::removeHTMLtags
* @dataProvider provideHtml5Tags
*
* @param string $tag Name of an HTML5 element (ie: 'video')
* @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '&lt;video&gt;')
*/
public function testRemovehtmltagsOnHtml5Tags( $tag, $escaped ) {
$this->hideDeprecated( Sanitizer::class . '::removeHTMLtags' );
if ( $escaped ) {
$this->assertEquals( "&lt;$tag&gt;",
Sanitizer::removeHTMLtags( "<$tag>" )
);
} else {
$this->assertEquals( "<$tag></$tag>\n",
Sanitizer::removeHTMLtags( "<$tag></$tag>\n" )
);
}
}
/**
* @covers Sanitizer::internalRemoveHTMLtags
* @dataProvider provideHtml5Tags
*
* @param string $tag Name of an HTML5 element (ie: 'video')
* @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '&lt;video&gt;')
*/
public function testInternalRemoveHtmlTagsOnHtml5Tags( $tag, $escaped ) {
if ( $escaped ) {
$this->assertEquals( "&lt;$tag&gt;",
Sanitizer::internalRemoveHtmlTags( "<$tag>" )
);
} else {
$this->assertEquals( "<$tag></$tag>\n",
Sanitizer::internalRemoveHtmlTags( "<$tag></$tag>\n" )
);
}
}
Add Sanitizer::removeSomeTags() which uses Remex to tokenize The existing Sanitizer::removeHTMLtags() method, in addition to having dodgy capitalization, uses regular expressions to parse the HTML. That produces corner cases like T298401 and T67747 and is not guaranteed to yield balanced or well-formed HTML. Instead, introduce and use a new Sanitizer::removeSomeTags() method which is guaranteed to always return balanced and well-formed HTML. Note that Sanitizer::removeHTMLtags()/::removeSomeTags() take a callback argument which (as far as I can tell) is never used outside core. Mark that argument as @internal, and clean up the version used by ::removeSomeTags(). Use the new ::removeSomeTags() method in the two places where DISPLAYTITLE is handled (following up on T67747). The use by the legacy parser is more difficult to replace (and would have a performace cost), so leave the old ::removeHTMLtags() method in place for that call site for now: when the legacy parser is replaced by Parsoid the need for the old ::removeHTMLtags() will go away. In a follow-up patch we'll rename ::removeHTMLtags() and mark it @internal so that we can deprecate ::removeHTMLtags() for external use. Some benchmarking code added. On my machine, with PHP 7.4, the new method tidies short 30-character title strings at a rate of about 6764/s while the tidy-based method being replaced here managed 6384/s. Sanitizer::removeHTMLtags blazes through short strings 20x faster (120,915/s); some of this difference is due to the set up cost of creating the tag whitelist and the Remex pipeline, so further optimizations could doubtless be done if Sanitizer::removeSomeTags() is more widely used. Bug: T299722 Bug: T67747 Change-Id: Ic864c01471c292f11799c4fbdac4d7d30b8bc50f
2022-01-21 22:03:26 +00:00
/**
* @covers Sanitizer::removeSomeTags
* @dataProvider provideHtml5Tags
*
* @param string $tag Name of an HTML5 element (ie: 'video')
* @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '&lt;video&gt;')
*/
public function testRemoveSomeTagsOnHtml5Tags( $tag, $escaped ) {
if ( $escaped ) {
$this->assertEquals( "&lt;$tag&gt;",
Sanitizer::removeSomeTags( "<$tag>" )
);
} else {
$this->assertEquals( "<$tag></$tag>\n",
Sanitizer::removeSomeTags( "<$tag></$tag>\n" )
);
$this->assertEquals( "<$tag></$tag>",
Sanitizer::removeSomeTags( "<$tag>" )
);
}
}
public static function provideHtml5Tags() {
$ESCAPED = true; # We want tag to be escaped
$VERBATIM = false; # We want to keep the tag
return [
[ 'data', $VERBATIM ],
[ 'mark', $VERBATIM ],
[ 'time', $VERBATIM ],
[ 'video', $ESCAPED ],
];
}
public function dataRemoveHTMLtags() {
return [
// former testSelfClosingTag
[
'<div>Hello world</div />',
'<div>Hello world</div>',
'Self-closing closing div'
],
// Make sure special nested HTML5 semantics are not broken
// https://html.spec.whatwg.org/multipage/semantics.html#the-kbd-element
[
'<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>',
'<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>',
'Nested <kbd>.'
],
// https://html.spec.whatwg.org/multipage/semantics.html#the-sub-and-sup-elements
[
'<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>',
'<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>',
'Nested <var>.'
],
// https://html.spec.whatwg.org/multipage/semantics.html#the-dfn-element
[
'<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>',
'<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>',
'<abbr> inside <dfn>',
],
];
}
/**
* @dataProvider dataRemoveHTMLtags
* @covers Sanitizer::removeHTMLtags
*/
public function testRemoveHTMLtags( $input, $output, $msg = null ) {
$this->hideDeprecated( Sanitizer::class . '::removeHTMLtags' );
$this->assertEquals( $output, Sanitizer::removeHTMLtags( $input ), $msg );
}
/**
* @dataProvider dataRemoveHTMLtags
* @covers Sanitizer::internalRemoveHtmlTags
*/
public function testInternalRemoveHTMLtags( $input, $output, $msg = null ) {
$this->assertEquals( $output, Sanitizer::internalRemoveHtmlTags( $input ), $msg );
}
Add Sanitizer::removeSomeTags() which uses Remex to tokenize The existing Sanitizer::removeHTMLtags() method, in addition to having dodgy capitalization, uses regular expressions to parse the HTML. That produces corner cases like T298401 and T67747 and is not guaranteed to yield balanced or well-formed HTML. Instead, introduce and use a new Sanitizer::removeSomeTags() method which is guaranteed to always return balanced and well-formed HTML. Note that Sanitizer::removeHTMLtags()/::removeSomeTags() take a callback argument which (as far as I can tell) is never used outside core. Mark that argument as @internal, and clean up the version used by ::removeSomeTags(). Use the new ::removeSomeTags() method in the two places where DISPLAYTITLE is handled (following up on T67747). The use by the legacy parser is more difficult to replace (and would have a performace cost), so leave the old ::removeHTMLtags() method in place for that call site for now: when the legacy parser is replaced by Parsoid the need for the old ::removeHTMLtags() will go away. In a follow-up patch we'll rename ::removeHTMLtags() and mark it @internal so that we can deprecate ::removeHTMLtags() for external use. Some benchmarking code added. On my machine, with PHP 7.4, the new method tidies short 30-character title strings at a rate of about 6764/s while the tidy-based method being replaced here managed 6384/s. Sanitizer::removeHTMLtags blazes through short strings 20x faster (120,915/s); some of this difference is due to the set up cost of creating the tag whitelist and the Remex pipeline, so further optimizations could doubtless be done if Sanitizer::removeSomeTags() is more widely used. Bug: T299722 Bug: T67747 Change-Id: Ic864c01471c292f11799c4fbdac4d7d30b8bc50f
2022-01-21 22:03:26 +00:00
/**
* @dataProvider dataRemoveHTMLtags
* @covers Sanitizer::removeSomeTags
*/
public function testRemoveSomeTags( $input, $output, $msg = null ) {
$this->assertEquals( $output, Sanitizer::removeSomeTags( $input ), $msg );
}
/**
* @dataProvider provideDeprecatedAttributes
* @covers Sanitizer::fixTagAttributes
* @covers Sanitizer::validateTagAttributes
* @covers Sanitizer::validateAttributes
*/
public function testDeprecatedAttributesUnaltered( $inputAttr, $inputEl, $message = '' ) {
$this->assertEquals( " $inputAttr",
Sanitizer::fixTagAttributes( $inputAttr, $inputEl ),
$message
);
Clean and repair many phpunit tests (+ fix implied configuration) This commit depends on the introduction of MediaWikiTestCase::setMwGlobals in change Iccf6ea81f4. Various tests already set their globals, but forgot to restore them afterwards, or forgot to call the parent setUp, tearDown... Either way they won't have to anymore with setMwGlobals. Consistent use of function characteristics: * protected function setUp * protected function tearDown * public static function (provide..) (Matching the function signature with PHPUnit/Framework/TestCase.php) Replaces: * public function (setUp|tearDown)\( * protected function $1( * \tfunction (setUp|tearDown)\( * \tprotected function $1( * \tfunction (data|provide)\( * \tpublic static function $1\( Also renamed a few "data#", "provider#" and "provides#" functions to "provide#" for consistency. This also removes confusion where the /media tests had a few private methods called dataFile(), which were sometimes expected to be data providers. Fixes: TimestampTest often failed due to a previous test setting a different language (it tests "1 hour ago" so need to make sure it is set to English). MWNamespaceTest became a lot cleaner now that it executes with a known context. Though the now-redundant code that was removed didn't work anyway because wgContentNamespaces isn't keyed by namespace id, it had them was values... FileBackendTest: * Fixed: "PHP Fatal: Using $this when not in object context" HttpTest * Added comment about: "PHP Fatal: Call to protected MWHttpRequest::__construct()" (too much unrelated code to fix in this commit) ExternalStoreTest * Add an assertTrue as well, without it the test is useless because regardless of whether wgExternalStores is true or false it only uses it if it is an array. Change-Id: I9d2b148e57bada64afeb7d5a99bec0e58f8e1561
2012-10-08 10:56:20 +00:00
}
public static function provideDeprecatedAttributes() {
/** [ <attribute>, <element>, [message] ] */
return [
[ 'clear="left"', 'br' ],
[ 'clear="all"', 'br' ],
[ 'width="100"', 'td' ],
[ 'nowrap="true"', 'td' ],
[ 'nowrap=""', 'td' ],
[ 'align="right"', 'td' ],
[ 'align="center"', 'table' ],
[ 'align="left"', 'tr' ],
[ 'align="center"', 'div' ],
[ 'align="left"', 'h1' ],
[ 'align="left"', 'p' ],
];
}
/**
* @dataProvider provideValidateTagAttributes
* @covers Sanitizer::validateTagAttributes
* @covers Sanitizer::validateAttributes
*/
public function testValidateTagAttributes( $element, $attribs, $expected ) {
$actual = Sanitizer::validateTagAttributes( $attribs, $element );
$this->assertArrayEquals( $expected, $actual, false, true );
}
public static function provideValidateTagAttributes() {
return [
[ 'math',
[ 'id' => 'foo bar', 'bogus' => 'stripped', 'data-foo' => 'bar' ],
[ 'id' => 'foo_bar', 'data-foo' => 'bar' ],
],
[ 'meta',
[ 'id' => 'foo bar', 'itemprop' => 'foo', 'content' => 'bar' ],
[ 'itemprop' => 'foo', 'content' => 'bar' ],
],
[ 'div',
[ 'role' => 'presentation', 'aria-hidden' => 'true' ],
[ 'role' => 'presentation', 'aria-hidden' => 'true' ],
],
[ 'div',
[ 'role' => 'menuitem', 'aria-hidden' => 'false' ],
[ 'role' => 'menuitem', 'aria-hidden' => 'false' ],
],
];
}
/**
* @dataProvider provideAttributesAllowed
* @covers Sanitizer::attributesAllowedInternal
*/
public function testAttributesAllowedInternal( $element, $attribs ) {
$sanitizer = TestingAccessWrapper::newFromClass( Sanitizer::class );
$actual = $sanitizer->attributesAllowedInternal( $element );
$this->assertArrayEquals( $attribs, array_keys( $actual ) );
}
public static function provideAttributesAllowed() {
/** [ <element>, [ <good attribute 1>, <good attribute 2>, ...] ] */
return [
[ 'math', [ 'class', 'style', 'id', 'title' ] ],
[ 'meta', [ 'itemprop', 'content' ] ],
[ 'link', [ 'itemprop', 'href', 'title' ] ],
];
}
/**
* @dataProvider provideEscapeIdForStuff
*
* @covers Sanitizer::escapeIdForAttribute()
* @covers Sanitizer::escapeIdForLink()
* @covers Sanitizer::escapeIdForExternalInterwiki()
* @covers Sanitizer::escapeIdInternal()
* @covers Sanitizer::escapeIdInternalUrl()
*
* @param string $stuff
* @param string[] $config
* @param string $id
* @param string|false $expected
* @param int|null $mode
*/
public function testEscapeIdForStuff( $stuff, array $config, $id, $expected, $mode = null ) {
$func = "Sanitizer::escapeIdFor{$stuff}";
$iwFlavor = array_pop( $config );
$this->overrideConfigValues( [
MainConfigNames::FragmentMode => $config,
MainConfigNames::ExternalInterwikiFragmentMode => $iwFlavor,
] );
$escaped = $func( $id, $mode );
self::assertEquals( $expected, $escaped );
}
public static function provideEscapeIdForStuff() {
// Test inputs and outputs
$text = 'foo тест_#%!\'()[]:<>&&amp;&amp;amp;%F0';
$legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E' .
'.26.26amp.3B.26amp.3Bamp.3B.25F0';
$html5EncodedId = 'foo_тест_#%!\'()[]:<>&&amp;&amp;amp;%F0';
$html5EncodedHref = 'foo_тест_#%!\'()[]:<>&&amp;&amp;amp;%25F0';
// Settings: last element is $wgExternalInterwikiFragmentMode, the rest is $wgFragmentMode
$legacy = [ 'legacy', 'legacy' ];
$legacyNew = [ 'legacy', 'html5', 'legacy' ];
$newLegacy = [ 'html5', 'legacy', 'legacy' ];
$new = [ 'html5', 'legacy' ];
$allNew = [ 'html5', 'html5' ];
return [
// Pure legacy: how MW worked before 2017
[ 'Attribute', $legacy, $text, $legacyEncoded, Sanitizer::ID_PRIMARY ],
[ 'Attribute', $legacy, $text, false, Sanitizer::ID_FALLBACK ],
[ 'Link', $legacy, $text, $legacyEncoded ],
[ 'ExternalInterwiki', $legacy, $text, $legacyEncoded ],
// Transition to a new world: legacy links with HTML5 fallback
[ 'Attribute', $legacyNew, $text, $legacyEncoded, Sanitizer::ID_PRIMARY ],
[ 'Attribute', $legacyNew, $text, $html5EncodedId, Sanitizer::ID_FALLBACK ],
[ 'Link', $legacyNew, $text, $legacyEncoded ],
[ 'ExternalInterwiki', $legacyNew, $text, $legacyEncoded ],
// New world: HTML5 links, legacy fallbacks
[ 'Attribute', $newLegacy, $text, $html5EncodedId, Sanitizer::ID_PRIMARY ],
[ 'Attribute', $newLegacy, $text, $legacyEncoded, Sanitizer::ID_FALLBACK ],
[ 'Link', $newLegacy, $text, $html5EncodedHref ],
[ 'ExternalInterwiki', $newLegacy, $text, $legacyEncoded ],
// Distant future: no legacy fallbacks, but still linking to leagacy wikis
[ 'Attribute', $new, $text, $html5EncodedId, Sanitizer::ID_PRIMARY ],
[ 'Attribute', $new, $text, false, Sanitizer::ID_FALLBACK ],
[ 'Link', $new, $text, $html5EncodedHref ],
[ 'ExternalInterwiki', $new, $text, $legacyEncoded ],
// Just before the heat death of universe: external interwikis are also HTML5 \m/
[ 'Attribute', $allNew, $text, $html5EncodedId, Sanitizer::ID_PRIMARY ],
[ 'Attribute', $allNew, $text, false, Sanitizer::ID_FALLBACK ],
[ 'Link', $allNew, $text, $html5EncodedHref ],
[ 'ExternalInterwiki', $allNew, $text, $html5EncodedHref ],
// Whitespace
[ 'attribute', $allNew, "foo bar", 'foo_bar', Sanitizer::ID_PRIMARY ],
[ 'attribute', $allNew, "foo\fbar", 'foo_bar', Sanitizer::ID_PRIMARY ],
[ 'attribute', $allNew, "foo\nbar", 'foo_bar', Sanitizer::ID_PRIMARY ],
[ 'attribute', $allNew, "foo\tbar", 'foo_bar', Sanitizer::ID_PRIMARY ],
[ 'attribute', $allNew, "foo\rbar", 'foo_bar', Sanitizer::ID_PRIMARY ],
];
}
/**
* @covers Sanitizer::escapeIdInternal()
*/
public function testInvalidFragmentThrows() {
$this->overrideConfigValue( MainConfigNames::FragmentMode, [ 'boom!' ] );
$this->expectException( InvalidArgumentException::class );
Sanitizer::escapeIdForAttribute( 'This should throw' );
}
/**
* @covers Sanitizer::escapeIdForAttribute()
*/
public function testNoPrimaryFragmentModeThrows() {
$this->overrideConfigValue( MainConfigNames::FragmentMode, [ 666 => 'html5' ] );
$this->expectException( UnexpectedValueException::class );
Sanitizer::escapeIdForAttribute( 'This should throw' );
}
/**
* @covers Sanitizer::escapeIdForLink()
*/
public function testNoPrimaryFragmentModeThrows2() {
$this->overrideConfigValue( MainConfigNames::FragmentMode, [ 666 => 'html5' ] );
$this->expectException( UnexpectedValueException::class );
Sanitizer::escapeIdForLink( 'This should throw' );
}
/**
* Test escapeIdReferenceList for consistency with escapeIdForAttribute
*
* @dataProvider provideEscapeIdReferenceList
* @covers Sanitizer::escapeIdReferenceList
*/
public function testEscapeIdReferenceList( $referenceList, $id1, $id2 ) {
$this->hideDeprecated( 'Sanitizer::escapeIdReferenceList' );
$this->assertEquals(
Sanitizer::escapeIdReferenceList( $referenceList ),
Sanitizer::escapeIdForAttribute( $id1 )
. ' '
. Sanitizer::escapeIdForAttribute( $id2 )
);
}
public static function provideEscapeIdReferenceList() {
/** [ <reference list>, <individual id 1>, <individual id 2> ] */
return [
[ 'foo bar', 'foo', 'bar' ],
[ '#1 #2', '#1', '#2' ],
[ '+1 +2', '+1', '+2' ],
];
}
/**
* Test cleanUrl
*
* @dataProvider provideCleanUrl
* @covers Sanitizer::cleanUrl
*/
public function testCleanUrl( string $input, string $output ) {
$this->assertEquals( $output, Sanitizer::cleanUrl( $input ) );
}
public static function provideCleanUrl() {
return [
[ 'http://www.example.com/file.txt', 'http://www.example.com/file.txt' ],
[
"https://www.exa\u{00AD}\u{200B}\u{2060}\u{FEFF}" .
"\u{034F}\u{180B}\u{180C}\u{180D}\u{200C}\u{200D}" .
"\u{FE00}\u{FE08}\u{FE0F}mple.com",
'https://www.example.com'
],
];
}
}