* (T156983) $wgRateLimitsExcludedIPs now accepts CIDR ranges as well as single IPs.
* $wgDummyLanguageCodes is deprecated. Additional language code mappings may be
added to $wgExtraLanguageCodes instead.
+* (T161453) LocalisationCache will no longer use the temporary directory in it's
+ fallback chain when trying to work out where to write the cache.
=== New features in 1.29 ===
* (T5233) A cookie can now be set when a user is autoblocked, to track that user
* (T157035) "new mw.Uri()" was ignoring options when using default URI.
* Special:Allpages can no longer be filtered by redirect in miser mode.
* (T160519) CACHE_ANYTHING will not be CACHE_ACCEL if no accelerator is installed.
+* (T109140) (T122209) SECURITY: Special:UserLogin and Special:Search allow redirect
+ to interwiki links.
+* (T144845) SECURITY: XSS in SearchHighlighter::highlightText() when
+ $wgAdvancedSearchHighlighting is true.
+* (T125177) SECURITY: API parameters may now be marked as "sensitive" to keep
+ their values out of the logs.
+* (T150044) SECURITY: "Mark all pages visited" on the watchlist now requires a CSRF
+ token.
+* (T156184) SECURITY: Escape content model/format url parameter in message.
+* (T151735) SECURITY: SVG filter evasion using default attribute values in DTD
+ declaration.
+* (T161453) SECURITY: LocalisationCache will no longer use the temporary directory
+ in it's fallback chain when trying to work out where to write the cache.
+* (T48143) SECURITY: Spam blacklist ineffective on encoded URLs inside file inclusion
+ syntax's link parameter.
+* (T108138) SECURITY: Sysops can undelete pages, although the page is protected against
+ it.
=== Action API changes in 1.29 ===
* Submitting sensitive authentication request parameters to action=login,
various methods now take a module path rather than a module name.
* ApiMessageTrait::getApiCode() now strips 'apierror-' and 'apiwarn-' prefixes
from the message key, and maps some message keys for backwards compatibility.
+* API parameters may now be marked as "sensitive" to keep their values out of
+ the logs.
=== Languages updated in 1.29 ===
'SpecialExpandTemplates' => __DIR__ . '/includes/specials/SpecialExpandTemplates.php',
'SpecialExport' => __DIR__ . '/includes/specials/SpecialExport.php',
'SpecialFilepath' => __DIR__ . '/includes/specials/SpecialFilepath.php',
+ 'SpecialGoToInterwiki' => __DIR__ . '/includes/specials/SpecialGoToInterwiki.php',
'SpecialImport' => __DIR__ . '/includes/specials/SpecialImport.php',
'SpecialJavaScriptTest' => __DIR__ . '/includes/specials/SpecialJavaScriptTest.php',
'SpecialLinkAccounts' => __DIR__ . '/includes/specials/SpecialLinkAccounts.php',
$totalcnt = $rescnt;
$category = $this->cat;
DeferredUpdates::addCallableUpdate( function () use ( $category ) {
- $category->refreshCounts();
+ # Avoid excess contention on the same category (T162121)
+ $dbw = wfGetDB( DB_MASTER );
+ $name = __METHOD__ . ':' . md5( $this->mName );
+ $scopedLock = $dbw->getScopedLockAndFlush( $name, __METHOD__, 1 );
+ if ( $scopedLock ) {
+ $category->refreshCounts();
+ }
} );
} else {
// Case 3: hopeless. Don't give a total count at all.
throw new ErrorPageError(
'editpage-invalidcontentmodel-title',
'editpage-invalidcontentmodel-text',
- [ $this->contentModel ]
+ [ wfEscapeWikiText( $this->contentModel ) ]
);
}
throw new ErrorPageError(
'editpage-notsupportedcontentformat-title',
'editpage-notsupportedcontentformat-text',
- [ $this->contentFormat, ContentHandler::getLocalizedName( $this->contentModel ) ]
+ [
+ wfEscapeWikiText( $this->contentFormat ),
+ wfEscapeWikiText( ContentHandler::getLocalizedName( $this->contentModel ) )
+ ]
);
}
} else {
$titleObj = Title::newFromText( $returnto );
}
- if ( !is_object( $titleObj ) ) {
+ // We don't want people to return to external interwiki. That
+ // might potentially be used as part of a phishing scheme
+ if ( !is_object( $titleObj ) || $titleObj->isExternal() ) {
$titleObj = Title::newMainPage();
}
return $url;
}
+ /**
+ * Get a url appropriate for making redirects based on an untrusted url arg
+ *
+ * This is basically the same as getFullUrl(), but in the case of external
+ * interwikis, we send the user to a landing page, to prevent possible
+ * phishing attacks and the like.
+ *
+ * @note Uses current protocol by default, since technically relative urls
+ * aren't allowed in redirects per HTTP spec, so this is not suitable for
+ * places where the url gets cached, as might pollute between
+ * https and non-https users.
+ * @see self::getLocalURL for the arguments.
+ * @param array|string $query
+ * @param string $proto Protocol type to use in URL
+ * @return String. A url suitable to use in an HTTP location header.
+ */
+ public function getFullUrlForRedirect( $query = '', $proto = PROTO_CURRENT ) {
+ $target = $this;
+ if ( $this->isExternal() ) {
+ $target = SpecialPage::getTitleFor(
+ 'GoToInterwiki',
+ $this->getPrefixedDBKey()
+ );
+ }
+ return $target->getFullUrl( $query, false, $proto );
+ }
+
/**
* Get a URL with no fragment or server name (relative URL) from a Title object.
* If this page is generated with action=render, however,
) {
$errors[] = [ 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ];
}
+ } elseif ( $action === 'undelete' ) {
+ if ( count( $this->getUserPermissionsErrorsInternal( 'edit', $user, $rigor, true ) ) ) {
+ // Undeleting implies editing
+ $errors[] = [ 'undelete-cantedit' ];
+ }
+ if ( !$this->exists()
+ && count( $this->getUserPermissionsErrorsInternal( 'create', $user, $rigor, true ) )
+ ) {
+ // Undeleting where nothing currently exists implies creating
+ $errors[] = [ 'undelete-cantcreate' ];
+ }
}
return $errors;
}
$this->module->getMain()->markParamsUsed( array_keys( $data ) );
if ( $sensitive ) {
+ $this->module->getMain()->markParamsSensitive( array_keys( $sensitive ) );
$this->module->requirePostedParameters( array_keys( $sensitive ), 'noprefix' );
}
*/
const PARAM_EXTRA_NAMESPACES = 18;
+ /*
+ * (boolean) Is the parameter sensitive? Note 'password'-type fields are
+ * always sensitive regardless of the value of this field.
+ * @since 1.29
+ */
+ const PARAM_SENSITIVE = 19;
+
/**@}*/
const ALL_DEFAULT_STRING = '*';
} else {
$type = 'NULL'; // allow everything
}
+
+ if ( $type == 'password' || !empty( $paramSettings[self::PARAM_SENSITIVE] ) ) {
+ $this->getMain()->markParamsSensitive( $encParamName );
+ }
}
if ( $type == 'boolean' ) {
$params['token'] = [
ApiBase::PARAM_TYPE => 'string',
ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_SENSITIVE => true,
ApiBase::PARAM_HELP_MSG => [
'api-help-param-token',
$this->needsToken(),
'token' => [
ApiBase::PARAM_TYPE => 'string',
ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_SENSITIVE => true,
],
'maxtokenage' => [
ApiBase::PARAM_TYPE => 'integer',
'token' => [
ApiBase::PARAM_TYPE => 'string',
ApiBase::PARAM_REQUIRED => false, // for BC
+ ApiBase::PARAM_SENSITIVE => true,
ApiBase::PARAM_HELP_MSG => [ 'api-help-param-token', 'login' ],
],
];
private $mCacheMode = 'private';
private $mCacheControl = [];
private $mParamsUsed = [];
+ private $mParamsSensitive = [];
/** @var bool|null Cached return value from self::lacksSameOriginSecurity() */
private $lacksSameOriginSecurity = null;
" {$logCtx['ip']} " .
"T={$logCtx['timeSpentBackend']}ms";
+ $sensitive = array_flip( $this->getSensitiveParams() );
foreach ( $this->getParamsUsed() as $name ) {
$value = $request->getVal( $name );
if ( $value === null ) {
continue;
}
- if ( strlen( $value ) > 256 ) {
+ if ( isset( $sensitive[$name] ) ) {
+ $value = '[redacted]';
+ $encValue = '[redacted]';
+ } elseif ( strlen( $value ) > 256 ) {
$value = substr( $value, 0, 256 );
$encValue = $this->encodeRequestLogValue( $value ) . '[...]';
} else {
$this->mParamsUsed += array_fill_keys( (array)$params, true );
}
+ /**
+ * Get the request parameters that should be considered sensitive
+ * @since 1.29
+ * @return array
+ */
+ protected function getSensitiveParams() {
+ return array_keys( $this->mParamsSensitive );
+ }
+
+ /**
+ * Mark parameters as sensitive
+ * @since 1.29
+ * @param string|string[] $params
+ */
+ public function markParamsSensitive( $params ) {
+ $this->mParamsSensitive += array_fill_keys( (array)$params, true );
+ }
+
/**
* Get a request value, and register the fact that it was used, for logging.
* @param string $name
$this->addOption( 'LIMIT', $limit + 1 );
$this->addOption(
'USE INDEX',
- [ 'archive' => ( $mode == 'user' ? 'usertext_timestamp' : 'name_title_timestamp' ) ]
+ [ 'archive' => ( $mode == 'user' ? 'ar_usertext_timestamp' : 'name_title_timestamp' ) ]
);
if ( $mode == 'all' ) {
if ( $params['unique'] ) {
ApiBase::PARAM_TYPE => 'user'
],
'token' => [
- ApiBase::PARAM_TYPE => 'string'
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_SENSITIVE => true,
],
'continue' => [
ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
ApiBase::PARAM_TYPE => 'user'
],
'token' => [
- ApiBase::PARAM_TYPE => 'string'
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_SENSITIVE => true,
],
'dir' => [
ApiBase::PARAM_DFLT => 'ascending',
$this->useTransactionalTimeLimit();
$params = $this->extractRequestParams();
- $this->checkUserRightsAny( 'undelete' );
$user = $this->getUser();
if ( $user->isBlocked() ) {
$this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['title'] ) ] );
}
+ if ( !$titleObj->userCan( 'undelete', $user, 'secure' ) ) {
+ $this->dieWithError( 'permdenied-undelete' );
+ }
+
// Check if user can add tags
if ( !is_null( $params['tags'] ) ) {
$ableToTag = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
} else {
# Effectively disallows use of '/' character in NS_MEDIAWIKI for uses
# other than language code.
- $conds[] = 'page_title NOT' . $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() );
+ $conds[] = 'page_title NOT' .
+ $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() );
}
# Conditions to fetch oversized pages to ignore them
/**
* Updates cache as necessary when message page is changed
*
- * @param string $title Message cache key with initial uppercase letter.
+ * @param string $title Message cache key with initial uppercase letter
* @param string|bool $text New contents of the page (false if deleted)
*/
public function replace( $title, $text ) {
$page->loadPageData( $page::READ_LATEST );
$text = $this->getMessageTextFromContent( $page->getContent() );
// Check if an individual cache key should exist and update cache accordingly
- $titleKey = $this->wanCache->makeKey(
- 'messages-big', $this->mCache[$code]['HASH'], $title );
if ( is_string( $text ) && strlen( $text ) > $wgMaxMsgCacheEntrySize ) {
+ $titleKey = $this->bigMessageCacheKey( $this->mCache[$code]['HASH'], $title );
$this->wanCache->set( $titleKey, ' ' . $text, $this->mExpiry );
}
// Mark this cache as definitely being "latest" (non-volatile) so
* some callers require this behavior. LanguageConverter::parseCachedTable()
* and self::get() are some examples in core.
*
- * @param string $title Message cache key with initial uppercase letter.
- * @param string $code Code denoting the language to try.
+ * @param string $title Message cache key with initial uppercase letter
+ * @param string $code Code denoting the language to try
* @return string|bool The message, or false if it does not exist or on error
*/
public function getMsgFromNamespace( $title, $code ) {
return false;
}
- // Try the individual message cache
- $titleKey = $this->wanCache->makeKey( 'messages-big', $this->mCache[$code]['HASH'], $title );
+ // Individual message cache key
+ $titleKey = $this->bigMessageCacheKey( $this->mCache[$code]['HASH'], $title );
if ( $this->mCacheVolatile[$code] ) {
$entry = false;
__METHOD__ . ': loading volatile key \'{titleKey}\'',
[ 'titleKey' => $titleKey, 'code' => $code ] );
} else {
+ // Try the individual message cache
$entry = $this->wanCache->get( $titleKey );
}
$message = false; // negative caching
}
- if ( $message === false ) { // negative caching
+ if ( $message === false ) {
+ // Negative caching in case a "too big" message is no longer available (deleted)
$this->mCache[$code][$title] = '!NONEXISTENT';
$this->wanCache->set( $titleKey, '!NONEXISTENT', $this->mExpiry, $cacheOpts );
}
return $msgText;
}
+
+ /**
+ * @param string $hash Hash for this version of the entire key/value overrides map
+ * @param string $title Message cache key with initial uppercase letter
+ * @return string
+ */
+ private function bigMessageCacheKey( $hash, $title ) {
+ return $this->wanCache->makeKey( 'messages-big', $hash, $title );
+ }
}
case 'detect':
if ( !empty( $conf['storeDirectory'] ) ) {
$storeClass = 'LCStoreCDB';
+ } elseif ( $wgCacheDirectory ) {
+ $storeConf['directory'] = $wgCacheDirectory;
+ $storeClass = 'LCStoreCDB';
} else {
- $cacheDir = $wgCacheDirectory ?: wfTempDir();
- if ( $cacheDir ) {
- $storeConf['directory'] = $cacheDir;
- $storeClass = 'LCStoreCDB';
- } else {
- $storeClass = 'LCStoreDB';
- }
+ $storeClass = 'LCStoreDB';
}
break;
default:
throw new MWException(
- 'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.' );
+ 'Please set $wgLocalisationCacheConf[\'store\'] to something sensible.'
+ );
}
}
*/
private $parserOptions = [
'processing_instruction_handler' => '',
+ 'external_dtd_handler' => '',
+ 'dtd_handler' => '',
+ 'require_safe_dtd' => true
];
/**
+ * Allow filtering an XML file.
+ *
+ * Filters should return either true or a string to indicate something
+ * is wrong with the file. $this->filterMatch will store if the
+ * file failed validation (true = failed validation).
+ * $this->filterMatchType will contain the validation error.
+ * $this->wellFormed will contain whether the xml file is well-formed.
+ *
+ * @note If multiple filters are hit, only one of them will have the
+ * result stored in $this->filterMatchType.
+ *
* @param string $input a filename or string containing the XML element
* @param callable $filterCallback (optional)
* Function to call to do additional custom validity checks from the
* SAX element handler event. This gives you access to the element
* namespace, name, attributes, and text contents.
- * Filter should return 'true' to toggle on $this->filterMatch
+ * Filter should return a truthy value describing the error.
* @param bool $isFile (optional) indicates if the first parameter is a
* filename (default, true) or if it is a string (false)
* @param array $options list of additional parsing options:
* processing_instruction_handler: Callback for xml_set_processing_instruction_handler
+ * external_dtd_handler: Callback for the url of external dtd subset
+ * dtd_handler: Callback given the full text of the <!DOCTYPE declaration.
+ * require_safe_dtd: Only allow non-recursive entities in internal dtd (default true)
*/
function __construct( $input, $filterCallback = null, $isFile = true, $options = [] ) {
$this->filterCallback = $filterCallback;
if ( $reader->nodeType === XMLReader::PI ) {
$this->processingInstructionHandler( $reader->name, $reader->value );
}
+ if ( $reader->nodeType === XMLReader::DOC_TYPE ) {
+ $this->DTDHandler( $reader );
+ }
} while ( $reader->nodeType != XMLReader::ELEMENT );
// Process the rest of the document
$reader->value
);
break;
+ case XMLReader::DOC_TYPE:
+ // We should never see a doctype after first
+ // element.
+ $this->wellFormed = false;
+ break;
default:
- // One of DOC, DOC_TYPE, ENTITY, END_ENTITY,
+ // One of DOC, ENTITY, END_ENTITY,
// NOTATION, or XML_DECLARATION
// xml_parse didn't send these to the filter, so we won't.
}
$this->filterMatchType = $callbackReturn;
}
}
+ /**
+ * Handle coming across a <!DOCTYPE declaration.
+ *
+ * @param XMLReader $reader Reader currently pointing at DOCTYPE node.
+ */
+ private function DTDHandler( XMLReader $reader ) {
+ $externalCallback = $this->parserOptions['external_dtd_handler'];
+ $generalCallback = $this->parserOptions['dtd_handler'];
+ $checkIfSafe = $this->parserOptions['require_safe_dtd'];
+ if ( !$externalCallback && !$generalCallback && !$checkIfSafe ) {
+ return;
+ }
+ $dtd = $reader->readOuterXML();
+ $callbackReturn = false;
+
+ if ( $generalCallback ) {
+ $callbackReturn = call_user_func( $generalCallback, $dtd );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ $callbackReturn = false;
+ }
+
+ $parsedDTD = $this->parseDTD( $dtd );
+ if ( $externalCallback && isset( $parsedDTD['type'] ) ) {
+ $callbackReturn = call_user_func(
+ $externalCallback,
+ $parsedDTD['type'],
+ isset( $parsedDTD['publicid'] ) ? $parsedDTD['publicid'] : null,
+ isset( $parsedDTD['systemid'] ) ? $parsedDTD['systemid'] : null
+ );
+ }
+ if ( $callbackReturn ) {
+ // Filter hit!
+ $this->filterMatch = true;
+ $this->filterMatchType = $callbackReturn;
+ $callbackReturn = false;
+ }
+
+ if ( $checkIfSafe && isset( $parsedDTD['internal'] ) ) {
+ if ( !$this->checkDTDIsSafe( $parsedDTD['internal'] ) ) {
+ $this->wellFormed = false;
+ }
+ }
+ }
+
+ /**
+ * Check if the internal subset of the DTD is safe.
+ *
+ * We whitelist an extremely restricted subset of DTD features.
+ *
+ * Safe is defined as:
+ * * Only contains entity defintions (e.g. No <!ATLIST )
+ * * Entity definitions are not "system" entities
+ * * Entity definitions are not "parameter" (i.e. %) entities
+ * * Entity definitions do not reference other entites except &
+ * and quotes. Entity aliases (where the entity contains only
+ * another entity are allowed)
+ * * Entity references aren't overly long (>255 bytes).
+ * * <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink">
+ * allowed if matched exactly for compatibility with graphviz
+ * * Comments.
+ *
+ * @param string $internalSubset The internal subset of the DTD
+ * @return bool true if safe.
+ */
+ private function checkDTDIsSafe( $internalSubset ) {
+ $offset = 0;
+ $res = preg_match(
+ '/^(?:\s*<!ENTITY\s+\S+\s+' .
+ '(?:"(?:&[^"%&;]{1,64};|(?:[^"%&]|&|"){0,255})"' .
+ '|\'(?:&[^"%&;]{1,64};|(?:[^\'%&]|&|'){0,255})\')\s*>' .
+ '|\s*<!--(?:[^-]|-[^-])*-->' .
+ '|\s*<!ATTLIST svg xmlns:xlink CDATA #FIXED ' .
+ '"http:\/\/www.w3.org\/1999\/xlink">)*\s*$/',
+ $internalSubset
+ );
+
+ return (bool)$res;
+ }
+
+ /**
+ * Parse DTD into parts.
+ *
+ * If there is an error parsing the dtd, sets wellFormed to false.
+ *
+ * @param $dtd string
+ * @return array Possibly containing keys publicid, systemid, type and internal.
+ */
+ private function parseDTD( $dtd ) {
+ $m = [];
+ $res = preg_match(
+ '/^<!DOCTYPE\s*\S+\s*' .
+ '(?:(?P<typepublic>PUBLIC)\s*' .
+ '(?:"(?P<pubquote>[^"]*)"|\'(?P<pubapos>[^\']*)\')' . // public identifer
+ '\s*"(?P<pubsysquote>[^"]*)"|\'(?P<pubsysapos>[^\']*)\'' . // system identifier
+ '|(?P<typesystem>SYSTEM)\s*' .
+ '(?:"(?P<sysquote>[^"]*)"|\'(?P<sysapos>[^\']*)\')' .
+ ')?\s*' .
+ '(?:\[\s*(?P<internal>.*)\])?\s*>$/s',
+ $dtd,
+ $m
+ );
+ if ( !$res ) {
+ $this->wellFormed = false;
+ return [];
+ }
+ $parsed = [];
+ foreach ( $m as $field => $value ) {
+ if ( $value === '' || is_numeric( $field ) ) {
+ continue;
+ }
+ switch ( $field ) {
+ case 'typepublic':
+ case 'typesystem':
+ $parsed['type'] = $value;
+ break;
+ case 'pubquote':
+ case 'pubapos':
+ $parsed['publicid'] = $value;
+ break;
+ case 'pubsysquote':
+ case 'pubsysapos':
+ case 'sysquote':
+ case 'sysapos':
+ $parsed['systemid'] = $value;
+ break;
+ case 'internal':
+ $parsed['internal'] = $value;
+ break;
+ }
+ }
+ return $parsed;
+ }
}
true, 'free',
$this->getExternalLinkAttribs( $url ), $this->mTitle );
# Register it in the output object...
- # Replace unnecessary URL escape codes with their equivalent characters
- $pasteurized = self::normalizeLinkUrl( $url );
- $this->mOutput->addExternalLink( $pasteurized );
+ $this->mOutput->addExternalLink( $url );
}
return $text . $trail;
}
$this->getExternalLinkAttribs( $url ), $this->mTitle ) . $dtrail . $trail;
# Register link in the output object.
- # Replace unnecessary URL escape codes with the referenced character
- # This prevents spammers from hiding links from the filters
- $pasteurized = self::normalizeLinkUrl( $url );
- $this->mOutput->addExternalLink( $pasteurized );
+ $this->mOutput->addExternalLink( $url );
}
return $s;
}
if ( preg_match( "/^($prots)$addr$chars*$/u", $linkValue ) ) {
$link = $linkValue;
+ $this->mOutput->addExternalLink( $link );
} else {
$localLinkTitle = Title::newFromText( $linkValue );
if ( $localLinkTitle !== null ) {
+ $this->mOutput->addLink( $localLinkTitle );
$link = $localLinkTitle->getLinkURL();
}
}
# We don't register links pointing to our own server, unless... :-)
global $wgServer, $wgRegisterInternalExternals;
+ # Replace unnecessary URL escape codes with the referenced character
+ # This prevents spammers from hiding links from the filters
+ $url = parser::normalizeLinkUrl( $url );
+
$registerExternalLink = true;
if ( !$wgRegisterInternalExternals ) {
$registerExternalLink = !self::isLinkInternal( $wgServer, $url );
class SearchHighlighter {
protected $mCleanWikitext = true;
+ /**
+ * @warning If you pass false to this constructor, then
+ * the caller is responsible for HTML escaping.
+ */
function __construct( $cleanupWikitext = true ) {
$this->mCleanWikitext = $cleanupWikitext;
}
$text = preg_replace( "/('''|<\/?[iIuUbB]>)/", "", $text );
$text = preg_replace( "/''/", "", $text );
+ // Note, the previous /<\/?[^>]+>/ is insufficient
+ // for XSS safety as the HTML tag can span multiple
+ // search results (T144845).
+ $text = Sanitizer::escapeHtmlAllowEntities( $text );
return $text;
}
$query = $this->getRedirectQuery();
// Redirect to a page title with possible query parameters
if ( $redirect instanceof Title ) {
- $url = $redirect->getFullURL( $query );
+ $url = $redirect->getFullUrlForRedirect( $query );
$this->getOutput()->redirect( $url );
return $redirect;
'RandomInCategory' => 'SpecialRandomInCategory',
'Randomredirect' => 'SpecialRandomredirect',
'Randomrootpage' => 'SpecialRandomrootpage',
+ 'GoToInterwiki' => 'SpecialGoToInterwiki',
// High use pages
'Mostlinkedcategories' => 'MostlinkedCategoriesPage',
}
$title = Title::newFromText( $returnTo );
- return $title->getFullURL( $returnToQuery );
+ return $title->getFullUrlForRedirect( $returnToQuery );
}
protected function getRequestBlacklist() {
$query = $request->getVal( 'returntoquery' );
if ( $this->status->value === true ) {
- $this->getOutput()->redirect( $titleObj->getFullURL( $query ) );
+ $this->getOutput()->redirect( $titleObj->getFullUrlForRedirect( $query ) );
} elseif ( $this->status->value === 'eauth' ) {
# Notify user that a confirmation email has been sent...
$this->getOutput()->wrapWikiMsg( "<div class='error' style='clear: both;'>\n$1\n</div>",
--- /dev/null
+<?php
+/**
+ * Implements Special:GoToInterwiki
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Landing page for non-local interwiki links.
+ *
+ * Meant to warn people that the site they're visiting
+ * is not the local wiki (In case of phishing tricks).
+ * Only meant to be used for things that directly
+ * redirect from url (e.g. Special:Search/google:foo )
+ * Not meant for general interwiki linking (e.g.
+ * [[google:foo]] should still directly link)
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialGoToInterwiki extends UnlistedSpecialPage {
+ public function __construct( $name = 'GoToInterwiki' ) {
+ parent::__construct( $name );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $target = Title::newFromText( $par );
+ // Disallow special pages as a precaution against
+ // possible redirect loops.
+ if ( !$target || $target->isSpecialPage() ) {
+ $this->getOutput()->setStatusCode( 404 );
+ $this->getOutput()->addWikiMsg( 'gotointerwiki-invalid' );
+ return;
+ }
+
+ $url = $target->getFullURL();
+ if ( !$target->isExternal() || $target->isLocal() ) {
+ // Either a normal page, or a local interwiki.
+ // just redirect.
+ $this->getOutput()->redirect( $url, '301' );
+ } else {
+ $this->getOutput()->addWikiMsg(
+ 'gotointerwiki-external',
+ $url,
+ $target->getFullText()
+ );
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function requiresWrite() {
+ return false;
+ }
+
+ /**
+ * @return String
+ */
+ protected function getGroupName() {
+ return 'redirects';
+ }
+}
}
// Url to redirect to after the operation
- $this->goToUrl = $title->getFullURL(
+ $this->goToUrl = $title->getFullUrlForRedirect(
$title->isRedirect() ? [ 'redirect' => 'no' ] : []
);
// Set session data for the success message
$this->getRequest()->getSession()->set( 'specialPreferencesSaveSuccess', 1 );
- $url = $this->getPageTitle()->getFullURL();
+ $url = $this->getPageTitle()->getFullUrlForRedirect();
$this->getOutput()->redirect( $url );
return true;
$user = $this->getUser();
$config = $this->getConfig();
if ( $options['from'] ) {
+ $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' ),
+ [ 'from' => '' ], $nondefaults );
+
$note .= $this->msg( 'rcnotefrom' )
->numParams( $options['limit'] )
->params(
$lang->userTime( $options['from'], $user )
)
->numParams( $numRows )
- ->parse() . '<br />';
+ ->parse() . ' ' .
+ Html::rawElement(
+ 'span',
+ [ 'class' => 'rcoptions-listfromreset' ],
+ $this->msg( 'parentheses' )->rawParams( $resetLink )->parse()
+ ) .
+ '<br />';
}
# Sort data for display and make sure it's unique after we've added user data.
return null;
}
- return $url === null ? $title->getFullURL() : $url;
+ return $url === null ? $title->getFullUrlForRedirect() : $url;
}
/**
if ( ( $config->get( 'EnotifWatchlist' ) || $config->get( 'ShowUpdatedMarker' ) )
&& $request->getVal( 'reset' )
&& $request->wasPosted()
+ && $user->matchEditToken( $request->getVal( 'token' ) )
) {
$user->clearAllNotifications();
$output->redirect( $this->getPageTitle()->getFullURL( $opts->getChangedValues() ) );
'id' => 'mw-watchlist-resetbutton' ] ) . "\n" .
Xml::submitButton( $this->msg( 'enotif_reset' )->text(),
[ 'name' => 'mw-watchlist-reset-submit' ] ) . "\n" .
+ Html::hidden( 'token', $user->getEditToken() ) . "\n" .
Html::hidden( 'reset', 'all' ) . "\n";
foreach ( $nondefaults as $key => $value ) {
$form .= Html::hidden( $key, $value ) . "\n";
}
if ( $type === 'successredirect' ) {
- $redirectUrl = $returnToTitle->getFullURL( $returnToQuery, false, $proto );
+ $redirectUrl = $returnToTitle->getFullUrlForRedirect( $returnToQuery, $proto );
$this->getOutput()->redirect( $redirectUrl );
} else {
$this->getOutput()->addReturnTo( $returnToTitle, $returnToQuery, null, $options );
$condition = [];
$condition['ar_user_text'] = $this->target;
- $index = 'usertext_timestamp';
+ $index = 'ar_usertext_timestamp';
return [ $index, $condition ];
}
$filename,
[ $this, 'checkSvgScriptCallback' ],
true,
- [ 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback' ]
+ [
+ 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback',
+ 'external_dtd_handler' => 'UploadBase::checkSvgExternalDTD',
+ ]
);
if ( $check->wellFormed !== true ) {
// Invalid xml (T60553)
return false;
}
+ /**
+ * Verify that DTD urls referenced are only the standard dtds
+ *
+ * Browsers seem to ignore external dtds. However just to be on the
+ * safe side, only allow dtds from the svg standard.
+ *
+ * @param string $type PUBLIC or SYSTEM
+ * @param string $publicId The well-known public identifier for the dtd
+ * @param string $systemId The url for the external dtd
+ */
+ public static function checkSvgExternalDTD( $type, $publicId, $systemId ) {
+ // This doesn't include the XHTML+MathML+SVG doctype since we don't
+ // allow XHTML anyways.
+ $allowedDTDs = [
+ 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd',
+ 'http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd',
+ 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd',
+ 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd'
+ ];
+ if ( $type !== 'PUBLIC'
+ || !in_array( $systemId, $allowedDTDs )
+ || strpos( $publicId, "-//W3C//" ) !== 0
+ ) {
+ return [ 'upload-scripted-dtd' ];
+ }
+ return false;
+ }
+
/**
* @todo Replace this with a whitelist filter!
* @param string $element
"rcfilters-hideminor-conflicts-typeofchange": "Certain types of change cannot be designated as \"minor,\" so this filter conflicts with the following Type of Change filters: $1",
"rcfilters-typeofchange-conflicts-hideminor": "This Type of Change filter conflicts with the \"Minor Edits\" filter. Certain types of change cannot be designated as \"minor.\"",
"rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since <strong>$3, $4</strong> (up to <strong>$1</strong> shown).",
+ "rclistfromreset": "Reset date selection",
"rclistfrom": "Show new changes starting from $2, $3",
"rcshowhideminor": "$1 minor edits",
"rcshowhideminor-show": "Show",
"php-uploaddisabledtext": "File uploads are disabled in PHP.\nPlease check the file_uploads setting.",
"uploadscripted": "This file contains HTML or script code that may be erroneously interpreted by a web browser.",
"upload-scripted-pi-callback": "Cannot upload a file that contains XML-stylesheet processing instruction.",
+ "upload-scripted-dtd": "Cannot upload SVG files that contain a non-standard DTD declaration.",
"uploaded-script-svg": "Found scriptable element \"$1\" in the uploaded SVG file.",
"uploaded-hostile-svg": "Found unsafe CSS in the style element of uploaded SVG file.",
"uploaded-event-handler-on-svg": "Setting event-handler attributes <code>$1=\"$2\"</code> is not allowed in SVG files.",
"restrictionsfield-help": "One IP address or CIDR range per line. To enable everything, use:<pre>0.0.0.0/0\n::/0</pre>",
"revid": "revision $1",
"pageid": "page ID $1",
- "rawhtml-notallowed": "<html> tags cannot be used outside of normal pages."
+ "rawhtml-notallowed": "<html> tags cannot be used outside of normal pages.",
+ "gotointerwiki": "Leaving {{SITENAME}}",
+ "gotointerwiki-invalid": "The specified title was invalid.",
+ "gotointerwiki-external": "You are about to leave {{SITENAME}} to visit [[$2]] which is a separate website.\n\n[$1 Click here to continue on to $1].",
+ "undelete-cantedit": "You cannot undelete this page as you are not allowed to edit this page.",
+ "undelete-cantcreate": "You cannot undelete this page as there is no existing page with this name and you are not allowed to create this page."
}
"rcfilters-typeofchange-conflicts-hideminor": "Tooltip shown when hovering over a Type of change filter tag, when the Minor edits filter is also selected.\n\n\"Minor edits\" is {{msg-mw|rcfilters-filter-minor-label}}.\n\n\"Type of change\" is {{msg-mw|rcfilters-filtergroup-changetype}}.\n\nThis indicates that no results will be shown.",
"rcnotefrom": "This message is displayed at [[Special:RecentChanges]] when viewing recentchanges from some specific time.\n\nThe corresponding message is {{msg-mw|Rclistfrom}}.\n\nParameters:\n* $1 - the maximum number of changes that are displayed\n* $2 - (Optional) a date and time\n* $3 - a date\n* $4 - a time\n* $5 - Number of changes are displayed, for use with PLURAL",
"rclistfrom": "Used on [[Special:RecentChanges]]. Parameters:\n* $1 - (Currently not use) date and time. The date and the time adds to the rclistfrom description.\n* $2 - time. The time adds to the rclistfrom link description (with split of date and time).\n* $3 - date. The date adds to the rclistfrom link description (with split of date and time).\n\nThe corresponding message is {{msg-mw|Rcnotefrom}}.",
+ "rclistfromreset": "Used on [[Special:RecentChanges]] to reset a selection of a certain date range.",
"rcshowhideminor": "Option text in [[Special:RecentChanges]]. Parameters:\n* $1 - the \"show/hide\" command, with the text taken from either {{msg-mw|rcshowhideminor-show}} or {{msg-mw|rcshowhideminor-hide}}\n{{Identical|Minor edit}}",
"rcshowhideminor-show": "{{doc-actionlink}}\nOption text in [[Special:RecentChanges]] in conjunction with {{msg-mw|rcshowhideminor}}.\n\nSee also:\n* {{msg-mw|rcshowhideminor-hide}}\n{{Identical|Show}}",
"rcshowhideminor-hide": "{{doc-actionlink}}\nOption text in [[Special:RecentChanges]] in conjunction with {{msg-mw|rcshowhideminor}}.\n\nSee also:\n* {{msg-mw|rcshowhideminor-show}}\n{{Identical|Hide}}",
"php-uploaddisabledtext": "This means that file uploading is disabled in PHP, not upload of PHP-files.",
"uploadscripted": "Used as error message when uploading a file.\n\nSee also:\n* {{msg-mw|zip-wrong-format}}\n* {{msg-mw|uploadjava}}\n* {{msg-mw|uploadvirus}}",
"upload-scripted-pi-callback": "Used as error message when uploading an SVG file that contains xml-stylesheet processing instruction.",
+ "upload-scripted-dtd": "Used as an error message when uploading an svg file that contains a DTD declaration where the system identifier of the DTD is for something other than the standard SVG DTDS, or it is a SYSTEM DTD, or the public identifier does not start with -//W3C//. Note that errors related to the internal dtd subset do not use this error message.",
"uploaded-script-svg": "Used as error message when uploading an SVG file that contains scriptable tags (script, handler, stylesheet, iframe).\n\nParameters:\n* $1 - The scriptable tag that blocked the SVG file from uploading.",
"uploaded-hostile-svg": "Used as error message when uploading an SVG file that contains unsafe CSS.",
"uploaded-event-handler-on-svg": "Used as error message when uploading an SVG file that contains event-handler attributes.\n\nParameters:\n* $1 - The event-handler attribute that is being modified in the SVG file.\n* $2 - The value that is given to the event-handler attribute.",
"restrictionsfield-help": "Placeholder text displayed in restriction fields (e.g. on Special:BotPassword).",
"revid": "Used to format a revision ID number in text. Parameters:\n* $1 - Revision ID number.\n{{Identical|Revision}}",
"pageid": "Used to format a page ID number in text. Parameters:\n* $1 - Page ID number.",
- "rawhtml-notallowed": "Error message given when $wgRawHtml = true; is set and a user uses an <html> tag in a system message or somewhere other than a normal page."
+ "rawhtml-notallowed": "Error message given when $wgRawHtml = true; is set and a user uses an <html> tag in a system message or somewhere other than a normal page.",
+ "gotointerwiki": "{{doc-special|GoToInterwiki}}\n\nSpecial:GoToInterwiki is a warning page displayed before redirecting users to external interwiki links. Its triggered by people going to something like [[Special:Search/google:foo]].",
+ "gotointerwiki-invalid": "Message shown on Special:GoToInterwiki if given an invalid title.",
+ "gotointerwiki-external": "Message shown on Special:GoToInterwiki if given a external interwiki link (e.g. [[Special:GoToInterwiki/Google:Foo]]). $1 is the full url the user is trying to get to. $2 is the text of the interwiki link (e.g. \"Google:foo\").",
+ "undelete-cantedit": "Shown if the user tries to undelete a page that they cannot edit",
+ "undelete-cantcreate": "Shown if the user tries to undelete a page which currently does not exist, and they are not allowed to create it. This could for example happen on a wiki with custom protection levels where the page name has been create-protected and the user has the right to undelete but not the right to edit protected pages."
}
'Fewestrevisions' => [ 'FewestRevisions' ],
'FileDuplicateSearch' => [ 'FileDuplicateSearch' ],
'Filepath' => [ 'FilePath' ],
+ 'GoToInterwiki' => [ 'GoToInterwiki' ],
'Import' => [ 'Import' ],
'Invalidateemail' => [ 'InvalidateEmail' ],
'JavaScriptTest' => [ 'JavaScriptTest' ],
// Result list circle indicators
// Defined and used in mw.rcfilters.ui.ChangesListWrapperWidget.less
-@result-circle-margin: 0.1em;
+@result-circle-margin: 3px;
@result-circle-general-margin: 0.5em;
// In these small sizes, 'em' appears
// squished and inconsistent.
// Pixels are better for this use case:
-@result-circle-diameter: 5px;
+@result-circle-diameter: 6px;
enter: 'onTextInputEnter'
} );
this.capsule.connect( this, { capsuleItemClick: 'onCapsuleItemClick' } );
- this.capsule.popup.connect( this, { toggle: 'onCapsulePopupToggle' } );
+ this.capsule.popup.connect( this, {
+ toggle: 'onCapsulePopupToggle',
+ ready: 'onCapsulePopupReady'
+ } );
// Initialize
this.$element
this.scrollToTop( filterWidget.$element );
};
+ /**
+ * Respond to capsule popup ready event, fired after the popup is visible, positioned and clipped
+ */
+ mw.rcfilters.ui.FilterWrapperWidget.prototype.onCapsulePopupReady = function () {
+ mw.hook( 'RcFilters.popup.open' ).fire( this.filterPopup.getSelectedFilter() );
+
+ this.scrollToTop( this.capsule.$element, 10 );
+ if ( !this.filterPopup.getSelectedFilter() ) {
+ // No selection, scroll the popup list to top
+ setTimeout( function () { this.capsule.popup.$body.scrollTop( 0 ); }.bind( this ), 0 );
+ }
+ };
+
/**
* Respond to popup toggle event. Reset selection in the list when the popup is closed.
*
* @param {boolean} isVisible Popup is visible
*/
mw.rcfilters.ui.FilterWrapperWidget.prototype.onCapsulePopupToggle = function ( isVisible ) {
- if ( !isVisible ) {
- if ( !this.textInput.getValue() ) {
- // Only reset selection if we are not filtering
- this.filterPopup.resetSelection();
- this.capsule.resetSelection();
- }
- } else {
- mw.hook( 'RcFilters.popup.open' ).fire( this.filterPopup.getSelectedFilter() );
-
- this.scrollToTop( this.capsule.$element, 10 );
- if ( !this.filterPopup.getSelectedFilter() ) {
- // No selection, scroll the popup list to top
- setTimeout( function () { this.capsule.popup.$body.scrollTop( 0 ); }.bind( this ), 0 );
- }
+ if ( !isVisible && !this.textInput.getValue() ) {
+ // Only reset selection if we are not filtering
+ this.filterPopup.resetSelection();
+ this.capsule.resetSelection();
}
};
] );
}
- public static function provideGetModuleRegistrations() {
+ public function provideGetModuleRegistrations() {
return [
[ [
'msg' => 'Empty registry',
"test.blank",
"{blankVer}"
]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Omit raw modules from registry',
+ 'modules' => [
+ 'test.raw' => new ResourceLoaderTestModule( [ 'isRaw' => true ] ),
+ 'test.blank' => new ResourceLoaderTestModule(),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Version falls back gracefully if getVersionHash throws',
+ 'modules' => [
+ 'test.fail' => (
+ ( $mock = $this->getMockBuilder( 'ResourceLoaderTestModule' )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->will(
+ $this->throwException( new Exception )
+ )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.fail",
+ ""
+ ]
+] );
+mw.loader.state( {
+ "test.fail": "error"
+} );',
+ ] ],
+ [ [
+ 'msg' => 'Use version from getVersionHash',
+ 'modules' => [
+ 'test.version' => (
+ ( $mock = $this->getMockBuilder( 'ResourceLoaderTestModule' )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->willReturn( '1234567' )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.version",
+ "1234567"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Re-hash version from getVersionHash if too long',
+ 'modules' => [
+ 'test.version' => (
+ ( $mock = $this->getMockBuilder( 'ResourceLoaderTestModule' )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->willReturn( '12345678' )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.version",
+ "016es8l"
+ ]
] );',
] ],
[ [
/**
* @dataProvider provideGetModuleRegistrations
- * @covers ResourceLoaderStartUpModule::compileUnresolvedDependencies
* @covers ResourceLoaderStartUpModule::getModuleRegistrations
+ * @covers ResourceLoaderStartUpModule::compileUnresolvedDependencies
* @covers ResourceLoader::makeLoaderRegisterScript
*/
public function testGetModuleRegistrations( $case ) {
];
}
/**
+ * @covers ResourceLoaderStartUpModule::getModuleRegistrations
* @dataProvider provideRegistrations
*/
public function testRegistrationsMinified( $modules ) {
}
/**
+ * @covers ResourceLoaderStartUpModule::getModuleRegistrations
* @dataProvider provideRegistrations
*/
public function testRegistrationsUnminified( $modules ) {
* @covers ResourceLoader::register
* @covers ResourceLoader::getModule
*/
- public function testRegisterValid() {
+ public function testRegisterValidObject() {
$module = new ResourceLoaderTestModule();
$resourceLoader = new EmptyResourceLoader();
$resourceLoader->register( 'test', $module );
$this->assertEquals( $module, $resourceLoader->getModule( 'test' ) );
}
+ /**
+ * @covers ResourceLoader::register
+ * @covers ResourceLoader::getModule
+ */
+ public function testRegisterValidArray() {
+ $module = new ResourceLoaderTestModule();
+ $resourceLoader = new EmptyResourceLoader();
+ // Covers case of register() setting $rl->moduleInfos,
+ // but $rl->modules lazy-populated by getModule()
+ $resourceLoader->register( 'test', [ 'object' => $module ] );
+ $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) );
+ }
+
/**
* @covers ResourceLoader::register
*/
);
}
+ /**
+ * @covers ResourceLoader::makeLoaderRegisterScript
+ */
+ public function testMakeLoaderRegisterScript() {
+ $this->assertEquals(
+ 'mw.loader.register( [
+ [
+ "test.name",
+ "1234567"
+ ]
+] );',
+ ResourceLoader::makeLoaderRegisterScript( [
+ [ 'test.name', '1234567' ],
+ ] ),
+ 'Nested array parameter'
+ );
+
+ $this->assertEquals(
+ 'mw.loader.register( "test.name", "1234567" );',
+ ResourceLoader::makeLoaderRegisterScript(
+ 'test.name',
+ '1234567'
+ ),
+ 'Variadic parameters'
+ );
+ }
+
/**
* @covers ResourceLoader::makeLoaderSourcesScript
*/
*/
public function testCheckSvgScriptCallback( $svg, $wellFormed, $filterMatch, $message ) {
list( $formed, $match ) = $this->upload->checkSvgString( $svg );
- $this->assertSame( $wellFormed, $formed, $message );
- $this->assertSame( $filterMatch, $match, $message );
+ $this->assertSame( $wellFormed, $formed, $message . " (well-formed)" );
+ $this->assertSame( $filterMatch, $match, $message . " (filter match)" );
}
public static function provideCheckSvgScriptCallback() {
],
[
'<?xml version="1.0"?> <?xml-stylesheet type="text/xml" href="#stylesheet"?> <!DOCTYPE doc [ <!ATTLIST xsl:stylesheet id ID #REQUIRED>]> <svg xmlns="http://www.w3.org/2000/svg"> <xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe> </xsl:template> </xsl:stylesheet> <circle fill="red" r="40"></circle> </svg>',
- true,
+ false,
true,
'SVG with embedded stylesheet (http://html5sec.org/#125)'
],
+ [
+ '<?xml version="1.0"?> <?xml-stylesheet type="text/xml" href="#stylesheet"?> <svg xmlns="http://www.w3.org/2000/svg"> <xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe> </xsl:template> </xsl:stylesheet> <circle fill="red" r="40"></circle> </svg>',
+ true,
+ true,
+ 'SVG with embedded stylesheet no doctype'
+ ],
[
'<svg xmlns="http://www.w3.org/2000/svg" id="x"> <listener event="load" handler="#y" xmlns="http://www.w3.org/2001/xml-events" observer="x"/> <handler id="y">alert(1)</handler> </svg>',
true,
],
[
'<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY lol "lol"> <!ENTITY lol2 "<script>alert('XSSED => '+document.domain);</script>"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="68" height="68" viewBox="-34 -34 68 68" version="1.1"> <circle cx="0" cy="0" r="24" fill="#c8c8c8"/> <text x="0" y="0" fill="black">&lol2;</text> </svg>',
- true,
+ false,
true,
'SVG with encoded script tag in internal entity (reported by Beyond Security)'
],
false,
'SVG with external entity'
],
+ [
+ // The base64 = <script>alert(1)</script>. If for some reason
+ // entities actually do get loaded, this should trigger
+ // filterMatch to be true. So this test verifies that we
+ // are not loading external entities.
+ '<?xml version="1.0"?> <!DOCTYPE svg [ <!ENTITY foo SYSTEM "data:text/plain;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pgo="> ]> <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <desc>&foo;</desc> <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)" /> </svg>',
+ false,
+ false, /* False verifies entities aren't getting loaded */
+ 'SVG with data: uri external entity'
+ ],
[
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"> <g> <a xlink:href=\"javascript:alert('1 https://google.com')\"> <rect width=\"300\" height=\"100\" style=\"fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)\" /> </a> </g> </svg>",
true,
false,
'SVG with local urls, including filter: in style'
],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE x [<!ATTLIST image x:href CDATA "data:image/png,foo" onerror CDATA "alert(\'XSSED = \'+document.domain)" onload CDATA "alert(\'XSSED = \'+document.domain)"> ]> <svg xmlns:h="http://www.w3.org/1999/xhtml" xmlns:x="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"> <image /> </svg>',
+ false,
+ false,
+ 'SVG with evil default attribute values'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg SYSTEM "data:application/xml-dtd;base64,PCFET0NUWVBFIHN2ZyBbPCFBVFRMSVNUIGltYWdlIHg6aHJlZiBDREFUQSAiZGF0YTppbWFnZS9wbmcsZm9vIiBvbmVycm9yIENEQVRBICJhbGVydCgnWFNTRUQgPSAnK2RvY3VtZW50LmRvbWFpbikiIG9ubG9hZCBDREFUQSAiYWxlcnQoJ1hTU0VEID0gJytkb2N1bWVudC5kb21haW4pIj4gXT4K"><svg xmlns:x="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"> <image /> </svg>',
+ true,
+ true,
+ 'SVG with an evil external dtd'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//FOO/bar" "http://example.com"><svg></svg>',
+ true,
+ true,
+ 'SVG with random public doctype'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg SYSTEM \'http://example.com/evil.dtd\' ><svg></svg>',
+ true,
+ true,
+ 'SVG with random SYSTEM doctype'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY % foo "bar" >] ><svg></svg>',
+ false,
+ false,
+ 'SVG with parameter entity'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY foo "bar%a;" ] ><svg></svg>',
+ false,
+ false,
+ 'SVG with entity referencing parameter entity'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY foo "bar0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"> ] ><svg></svg>',
+ false,
+ false,
+ 'SVG with long entity'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY foo \'"Hi", said bob\'> ] ><svg><g>&foo;</g></svg>',
+ true,
+ false,
+ 'SVG with apostrophe quote entity'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY name "Bob"><!ENTITY foo \'"Hi", said &name;.\'> ] ><svg><g>&foo;</g></svg>',
+ false,
+ false,
+ 'SVG with recursive entity',
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [ <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink"> ]> <svg width="417pt" height="366pt"
+ viewBox="0.00 0.00 417.00 366.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg>',
+ true, /* well-formed */
+ false, /* filter-hit */
+ 'GraphViz-esque svg with #FIXED xlink ns (Should be allowed)'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [ <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink2"> ]> <svg width="417pt" height="366pt"
+ viewBox="0.00 0.00 417.00 366.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg>',
+ false,
+ false,
+ 'GraphViz ATLIST exception should match exactly'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- Comment-here --> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ true,
+ false,
+ 'DTD with comments (Should be allowed)'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- invalid--comment --> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ false,
+ false,
+ 'DTD with invalid comment'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- invalid ---> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ false,
+ false,
+ 'DTD with invalid comment 2'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY bar "&foo;"> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ true,
+ false,
+ 'DTD with aliased entities (Should be allowed)'
+ ],
+ [
+ '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY bar \'&foo;\'> <!ENTITY foo \'#ff6666\'>]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
+ true,
+ false,
+ 'DTD with aliased entities apos (Should be allowed)'
+ ]
];
// @codingStandardsIgnoreEnd
}
$svg,
[ $this, 'checkSvgScriptCallback' ],
false,
- [ 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback' ]
+ [
+ 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback',
+ 'external_dtd_handler' => 'UploadBase::checkSvgExternalDTD'
+ ]
);
return [ $check->wellFormed, $check->filterMatch ];
}