1<?php 2/** 3 * Base class for the backend of file upload. 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * @ingroup Upload 22 */ 23 24use MediaWiki\HookContainer\ProtectedHookAccessorTrait; 25use MediaWiki\MediaWikiServices; 26use MediaWiki\Permissions\Authority; 27use MediaWiki\Permissions\PermissionStatus; 28use MediaWiki\Shell\Shell; 29use MediaWiki\User\UserIdentity; 30 31/** 32 * @defgroup Upload Upload related 33 */ 34 35/** 36 * @ingroup Upload 37 * 38 * UploadBase and subclasses are the backend of MediaWiki's file uploads. 39 * The frontends are formed by ApiUpload and SpecialUpload. 40 * 41 * @stable to extend 42 * 43 * @author Brion Vibber 44 * @author Bryan Tong Minh 45 * @author Michael Dale 46 */ 47abstract class UploadBase { 48 use ProtectedHookAccessorTrait; 49 50 /** @var string|null Local file system path to the file to upload (or a local copy) */ 51 protected $mTempPath; 52 /** @var TempFSFile|null Wrapper to handle deleting the temp file */ 53 protected $tempFileObj; 54 /** @var string|null */ 55 protected $mDesiredDestName; 56 /** @var string|null */ 57 protected $mDestName; 58 /** @var bool|null */ 59 protected $mRemoveTempFile; 60 /** @var string|null */ 61 protected $mSourceType; 62 /** @var Title|bool */ 63 protected $mTitle = false; 64 /** @var int */ 65 protected $mTitleError = 0; 66 /** @var string|null */ 67 protected $mFilteredName; 68 /** @var string|null */ 69 protected $mFinalExtension; 70 /** @var LocalFile|null */ 71 protected $mLocalFile; 72 /** @var UploadStashFile|null */ 73 protected $mStashFile; 74 /** @var int|null */ 75 protected $mFileSize; 76 /** @var array|null */ 77 protected $mFileProps; 78 /** @var string[] */ 79 protected $mBlackListedExtensions; 80 /** @var bool|null */ 81 protected $mJavaDetected; 82 /** @var string|null */ 83 protected $mSVGNSError; 84 85 protected static $safeXmlEncodings = [ 86 'UTF-8', 87 'ISO-8859-1', 88 'ISO-8859-2', 89 'UTF-16', 90 'UTF-32', 91 'WINDOWS-1250', 92 'WINDOWS-1251', 93 'WINDOWS-1252', 94 'WINDOWS-1253', 95 'WINDOWS-1254', 96 'WINDOWS-1255', 97 'WINDOWS-1256', 98 'WINDOWS-1257', 99 'WINDOWS-1258', 100 ]; 101 102 public const SUCCESS = 0; 103 public const OK = 0; 104 public const EMPTY_FILE = 3; 105 public const MIN_LENGTH_PARTNAME = 4; 106 public const ILLEGAL_FILENAME = 5; 107 public const OVERWRITE_EXISTING_FILE = 7; # Not used anymore; handled by verifyTitlePermissions() 108 public const FILETYPE_MISSING = 8; 109 public const FILETYPE_BADTYPE = 9; 110 public const VERIFICATION_ERROR = 10; 111 public const HOOK_ABORTED = 11; 112 public const FILE_TOO_LARGE = 12; 113 public const WINDOWS_NONASCII_FILENAME = 13; 114 public const FILENAME_TOO_LONG = 14; 115 116 /** 117 * @param int $error 118 * @return string 119 */ 120 public function getVerificationErrorCode( $error ) { 121 $code_to_status = [ 122 self::EMPTY_FILE => 'empty-file', 123 self::FILE_TOO_LARGE => 'file-too-large', 124 self::FILETYPE_MISSING => 'filetype-missing', 125 self::FILETYPE_BADTYPE => 'filetype-banned', 126 self::MIN_LENGTH_PARTNAME => 'filename-tooshort', 127 self::ILLEGAL_FILENAME => 'illegal-filename', 128 self::OVERWRITE_EXISTING_FILE => 'overwrite', 129 self::VERIFICATION_ERROR => 'verification-error', 130 self::HOOK_ABORTED => 'hookaborted', 131 self::WINDOWS_NONASCII_FILENAME => 'windows-nonascii-filename', 132 self::FILENAME_TOO_LONG => 'filename-toolong', 133 ]; 134 return $code_to_status[$error] ?? 'unknown-error'; 135 } 136 137 /** 138 * Returns true if uploads are enabled. 139 * Can be override by subclasses. 140 * @stable to override 141 * @return bool 142 */ 143 public static function isEnabled() { 144 global $wgEnableUploads; 145 146 return $wgEnableUploads && wfIniGetBool( 'file_uploads' ); 147 } 148 149 /** 150 * Returns true if the user can use this upload module or else a string 151 * identifying the missing permission. 152 * Can be overridden by subclasses. 153 * 154 * @param Authority $performer 155 * @return bool|string 156 */ 157 public static function isAllowed( Authority $performer ) { 158 foreach ( [ 'upload', 'edit' ] as $permission ) { 159 if ( !$performer->isAllowed( $permission ) ) { 160 return $permission; 161 } 162 } 163 164 return true; 165 } 166 167 /** 168 * Returns true if the user has surpassed the upload rate limit, false otherwise. 169 * 170 * @param User $user 171 * @return bool 172 */ 173 public static function isThrottled( $user ) { 174 return $user->pingLimiter( 'upload' ); 175 } 176 177 /** @var string[] Upload handlers. Should probably just be a global. */ 178 private static $uploadHandlers = [ 'Stash', 'File', 'Url' ]; 179 180 /** 181 * Create a form of UploadBase depending on wpSourceType and initializes it 182 * 183 * @param WebRequest &$request 184 * @param string|null $type 185 * @return null|self 186 */ 187 public static function createFromRequest( &$request, $type = null ) { 188 $type = $type ?: $request->getVal( 'wpSourceType', 'File' ); 189 190 if ( !$type ) { 191 return null; 192 } 193 194 // Get the upload class 195 $type = ucfirst( $type ); 196 197 // Give hooks the chance to handle this request 198 /** @var self|null $className */ 199 $className = null; 200 Hooks::runner()->onUploadCreateFromRequest( $type, $className ); 201 if ( $className === null ) { 202 $className = 'UploadFrom' . $type; 203 wfDebug( __METHOD__ . ": class name: $className" ); 204 if ( !in_array( $type, self::$uploadHandlers ) ) { 205 return null; 206 } 207 } 208 209 // Check whether this upload class is enabled 210 if ( !$className::isEnabled() ) { 211 return null; 212 } 213 214 // Check whether the request is valid 215 if ( !$className::isValidRequest( $request ) ) { 216 return null; 217 } 218 219 /** @var self $handler */ 220 $handler = new $className; 221 222 $handler->initializeFromRequest( $request ); 223 224 return $handler; 225 } 226 227 /** 228 * Check whether a request if valid for this handler 229 * @param WebRequest $request 230 * @return bool 231 */ 232 public static function isValidRequest( $request ) { 233 return false; 234 } 235 236 /** 237 * @stable to call 238 */ 239 public function __construct() { 240 } 241 242 /** 243 * Returns the upload type. Should be overridden by child classes 244 * 245 * @since 1.18 246 * @stable to override 247 * @return string 248 */ 249 public function getSourceType() { 250 return null; 251 } 252 253 /** 254 * @param string $name The desired destination name 255 * @param string $tempPath 256 * @param int|null $fileSize 257 * @param bool $removeTempFile (false) remove the temporary file? 258 * @throws MWException 259 */ 260 public function initializePathInfo( $name, $tempPath, $fileSize, $removeTempFile = false ) { 261 $this->mDesiredDestName = $name; 262 if ( FileBackend::isStoragePath( $tempPath ) ) { 263 throw new MWException( __METHOD__ . " given storage path `$tempPath`." ); 264 } 265 266 $this->setTempFile( $tempPath, $fileSize ); 267 $this->mRemoveTempFile = $removeTempFile; 268 } 269 270 /** 271 * Initialize from a WebRequest. Override this in a subclass. 272 * 273 * @param WebRequest &$request 274 */ 275 abstract public function initializeFromRequest( &$request ); 276 277 /** 278 * @param string $tempPath File system path to temporary file containing the upload 279 * @param int|null $fileSize 280 */ 281 protected function setTempFile( $tempPath, $fileSize = null ) { 282 $this->mTempPath = $tempPath; 283 $this->mFileSize = $fileSize ?: null; 284 if ( strlen( $this->mTempPath ) && file_exists( $this->mTempPath ) ) { 285 $this->tempFileObj = new TempFSFile( $this->mTempPath ); 286 if ( !$fileSize ) { 287 $this->mFileSize = filesize( $this->mTempPath ); 288 } 289 } else { 290 $this->tempFileObj = null; 291 } 292 } 293 294 /** 295 * Fetch the file. Usually a no-op 296 * @stable to override 297 * @return Status 298 */ 299 public function fetchFile() { 300 return Status::newGood(); 301 } 302 303 /** 304 * Return true if the file is empty 305 * @return bool 306 */ 307 public function isEmptyFile() { 308 return empty( $this->mFileSize ); 309 } 310 311 /** 312 * Return the file size 313 * @return int 314 */ 315 public function getFileSize() { 316 return $this->mFileSize; 317 } 318 319 /** 320 * Get the base 36 SHA1 of the file 321 * @stable to override 322 * @return string|false 323 */ 324 public function getTempFileSha1Base36() { 325 return FSFile::getSha1Base36FromPath( $this->mTempPath ); 326 } 327 328 /** 329 * @param string $srcPath The source path 330 * @return string|bool The real path if it was a virtual URL Returns false on failure 331 */ 332 public function getRealPath( $srcPath ) { 333 $repo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo(); 334 if ( FileRepo::isVirtualUrl( $srcPath ) ) { 335 /** @todo Just make uploads work with storage paths UploadFromStash 336 * loads files via virtual URLs. 337 */ 338 $tmpFile = $repo->getLocalCopy( $srcPath ); 339 if ( $tmpFile ) { 340 $tmpFile->bind( $this ); // keep alive with $this 341 } 342 $path = $tmpFile ? $tmpFile->getPath() : false; 343 } else { 344 $path = $srcPath; 345 } 346 347 return $path; 348 } 349 350 /** 351 * Verify whether the upload is sane. 352 * 353 * Return a status array representing the outcome of the verification. 354 * Possible keys are: 355 * - 'status': set to self::OK in case of success, or to one of the error constants defined in 356 * this class in case of failure 357 * - 'max': set to the maximum allowed file size ($wgMaxUploadSize) if the upload is too large 358 * - 'details': set to error details if the file type is valid but contents are corrupt 359 * - 'filtered': set to the sanitized file name if the requested file name is invalid 360 * - 'finalExt': set to the file's file extension if it is not an allowed file extension 361 * - 'blacklistedExt': set to the list of blacklisted file extensions if the current file extension 362 * is not allowed for uploads and the blacklist is not empty 363 * 364 * @stable to override 365 * @return mixed[] array representing the result of the verification 366 */ 367 public function verifyUpload() { 368 /** 369 * If there was no filename or a zero size given, give up quick. 370 */ 371 if ( $this->isEmptyFile() ) { 372 return [ 'status' => self::EMPTY_FILE ]; 373 } 374 375 /** 376 * Honor $wgMaxUploadSize 377 */ 378 $maxSize = self::getMaxUploadSize( $this->getSourceType() ); 379 if ( $this->mFileSize > $maxSize ) { 380 return [ 381 'status' => self::FILE_TOO_LARGE, 382 'max' => $maxSize, 383 ]; 384 } 385 386 /** 387 * Look at the contents of the file; if we can recognize the 388 * type but it's corrupt or data of the wrong type, we should 389 * probably not accept it. 390 */ 391 $verification = $this->verifyFile(); 392 if ( $verification !== true ) { 393 return [ 394 'status' => self::VERIFICATION_ERROR, 395 'details' => $verification 396 ]; 397 } 398 399 /** 400 * Make sure this file can be created 401 */ 402 $result = $this->validateName(); 403 if ( $result !== true ) { 404 return $result; 405 } 406 407 return [ 'status' => self::OK ]; 408 } 409 410 /** 411 * Verify that the name is valid and, if necessary, that we can overwrite 412 * 413 * @return array|bool True if valid, otherwise an array with 'status' 414 * and other keys 415 */ 416 public function validateName() { 417 $nt = $this->getTitle(); 418 if ( $nt === null ) { 419 $result = [ 'status' => $this->mTitleError ]; 420 if ( $this->mTitleError == self::ILLEGAL_FILENAME ) { 421 $result['filtered'] = $this->mFilteredName; 422 } 423 if ( $this->mTitleError == self::FILETYPE_BADTYPE ) { 424 $result['finalExt'] = $this->mFinalExtension; 425 if ( count( $this->mBlackListedExtensions ) ) { 426 $result['blacklistedExt'] = $this->mBlackListedExtensions; 427 } 428 } 429 430 return $result; 431 } 432 $this->mDestName = $this->getLocalFile()->getName(); 433 434 return true; 435 } 436 437 /** 438 * Verify the MIME type. 439 * 440 * @note Only checks that it is not an evil MIME. The "does it have 441 * correct extension given its MIME type?" check is in verifyFile. 442 * in `verifyFile()` that MIME type and file extension correlate. 443 * @param string $mime Representing the MIME 444 * @return array|bool True if the file is verified, an array otherwise 445 */ 446 protected function verifyMimeType( $mime ) { 447 global $wgVerifyMimeType, $wgVerifyMimeTypeIE; 448 if ( $wgVerifyMimeType ) { 449 wfDebug( "mime: <$mime> extension: <{$this->mFinalExtension}>" ); 450 global $wgMimeTypeExclusions; 451 if ( self::checkFileExtension( $mime, $wgMimeTypeExclusions ) ) { 452 return [ 'filetype-badmime', $mime ]; 453 } 454 455 if ( $wgVerifyMimeTypeIE ) { 456 # Check what Internet Explorer would detect 457 $fp = fopen( $this->mTempPath, 'rb' ); 458 if ( $fp ) { 459 $chunk = fread( $fp, 256 ); 460 fclose( $fp ); 461 462 $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); 463 $extMime = $magic->getMimeTypeFromExtensionOrNull( (string)$this->mFinalExtension ) ?? ''; 464 $ieTypes = $magic->getIEMimeTypes( $this->mTempPath, $chunk, $extMime ); 465 foreach ( $ieTypes as $ieType ) { 466 if ( self::checkFileExtension( $ieType, $wgMimeTypeExclusions ) ) { 467 return [ 'filetype-bad-ie-mime', $ieType ]; 468 } 469 } 470 } 471 } 472 } 473 474 return true; 475 } 476 477 /** 478 * Verifies that it's ok to include the uploaded file 479 * 480 * @return array|bool True of the file is verified, array otherwise. 481 */ 482 protected function verifyFile() { 483 global $wgVerifyMimeType, $wgDisableUploadScriptChecks; 484 485 $status = $this->verifyPartialFile(); 486 if ( $status !== true ) { 487 return $status; 488 } 489 490 $mwProps = new MWFileProps( MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer() ); 491 $this->mFileProps = $mwProps->getPropsFromPath( $this->mTempPath, $this->mFinalExtension ); 492 $mime = $this->mFileProps['mime']; 493 494 if ( $wgVerifyMimeType ) { 495 # XXX: Missing extension will be caught by validateName() via getTitle() 496 if ( (string)$this->mFinalExtension !== '' && 497 !$this->verifyExtension( $mime, $this->mFinalExtension ) 498 ) { 499 return [ 'filetype-mime-mismatch', $this->mFinalExtension, $mime ]; 500 } 501 } 502 503 # check for htmlish code and javascript 504 if ( !$wgDisableUploadScriptChecks ) { 505 if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) { 506 $svgStatus = $this->detectScriptInSvg( $this->mTempPath, false ); 507 if ( $svgStatus !== false ) { 508 return $svgStatus; 509 } 510 } 511 } 512 513 $handler = MediaHandler::getHandler( $mime ); 514 if ( $handler ) { 515 $handlerStatus = $handler->verifyUpload( $this->mTempPath ); 516 if ( !$handlerStatus->isOK() ) { 517 $errors = $handlerStatus->getErrorsArray(); 518 519 return reset( $errors ); 520 } 521 } 522 523 $error = true; 524 $this->getHookRunner()->onUploadVerifyFile( $this, $mime, $error ); 525 if ( $error !== true ) { 526 if ( !is_array( $error ) ) { 527 $error = [ $error ]; 528 } 529 return $error; 530 } 531 532 wfDebug( __METHOD__ . ": all clear; passing." ); 533 534 return true; 535 } 536 537 /** 538 * A verification routine suitable for partial files 539 * 540 * Runs the blacklist checks, but not any checks that may 541 * assume the entire file is present. 542 * 543 * @return array|bool True if the file is valid, else an array with error message key. 544 */ 545 protected function verifyPartialFile() { 546 global $wgAllowJavaUploads, $wgDisableUploadScriptChecks; 547 548 # getTitle() sets some internal parameters like $this->mFinalExtension 549 $this->getTitle(); 550 551 $mwProps = new MWFileProps( MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer() ); 552 $this->mFileProps = $mwProps->getPropsFromPath( $this->mTempPath, $this->mFinalExtension ); 553 554 # check MIME type, if desired 555 $mime = $this->mFileProps['file-mime']; 556 $status = $this->verifyMimeType( $mime ); 557 if ( $status !== true ) { 558 return $status; 559 } 560 561 # check for htmlish code and javascript 562 if ( !$wgDisableUploadScriptChecks ) { 563 if ( self::detectScript( $this->mTempPath, $mime, $this->mFinalExtension ) ) { 564 return [ 'uploadscripted' ]; 565 } 566 if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) { 567 $svgStatus = $this->detectScriptInSvg( $this->mTempPath, true ); 568 if ( $svgStatus !== false ) { 569 return $svgStatus; 570 } 571 } 572 } 573 574 # Check for Java applets, which if uploaded can bypass cross-site 575 # restrictions. 576 if ( !$wgAllowJavaUploads ) { 577 $this->mJavaDetected = false; 578 $zipStatus = ZipDirectoryReader::read( $this->mTempPath, 579 [ $this, 'zipEntryCallback' ] ); 580 if ( !$zipStatus->isOK() ) { 581 $errors = $zipStatus->getErrorsArray(); 582 $error = reset( $errors ); 583 if ( $error[0] !== 'zip-wrong-format' ) { 584 return $error; 585 } 586 } 587 if ( $this->mJavaDetected ) { 588 return [ 'uploadjava' ]; 589 } 590 } 591 592 # Scan the uploaded file for viruses 593 $virus = $this->detectVirus( $this->mTempPath ); 594 if ( $virus ) { 595 return [ 'uploadvirus', $virus ]; 596 } 597 598 return true; 599 } 600 601 /** 602 * Callback for ZipDirectoryReader to detect Java class files. 603 * 604 * @param array $entry 605 */ 606 public function zipEntryCallback( $entry ) { 607 $names = [ $entry['name'] ]; 608 609 // If there is a null character, cut off the name at it, because JDK's 610 // ZIP_GetEntry() uses strcmp() if the name hashes match. If a file name 611 // were constructed which had ".class\0" followed by a string chosen to 612 // make the hash collide with the truncated name, that file could be 613 // returned in response to a request for the .class file. 614 $nullPos = strpos( $entry['name'], "\000" ); 615 if ( $nullPos !== false ) { 616 $names[] = substr( $entry['name'], 0, $nullPos ); 617 } 618 619 // If there is a trailing slash in the file name, we have to strip it, 620 // because that's what ZIP_GetEntry() does. 621 if ( preg_grep( '!\.class/?$!', $names ) ) { 622 $this->mJavaDetected = true; 623 } 624 } 625 626 /** 627 * Alias for verifyTitlePermissions. The function was originally 628 * 'verifyPermissions', but that suggests it's checking the user, when it's 629 * really checking the title + user combination. 630 * 631 * @param Authority $performer to verify the permissions against 632 * @return array|bool An array as returned by getPermissionErrors or true 633 * in case the user has proper permissions. 634 */ 635 public function verifyPermissions( Authority $performer ) { 636 return $this->verifyTitlePermissions( $performer ); 637 } 638 639 /** 640 * Check whether the user can edit, upload and create the image. This 641 * checks only against the current title; if it returns errors, it may 642 * very well be that another title will not give errors. Therefore 643 * isAllowed() should be called as well for generic is-user-blocked or 644 * can-user-upload checking. 645 * 646 * @param Authority $performer to verify the permissions against 647 * @return array|bool An array as returned by getPermissionErrors or true 648 * in case the user has proper permissions. 649 */ 650 public function verifyTitlePermissions( Authority $performer ) { 651 /** 652 * If the image is protected, non-sysop users won't be able 653 * to modify it by uploading a new revision. 654 */ 655 $nt = $this->getTitle(); 656 if ( $nt === null ) { 657 return true; 658 } 659 660 $status = PermissionStatus::newEmpty(); 661 $performer->authorizeWrite( 'edit', $nt, $status ); 662 $performer->authorizeWrite( 'upload', $nt, $status ); 663 if ( !$status->isGood() ) { 664 return $status->toLegacyErrorArray(); 665 } 666 667 $overwriteError = $this->checkOverwrite( $performer ); 668 if ( $overwriteError !== true ) { 669 return [ $overwriteError ]; 670 } 671 672 return true; 673 } 674 675 /** 676 * Check for non fatal problems with the file. 677 * 678 * This should not assume that mTempPath is set. 679 * 680 * @param User|null $user Accepted since 1.35 681 * 682 * @return mixed[] Array of warnings 683 */ 684 public function checkWarnings( $user = null ) { 685 if ( $user === null ) { 686 // TODO check uses and hard deprecate 687 $user = RequestContext::getMain()->getUser(); 688 } 689 690 $warnings = []; 691 692 $localFile = $this->getLocalFile(); 693 $localFile->load( File::READ_LATEST ); 694 $filename = $localFile->getName(); 695 $hash = $this->getTempFileSha1Base36(); 696 697 $badFileName = $this->checkBadFileName( $filename, $this->mDesiredDestName ); 698 if ( $badFileName !== null ) { 699 $warnings['badfilename'] = $badFileName; 700 } 701 702 $unwantedFileExtensionDetails = $this->checkUnwantedFileExtensions( (string)$this->mFinalExtension ); 703 if ( $unwantedFileExtensionDetails !== null ) { 704 $warnings['filetype-unwanted-type'] = $unwantedFileExtensionDetails; 705 } 706 707 $fileSizeWarnings = $this->checkFileSize( $this->mFileSize ); 708 if ( $fileSizeWarnings ) { 709 $warnings = array_merge( $warnings, $fileSizeWarnings ); 710 } 711 712 $localFileExistsWarnings = $this->checkLocalFileExists( $localFile, $hash ); 713 if ( $localFileExistsWarnings ) { 714 $warnings = array_merge( $warnings, $localFileExistsWarnings ); 715 } 716 717 if ( $this->checkLocalFileWasDeleted( $localFile ) ) { 718 $warnings['was-deleted'] = $filename; 719 } 720 721 // If a file with the same name exists locally then the local file has already been tested 722 // for duplication of content 723 $ignoreLocalDupes = isset( $warnings['exists'] ); 724 $dupes = $this->checkAgainstExistingDupes( $hash, $ignoreLocalDupes ); 725 if ( $dupes ) { 726 $warnings['duplicate'] = $dupes; 727 } 728 729 $archivedDupes = $this->checkAgainstArchiveDupes( $hash, $user ); 730 if ( $archivedDupes !== null ) { 731 $warnings['duplicate-archive'] = $archivedDupes; 732 } 733 734 return $warnings; 735 } 736 737 /** 738 * Convert the warnings array returned by checkWarnings() to something that 739 * can be serialized. File objects will be converted to an associative array 740 * with the following keys: 741 * 742 * - fileName: The name of the file 743 * - timestamp: The upload timestamp 744 * 745 * @param mixed[] $warnings 746 * @return mixed[] 747 */ 748 public static function makeWarningsSerializable( $warnings ) { 749 array_walk_recursive( $warnings, static function ( &$param, $key ) { 750 if ( $param instanceof File ) { 751 $param = [ 752 'fileName' => $param->getName(), 753 'timestamp' => $param->getTimestamp() 754 ]; 755 } elseif ( is_object( $param ) ) { 756 throw new InvalidArgumentException( 757 'UploadBase::makeWarningsSerializable: ' . 758 'Unexpected object of class ' . get_class( $param ) ); 759 } 760 } ); 761 return $warnings; 762 } 763 764 /** 765 * Check whether the resulting filename is different from the desired one, 766 * but ignore things like ucfirst() and spaces/underscore things 767 * 768 * @param string $filename 769 * @param string $desiredFileName 770 * 771 * @return string|null String that was determined to be bad or null if the filename is okay 772 */ 773 private function checkBadFileName( $filename, $desiredFileName ) { 774 $comparableName = str_replace( ' ', '_', $desiredFileName ); 775 $comparableName = Title::capitalize( $comparableName, NS_FILE ); 776 777 if ( $desiredFileName != $filename && $comparableName != $filename ) { 778 return $filename; 779 } 780 781 return null; 782 } 783 784 /** 785 * @param string $fileExtension The file extension to check 786 * 787 * @return array|null array with the following keys: 788 * 0 => string The final extension being used 789 * 1 => string[] The extensions that are allowed 790 * 2 => int The number of extensions that are allowed. 791 */ 792 private function checkUnwantedFileExtensions( $fileExtension ) { 793 global $wgCheckFileExtensions, $wgFileExtensions, $wgLang; 794 795 if ( $wgCheckFileExtensions ) { 796 $extensions = array_unique( $wgFileExtensions ); 797 if ( !$this->checkFileExtension( $fileExtension, $extensions ) ) { 798 return [ 799 $fileExtension, 800 $wgLang->commaList( $extensions ), 801 count( $extensions ) 802 ]; 803 } 804 } 805 806 return null; 807 } 808 809 /** 810 * @param int $fileSize 811 * 812 * @return array warnings 813 */ 814 private function checkFileSize( $fileSize ) { 815 global $wgUploadSizeWarning; 816 817 $warnings = []; 818 819 if ( $wgUploadSizeWarning && ( $fileSize > $wgUploadSizeWarning ) ) { 820 $warnings['large-file'] = [ 821 Message::sizeParam( $wgUploadSizeWarning ), 822 Message::sizeParam( $fileSize ), 823 ]; 824 } 825 826 if ( $fileSize == 0 ) { 827 $warnings['empty-file'] = true; 828 } 829 830 return $warnings; 831 } 832 833 /** 834 * @param LocalFile $localFile 835 * @param string|false $hash sha1 hash of the file to check 836 * 837 * @return array warnings 838 */ 839 private function checkLocalFileExists( LocalFile $localFile, $hash ) { 840 $warnings = []; 841 842 $exists = self::getExistsWarning( $localFile ); 843 if ( $exists !== false ) { 844 $warnings['exists'] = $exists; 845 846 // check if file is an exact duplicate of current file version 847 if ( $hash !== false && $hash === $localFile->getSha1() ) { 848 $warnings['no-change'] = $localFile; 849 } 850 851 // check if file is an exact duplicate of older versions of this file 852 $history = $localFile->getHistory(); 853 foreach ( $history as $oldFile ) { 854 if ( $hash === $oldFile->getSha1() ) { 855 $warnings['duplicate-version'][] = $oldFile; 856 } 857 } 858 } 859 860 return $warnings; 861 } 862 863 private function checkLocalFileWasDeleted( LocalFile $localFile ) { 864 return $localFile->wasDeleted() && !$localFile->exists(); 865 } 866 867 /** 868 * @param string|false $hash sha1 hash of the file to check 869 * @param bool $ignoreLocalDupes True to ignore local duplicates 870 * 871 * @return File[] Duplicate files, if found. 872 */ 873 private function checkAgainstExistingDupes( $hash, $ignoreLocalDupes ) { 874 if ( $hash === false ) { 875 return []; 876 } 877 $dupes = MediaWikiServices::getInstance()->getRepoGroup()->findBySha1( $hash ); 878 $title = $this->getTitle(); 879 foreach ( $dupes as $key => $dupe ) { 880 if ( 881 ( $dupe instanceof LocalFile ) && 882 $ignoreLocalDupes && 883 $title->equals( $dupe->getTitle() ) 884 ) { 885 unset( $dupes[$key] ); 886 } 887 } 888 889 return $dupes; 890 } 891 892 /** 893 * @param string|false $hash sha1 hash of the file to check 894 * @param Authority $performer 895 * 896 * @return string|null Name of the dupe or empty string if discovered (depending on visibility) 897 * null if the check discovered no dupes. 898 */ 899 private function checkAgainstArchiveDupes( $hash, Authority $performer ) { 900 if ( $hash === false ) { 901 return null; 902 } 903 $archivedFile = new ArchivedFile( null, 0, '', $hash ); 904 if ( $archivedFile->getID() > 0 ) { 905 if ( $archivedFile->userCan( File::DELETED_FILE, $performer ) ) { 906 return $archivedFile->getName(); 907 } else { 908 return ''; 909 } 910 } 911 912 return null; 913 } 914 915 /** 916 * Really perform the upload. Stores the file in the local repo, watches 917 * if necessary and runs the UploadComplete hook. 918 * 919 * @param string $comment 920 * @param string $pageText 921 * @param bool $watch Whether the file page should be added to user's watchlist. 922 * (This doesn't check $user's permissions.) 923 * @param User $user 924 * @param string[] $tags Change tags to add to the log entry and page revision. 925 * (This doesn't check $user's permissions.) 926 * @param string|null $watchlistExpiry Optional watchlist expiry timestamp in any format 927 * acceptable to wfTimestamp(). 928 * @return Status Indicating the whether the upload succeeded. 929 * 930 * @since 1.35 Accepts $watchlistExpiry parameter. 931 */ 932 public function performUpload( 933 $comment, $pageText, $watch, $user, $tags = [], ?string $watchlistExpiry = null 934 ) { 935 $this->getLocalFile()->load( File::READ_LATEST ); 936 $props = $this->mFileProps; 937 938 $error = null; 939 $this->getHookRunner()->onUploadVerifyUpload( $this, $user, $props, $comment, $pageText, $error ); 940 if ( $error ) { 941 if ( !is_array( $error ) ) { 942 $error = [ $error ]; 943 } 944 return Status::newFatal( ...$error ); 945 } 946 947 $status = $this->getLocalFile()->upload( 948 $this->mTempPath, 949 $comment, 950 $pageText, 951 File::DELETE_SOURCE, 952 $props, 953 false, 954 $user, 955 $tags 956 ); 957 958 if ( $status->isGood() ) { 959 if ( $watch ) { 960 MediaWikiServices::getInstance()->getWatchlistManager()->addWatchIgnoringRights( 961 $user, 962 $this->getLocalFile()->getTitle(), 963 $watchlistExpiry 964 ); 965 } 966 $this->getHookRunner()->onUploadComplete( $this ); 967 968 $this->postProcessUpload(); 969 } 970 971 return $status; 972 } 973 974 /** 975 * Perform extra steps after a successful upload. 976 * 977 * @stable to override 978 * @since 1.25 979 */ 980 public function postProcessUpload() { 981 } 982 983 /** 984 * Returns the title of the file to be uploaded. Sets mTitleError in case 985 * the name was illegal. 986 * 987 * @return Title|null The title of the file or null in case the name was illegal 988 */ 989 public function getTitle() { 990 if ( $this->mTitle !== false ) { 991 return $this->mTitle; 992 } 993 if ( !is_string( $this->mDesiredDestName ) ) { 994 $this->mTitleError = self::ILLEGAL_FILENAME; 995 $this->mTitle = null; 996 997 return $this->mTitle; 998 } 999 /* Assume that if a user specified File:Something.jpg, this is an error 1000 * and that the namespace prefix needs to be stripped of. 1001 */ 1002 $title = Title::newFromText( $this->mDesiredDestName ); 1003 if ( $title && $title->getNamespace() === NS_FILE ) { 1004 $this->mFilteredName = $title->getDBkey(); 1005 } else { 1006 $this->mFilteredName = $this->mDesiredDestName; 1007 } 1008 1009 # oi_archive_name is max 255 bytes, which include a timestamp and an 1010 # exclamation mark, so restrict file name to 240 bytes. 1011 if ( strlen( $this->mFilteredName ) > 240 ) { 1012 $this->mTitleError = self::FILENAME_TOO_LONG; 1013 $this->mTitle = null; 1014 1015 return $this->mTitle; 1016 } 1017 1018 /** 1019 * Chop off any directories in the given filename. Then 1020 * filter out illegal characters, and try to make a legible name 1021 * out of it. We'll strip some silently that Title would die on. 1022 */ 1023 $this->mFilteredName = wfStripIllegalFilenameChars( $this->mFilteredName ); 1024 /* Normalize to title form before we do any further processing */ 1025 $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName ); 1026 if ( $nt === null ) { 1027 $this->mTitleError = self::ILLEGAL_FILENAME; 1028 $this->mTitle = null; 1029 1030 return $this->mTitle; 1031 } 1032 $this->mFilteredName = $nt->getDBkey(); 1033 1034 /** 1035 * We'll want to blacklist against *any* 'extension', and use 1036 * only the final one for the whitelist. 1037 */ 1038 list( $partname, $ext ) = $this->splitExtensions( $this->mFilteredName ); 1039 1040 if ( $ext !== [] ) { 1041 $this->mFinalExtension = trim( end( $ext ) ); 1042 } else { 1043 $this->mFinalExtension = ''; 1044 1045 // No extension, try guessing one from the temporary file 1046 // FIXME: Sometimes we mTempPath isn't set yet here, possibly due to an unrealistic 1047 // or incomplete test case in UploadBaseTest (T272328) 1048 if ( $this->mTempPath !== null ) { 1049 $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); 1050 $mime = $magic->guessMimeType( $this->mTempPath ); 1051 if ( $mime !== 'unknown/unknown' ) { 1052 # Get a space separated list of extensions 1053 $mimeExt = $magic->getExtensionFromMimeTypeOrNull( $mime ); 1054 if ( $mimeExt !== null ) { 1055 # Set the extension to the canonical extension 1056 $this->mFinalExtension = $mimeExt; 1057 1058 # Fix up the other variables 1059 $this->mFilteredName .= ".{$this->mFinalExtension}"; 1060 $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName ); 1061 $ext = [ $this->mFinalExtension ]; 1062 } 1063 } 1064 } 1065 } 1066 1067 // Don't allow users to override the list of prohibited file extensions (check file extension) 1068 global $wgCheckFileExtensions, $wgStrictFileExtensions; 1069 global $wgFileExtensions, $wgProhibitedFileExtensions; 1070 1071 $blackListedExtensions = self::checkFileExtensionList( $ext, $wgProhibitedFileExtensions ); 1072 1073 if ( $this->mFinalExtension == '' ) { 1074 $this->mTitleError = self::FILETYPE_MISSING; 1075 $this->mTitle = null; 1076 1077 return $this->mTitle; 1078 } elseif ( $blackListedExtensions || 1079 ( $wgCheckFileExtensions && $wgStrictFileExtensions && 1080 !$this->checkFileExtension( $this->mFinalExtension, $wgFileExtensions ) ) 1081 ) { 1082 $this->mBlackListedExtensions = $blackListedExtensions; 1083 $this->mTitleError = self::FILETYPE_BADTYPE; 1084 $this->mTitle = null; 1085 1086 return $this->mTitle; 1087 } 1088 1089 // Windows may be broken with special characters, see T3780 1090 if ( !preg_match( '/^[\x0-\x7f]*$/', $nt->getText() ) 1091 && !MediaWikiServices::getInstance()->getRepoGroup() 1092 ->getLocalRepo()->backendSupportsUnicodePaths() 1093 ) { 1094 $this->mTitleError = self::WINDOWS_NONASCII_FILENAME; 1095 $this->mTitle = null; 1096 1097 return $this->mTitle; 1098 } 1099 1100 # If there was more than one "extension", reassemble the base 1101 # filename to prevent bogus complaints about length 1102 if ( count( $ext ) > 1 ) { 1103 $iterations = count( $ext ) - 1; 1104 for ( $i = 0; $i < $iterations; $i++ ) { 1105 $partname .= '.' . $ext[$i]; 1106 } 1107 } 1108 1109 if ( strlen( $partname ) < 1 ) { 1110 $this->mTitleError = self::MIN_LENGTH_PARTNAME; 1111 $this->mTitle = null; 1112 1113 return $this->mTitle; 1114 } 1115 1116 $this->mTitle = $nt; 1117 1118 return $this->mTitle; 1119 } 1120 1121 /** 1122 * Return the local file and initializes if necessary. 1123 * 1124 * @stable to override 1125 * @return LocalFile|null 1126 */ 1127 public function getLocalFile() { 1128 if ( $this->mLocalFile === null ) { 1129 $nt = $this->getTitle(); 1130 $this->mLocalFile = $nt === null 1131 ? null 1132 : MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()->newFile( $nt ); 1133 } 1134 1135 return $this->mLocalFile; 1136 } 1137 1138 /** 1139 * @return UploadStashFile|null 1140 */ 1141 public function getStashFile() { 1142 return $this->mStashFile; 1143 } 1144 1145 /** 1146 * Like stashFile(), but respects extensions' wishes to prevent the stashing. verifyUpload() must 1147 * be called before calling this method (unless $isPartial is true). 1148 * 1149 * Upload stash exceptions are also caught and converted to an error status. 1150 * 1151 * @since 1.28 1152 * @stable to override 1153 * @param User $user 1154 * @param bool $isPartial Pass `true` if this is a part of a chunked upload (not a complete file). 1155 * @return Status If successful, value is an UploadStashFile instance 1156 */ 1157 public function tryStashFile( User $user, $isPartial = false ) { 1158 if ( !$isPartial ) { 1159 $error = $this->runUploadStashFileHook( $user ); 1160 if ( $error ) { 1161 return Status::newFatal( ...$error ); 1162 } 1163 } 1164 try { 1165 $file = $this->doStashFile( $user ); 1166 return Status::newGood( $file ); 1167 } catch ( UploadStashException $e ) { 1168 return Status::newFatal( 'uploadstash-exception', get_class( $e ), $e->getMessage() ); 1169 } 1170 } 1171 1172 /** 1173 * @param User $user 1174 * @return array|null Error message and parameters, null if there's no error 1175 */ 1176 protected function runUploadStashFileHook( User $user ) { 1177 $props = $this->mFileProps; 1178 $error = null; 1179 $this->getHookRunner()->onUploadStashFile( $this, $user, $props, $error ); 1180 if ( $error && !is_array( $error ) ) { 1181 $error = [ $error ]; 1182 } 1183 return $error; 1184 } 1185 1186 /** 1187 * Implementation for stashFile() and tryStashFile(). 1188 * 1189 * @stable to override 1190 * @param User|null $user 1191 * @return UploadStashFile Stashed file 1192 */ 1193 protected function doStashFile( User $user = null ) { 1194 $stash = MediaWikiServices::getInstance()->getRepoGroup() 1195 ->getLocalRepo()->getUploadStash( $user ); 1196 $file = $stash->stashFile( $this->mTempPath, $this->getSourceType() ); 1197 $this->mStashFile = $file; 1198 1199 return $file; 1200 } 1201 1202 /** 1203 * If we've modified the upload file we need to manually remove it 1204 * on exit to clean up. 1205 */ 1206 public function cleanupTempFile() { 1207 if ( $this->mRemoveTempFile && $this->tempFileObj ) { 1208 // Delete when all relevant TempFSFile handles go out of scope 1209 wfDebug( __METHOD__ . ": Marked temporary file '{$this->mTempPath}' for removal" ); 1210 $this->tempFileObj->autocollect(); 1211 } 1212 } 1213 1214 public function getTempPath() { 1215 return $this->mTempPath; 1216 } 1217 1218 /** 1219 * Split a file into a base name and all dot-delimited 'extensions' 1220 * on the end. Some web server configurations will fall back to 1221 * earlier pseudo-'extensions' to determine type and execute 1222 * scripts, so the blacklist needs to check them all. 1223 * 1224 * @param string $filename 1225 * @return array [ string, string[] ] 1226 */ 1227 public static function splitExtensions( $filename ) { 1228 $bits = explode( '.', $filename ); 1229 $basename = array_shift( $bits ); 1230 1231 return [ $basename, $bits ]; 1232 } 1233 1234 /** 1235 * Perform case-insensitive match against a list of file extensions. 1236 * Returns true if the extension is in the list. 1237 * 1238 * @param string $ext 1239 * @param array $list 1240 * @return bool 1241 */ 1242 public static function checkFileExtension( $ext, $list ) { 1243 return in_array( strtolower( $ext ), $list ); 1244 } 1245 1246 /** 1247 * Perform case-insensitive match against a list of file extensions. 1248 * Returns an array of matching extensions. 1249 * 1250 * @param string[] $ext 1251 * @param string[] $list 1252 * @return string[] 1253 */ 1254 public static function checkFileExtensionList( $ext, $list ) { 1255 return array_intersect( array_map( 'strtolower', $ext ), $list ); 1256 } 1257 1258 /** 1259 * Checks if the MIME type of the uploaded file matches the file extension. 1260 * 1261 * @param string $mime The MIME type of the uploaded file 1262 * @param string $extension The filename extension that the file is to be served with 1263 * @return bool 1264 */ 1265 public static function verifyExtension( $mime, $extension ) { 1266 $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); 1267 1268 if ( !$mime || $mime == 'unknown' || $mime == 'unknown/unknown' ) { 1269 if ( !$magic->isRecognizableExtension( $extension ) ) { 1270 wfDebug( __METHOD__ . ": passing file with unknown detected mime type; " . 1271 "unrecognized extension '$extension', can't verify" ); 1272 1273 return true; 1274 } else { 1275 wfDebug( __METHOD__ . ": rejecting file with unknown detected mime type; " . 1276 "recognized extension '$extension', so probably invalid file" ); 1277 1278 return false; 1279 } 1280 } 1281 1282 $match = $magic->isMatchingExtension( $extension, $mime ); 1283 1284 if ( $match === null ) { 1285 if ( $magic->getMimeTypesFromExtension( $extension ) !== [] ) { 1286 wfDebug( __METHOD__ . ": No extension known for $mime, but we know a mime for $extension" ); 1287 1288 return false; 1289 } else { 1290 wfDebug( __METHOD__ . ": no file extension known for mime type $mime, passing file" ); 1291 1292 return true; 1293 } 1294 } elseif ( $match ) { 1295 wfDebug( __METHOD__ . ": mime type $mime matches extension $extension, passing file" ); 1296 1297 /** @todo If it's a bitmap, make sure PHP or ImageMagick resp. can handle it! */ 1298 return true; 1299 } else { 1300 wfDebug( __METHOD__ 1301 . ": mime type $mime mismatches file extension $extension, rejecting file" ); 1302 1303 return false; 1304 } 1305 } 1306 1307 /** 1308 * Heuristic for detecting files that *could* contain JavaScript instructions or 1309 * things that may look like HTML to a browser and are thus 1310 * potentially harmful. The present implementation will produce false 1311 * positives in some situations. 1312 * 1313 * @param string $file Pathname to the temporary upload file 1314 * @param string $mime The MIME type of the file 1315 * @param string|null $extension The extension of the file 1316 * @return bool True if the file contains something looking like embedded scripts 1317 */ 1318 public static function detectScript( $file, $mime, $extension ) { 1319 # ugly hack: for text files, always look at the entire file. 1320 # For binary field, just check the first K. 1321 1322 $isText = strpos( $mime, 'text/' ) === 0; 1323 if ( $isText ) { 1324 $chunk = file_get_contents( $file ); 1325 } else { 1326 $fp = fopen( $file, 'rb' ); 1327 if ( !$fp ) { 1328 return false; 1329 } 1330 $chunk = fread( $fp, 1024 ); 1331 fclose( $fp ); 1332 } 1333 1334 $chunk = strtolower( $chunk ); 1335 1336 if ( !$chunk ) { 1337 return false; 1338 } 1339 1340 # decode from UTF-16 if needed (could be used for obfuscation). 1341 if ( substr( $chunk, 0, 2 ) == "\xfe\xff" ) { 1342 $enc = 'UTF-16BE'; 1343 } elseif ( substr( $chunk, 0, 2 ) == "\xff\xfe" ) { 1344 $enc = 'UTF-16LE'; 1345 } else { 1346 $enc = null; 1347 } 1348 1349 if ( $enc !== null ) { 1350 $chunk = iconv( $enc, "ASCII//IGNORE", $chunk ); 1351 } 1352 1353 $chunk = trim( $chunk ); 1354 1355 /** @todo FIXME: Convert from UTF-16 if necessary! */ 1356 wfDebug( __METHOD__ . ": checking for embedded scripts and HTML stuff" ); 1357 1358 # check for HTML doctype 1359 if ( preg_match( "/<!DOCTYPE *X?HTML/i", $chunk ) ) { 1360 return true; 1361 } 1362 1363 // Some browsers will interpret obscure xml encodings as UTF-8, while 1364 // PHP/expat will interpret the given encoding in the xml declaration (T49304) 1365 if ( $extension == 'svg' || strpos( $mime, 'image/svg' ) === 0 ) { 1366 if ( self::checkXMLEncodingMissmatch( $file ) ) { 1367 return true; 1368 } 1369 } 1370 1371 // Quick check for HTML heuristics in old IE and Safari. 1372 // 1373 // The exact heuristics IE uses are checked separately via verifyMimeType(), so we 1374 // don't need them all here as it can cause many false positives. 1375 // 1376 // Check for `<script` and such still to forbid script tags and embedded HTML in SVG: 1377 $tags = [ 1378 '<body', 1379 '<head', 1380 '<html', # also in safari 1381 '<script', # also in safari 1382 ]; 1383 1384 foreach ( $tags as $tag ) { 1385 if ( strpos( $chunk, $tag ) !== false ) { 1386 wfDebug( __METHOD__ . ": found something that may make it be mistaken for html: $tag" ); 1387 1388 return true; 1389 } 1390 } 1391 1392 /* 1393 * look for JavaScript 1394 */ 1395 1396 # resolve entity-refs to look at attributes. may be harsh on big files... cache result? 1397 $chunk = Sanitizer::decodeCharReferences( $chunk ); 1398 1399 # look for script-types 1400 if ( preg_match( '!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!sim', $chunk ) ) { 1401 wfDebug( __METHOD__ . ": found script types" ); 1402 1403 return true; 1404 } 1405 1406 # look for html-style script-urls 1407 if ( preg_match( '!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) { 1408 wfDebug( __METHOD__ . ": found html-style script urls" ); 1409 1410 return true; 1411 } 1412 1413 # look for css-style script-urls 1414 if ( preg_match( '!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) { 1415 wfDebug( __METHOD__ . ": found css-style script urls" ); 1416 1417 return true; 1418 } 1419 1420 wfDebug( __METHOD__ . ": no scripts found" ); 1421 1422 return false; 1423 } 1424 1425 /** 1426 * Check a whitelist of xml encodings that are known not to be interpreted differently 1427 * by the server's xml parser (expat) and some common browsers. 1428 * 1429 * @param string $file Pathname to the temporary upload file 1430 * @return bool True if the file contains an encoding that could be misinterpreted 1431 */ 1432 public static function checkXMLEncodingMissmatch( $file ) { 1433 global $wgSVGMetadataCutoff; 1434 $contents = file_get_contents( $file, false, null, 0, $wgSVGMetadataCutoff ); 1435 $encodingRegex = '!encoding[ \t\n\r]*=[ \t\n\r]*[\'"](.*?)[\'"]!si'; 1436 1437 if ( preg_match( "!<\?xml\b(.*?)\?>!si", $contents, $matches ) ) { 1438 if ( preg_match( $encodingRegex, $matches[1], $encMatch ) 1439 && !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings ) 1440 ) { 1441 wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'" ); 1442 1443 return true; 1444 } 1445 } elseif ( preg_match( "!<\?xml\b!si", $contents ) ) { 1446 // Start of XML declaration without an end in the first $wgSVGMetadataCutoff 1447 // bytes. There shouldn't be a legitimate reason for this to happen. 1448 wfDebug( __METHOD__ . ": Unmatched XML declaration start" ); 1449 1450 return true; 1451 } elseif ( substr( $contents, 0, 4 ) == "\x4C\x6F\xA7\x94" ) { 1452 // EBCDIC encoded XML 1453 wfDebug( __METHOD__ . ": EBCDIC Encoded XML" ); 1454 1455 return true; 1456 } 1457 1458 // It's possible the file is encoded with multi-byte encoding, so re-encode attempt to 1459 // detect the encoding in case is specifies an encoding not whitelisted in self::$safeXmlEncodings 1460 $attemptEncodings = [ 'UTF-16', 'UTF-16BE', 'UTF-32', 'UTF-32BE' ]; 1461 foreach ( $attemptEncodings as $encoding ) { 1462 Wikimedia\suppressWarnings(); 1463 $str = iconv( $encoding, 'UTF-8', $contents ); 1464 Wikimedia\restoreWarnings(); 1465 if ( $str != '' && preg_match( "!<\?xml\b(.*?)\?>!si", $str, $matches ) ) { 1466 if ( preg_match( $encodingRegex, $matches[1], $encMatch ) 1467 && !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings ) 1468 ) { 1469 wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'" ); 1470 1471 return true; 1472 } 1473 } elseif ( $str != '' && preg_match( "!<\?xml\b!si", $str ) ) { 1474 // Start of XML declaration without an end in the first $wgSVGMetadataCutoff 1475 // bytes. There shouldn't be a legitimate reason for this to happen. 1476 wfDebug( __METHOD__ . ": Unmatched XML declaration start" ); 1477 1478 return true; 1479 } 1480 } 1481 1482 return false; 1483 } 1484 1485 /** 1486 * @param string $filename 1487 * @param bool $partial 1488 * @return bool|array 1489 */ 1490 protected function detectScriptInSvg( $filename, $partial ) { 1491 $this->mSVGNSError = false; 1492 $check = new XmlTypeCheck( 1493 $filename, 1494 [ $this, 'checkSvgScriptCallback' ], 1495 true, 1496 [ 1497 'processing_instruction_handler' => [ __CLASS__, 'checkSvgPICallback' ], 1498 'external_dtd_handler' => [ __CLASS__, 'checkSvgExternalDTD' ], 1499 ] 1500 ); 1501 if ( $check->wellFormed !== true ) { 1502 // Invalid xml (T60553) 1503 // But only when non-partial (T67724) 1504 return $partial ? false : [ 'uploadinvalidxml' ]; 1505 } elseif ( $check->filterMatch ) { 1506 if ( $this->mSVGNSError ) { 1507 return [ 'uploadscriptednamespace', $this->mSVGNSError ]; 1508 } 1509 1510 return $check->filterMatchType; 1511 } 1512 1513 return false; 1514 } 1515 1516 /** 1517 * Callback to filter SVG Processing Instructions. 1518 * @param string $target Processing instruction name 1519 * @param string $data Processing instruction attribute and value 1520 * @return bool|array 1521 */ 1522 public static function checkSvgPICallback( $target, $data ) { 1523 // Don't allow external stylesheets (T59550) 1524 if ( preg_match( '/xml-stylesheet/i', $target ) ) { 1525 return [ 'upload-scripted-pi-callback' ]; 1526 } 1527 1528 return false; 1529 } 1530 1531 /** 1532 * Verify that DTD urls referenced are only the standard dtds 1533 * 1534 * Browsers seem to ignore external dtds. However just to be on the 1535 * safe side, only allow dtds from the svg standard. 1536 * 1537 * @param string $type PUBLIC or SYSTEM 1538 * @param string $publicId The well-known public identifier for the dtd 1539 * @param string $systemId The url for the external dtd 1540 * @return bool|array 1541 */ 1542 public static function checkSvgExternalDTD( $type, $publicId, $systemId ) { 1543 // This doesn't include the XHTML+MathML+SVG doctype since we don't 1544 // allow XHTML anyways. 1545 $allowedDTDs = [ 1546 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd', 1547 'http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd', 1548 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd', 1549 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd', 1550 // https://phabricator.wikimedia.org/T168856 1551 'http://www.w3.org/TR/2001/PR-SVG-20010719/DTD/svg10.dtd', 1552 ]; 1553 if ( $type !== 'PUBLIC' 1554 || !in_array( $systemId, $allowedDTDs ) 1555 || strpos( $publicId, "-//W3C//" ) !== 0 1556 ) { 1557 return [ 'upload-scripted-dtd' ]; 1558 } 1559 return false; 1560 } 1561 1562 /** 1563 * @todo Replace this with a whitelist filter! 1564 * @param string $element 1565 * @param array $attribs 1566 * @param string|null $data 1567 * @return bool|array 1568 */ 1569 public function checkSvgScriptCallback( $element, $attribs, $data = null ) { 1570 list( $namespace, $strippedElement ) = $this->splitXmlNamespace( $element ); 1571 1572 // We specifically don't include: 1573 // http://www.w3.org/1999/xhtml (T62771) 1574 static $validNamespaces = [ 1575 '', 1576 'adobe:ns:meta/', 1577 'http://creativecommons.org/ns#', 1578 'http://inkscape.sourceforge.net/dtd/sodipodi-0.dtd', 1579 'http://ns.adobe.com/adobeillustrator/10.0/', 1580 'http://ns.adobe.com/adobesvgviewerextensions/3.0/', 1581 'http://ns.adobe.com/extensibility/1.0/', 1582 'http://ns.adobe.com/flows/1.0/', 1583 'http://ns.adobe.com/illustrator/1.0/', 1584 'http://ns.adobe.com/imagereplacement/1.0/', 1585 'http://ns.adobe.com/pdf/1.3/', 1586 'http://ns.adobe.com/photoshop/1.0/', 1587 'http://ns.adobe.com/saveforweb/1.0/', 1588 'http://ns.adobe.com/variables/1.0/', 1589 'http://ns.adobe.com/xap/1.0/', 1590 'http://ns.adobe.com/xap/1.0/g/', 1591 'http://ns.adobe.com/xap/1.0/g/img/', 1592 'http://ns.adobe.com/xap/1.0/mm/', 1593 'http://ns.adobe.com/xap/1.0/rights/', 1594 'http://ns.adobe.com/xap/1.0/stype/dimensions#', 1595 'http://ns.adobe.com/xap/1.0/stype/font#', 1596 'http://ns.adobe.com/xap/1.0/stype/manifestitem#', 1597 'http://ns.adobe.com/xap/1.0/stype/resourceevent#', 1598 'http://ns.adobe.com/xap/1.0/stype/resourceref#', 1599 'http://ns.adobe.com/xap/1.0/t/pg/', 1600 'http://purl.org/dc/elements/1.1/', 1601 'http://purl.org/dc/elements/1.1', 1602 'http://schemas.microsoft.com/visio/2003/svgextensions/', 1603 'http://sodipodi.sourceforge.net/dtd/sodipodi-0.dtd', 1604 'http://taptrix.com/inkpad/svg_extensions', 1605 'http://web.resource.org/cc/', 1606 'http://www.freesoftware.fsf.org/bkchem/cdml', 1607 'http://www.inkscape.org/namespaces/inkscape', 1608 'http://www.opengis.net/gml', 1609 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 1610 'http://www.w3.org/2000/svg', 1611 'http://www.w3.org/tr/rec-rdf-syntax/', 1612 'http://www.w3.org/2000/01/rdf-schema#', 1613 ]; 1614 1615 // Inkscape mangles namespace definitions created by Adobe Illustrator. 1616 // This is nasty but harmless. (T144827) 1617 $isBuggyInkscape = preg_match( '/^&(#38;)*ns_[a-z_]+;$/', $namespace ); 1618 1619 if ( !( $isBuggyInkscape || in_array( $namespace, $validNamespaces ) ) ) { 1620 wfDebug( __METHOD__ . ": Non-svg namespace '$namespace' in uploaded file." ); 1621 /** @todo Return a status object to a closure in XmlTypeCheck, for MW1.21+ */ 1622 $this->mSVGNSError = $namespace; 1623 1624 return true; 1625 } 1626 1627 /* 1628 * check for elements that can contain javascript 1629 */ 1630 if ( $strippedElement == 'script' ) { 1631 wfDebug( __METHOD__ . ": Found script element '$element' in uploaded file." ); 1632 1633 return [ 'uploaded-script-svg', $strippedElement ]; 1634 } 1635 1636 # e.g., <svg xmlns="http://www.w3.org/2000/svg"> 1637 # <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg> 1638 if ( $strippedElement == 'handler' ) { 1639 wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file." ); 1640 1641 return [ 'uploaded-script-svg', $strippedElement ]; 1642 } 1643 1644 # SVG reported in Feb '12 that used xml:stylesheet to generate javascript block 1645 if ( $strippedElement == 'stylesheet' ) { 1646 wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file." ); 1647 1648 return [ 'uploaded-script-svg', $strippedElement ]; 1649 } 1650 1651 # Block iframes, in case they pass the namespace check 1652 if ( $strippedElement == 'iframe' ) { 1653 wfDebug( __METHOD__ . ": iframe in uploaded file." ); 1654 1655 return [ 'uploaded-script-svg', $strippedElement ]; 1656 } 1657 1658 # Check <style> css 1659 if ( $strippedElement == 'style' 1660 && self::checkCssFragment( Sanitizer::normalizeCss( $data ) ) 1661 ) { 1662 wfDebug( __METHOD__ . ": hostile css in style element." ); 1663 return [ 'uploaded-hostile-svg' ]; 1664 } 1665 1666 foreach ( $attribs as $attrib => $value ) { 1667 $stripped = $this->stripXmlNamespace( $attrib ); 1668 $value = strtolower( $value ); 1669 1670 if ( substr( $stripped, 0, 2 ) == 'on' ) { 1671 wfDebug( __METHOD__ 1672 . ": Found event-handler attribute '$attrib'='$value' in uploaded file." ); 1673 1674 return [ 'uploaded-event-handler-on-svg', $attrib, $value ]; 1675 } 1676 1677 # Do not allow relative links, or unsafe url schemas. 1678 # For <a> tags, only data:, http: and https: and same-document 1679 # fragment links are allowed. For all other tags, only data: 1680 # and fragment are allowed. 1681 if ( $stripped == 'href' 1682 && $value !== '' 1683 && strpos( $value, 'data:' ) !== 0 1684 && strpos( $value, '#' ) !== 0 1685 ) { 1686 if ( !( $strippedElement === 'a' 1687 && preg_match( '!^https?://!i', $value ) ) 1688 ) { 1689 wfDebug( __METHOD__ . ": Found href attribute <$strippedElement " 1690 . "'$attrib'='$value' in uploaded file." ); 1691 1692 return [ 'uploaded-href-attribute-svg', $strippedElement, $attrib, $value ]; 1693 } 1694 } 1695 1696 # only allow data: targets that should be safe. This prevents vectors like, 1697 # image/svg, text/xml, application/xml, and text/html, which can contain scripts 1698 if ( $stripped == 'href' && strncasecmp( 'data:', $value, 5 ) === 0 ) { 1699 // rfc2397 parameters. This is only slightly slower than (;[\w;]+)*. 1700 // phpcs:ignore Generic.Files.LineLength 1701 $parameters = '(?>;[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+=(?>[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+|"(?>[\0-\x0c\x0e-\x21\x23-\x5b\x5d-\x7f]+|\\\\[\0-\x7f])*"))*(?:;base64)?'; 1702 1703 if ( !preg_match( "!^data:\s*image/(gif|jpeg|jpg|png)$parameters,!i", $value ) ) { 1704 wfDebug( __METHOD__ . ": Found href to unwhitelisted data: uri " 1705 . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file." ); 1706 return [ 'uploaded-href-unsafe-target-svg', $strippedElement, $attrib, $value ]; 1707 } 1708 } 1709 1710 # Change href with animate from (http://html5sec.org/#137). 1711 if ( $stripped === 'attributename' 1712 && $strippedElement === 'animate' 1713 && $this->stripXmlNamespace( $value ) == 'href' 1714 ) { 1715 wfDebug( __METHOD__ . ": Found animate that might be changing href using from " 1716 . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file." ); 1717 1718 return [ 'uploaded-animate-svg', $strippedElement, $attrib, $value ]; 1719 } 1720 1721 # use set/animate to add event-handler attribute to parent 1722 if ( ( $strippedElement == 'set' || $strippedElement == 'animate' ) 1723 && $stripped == 'attributename' 1724 && substr( $value, 0, 2 ) == 'on' 1725 ) { 1726 wfDebug( __METHOD__ . ": Found svg setting event-handler attribute with " 1727 . "\"<$strippedElement $stripped='$value'...\" in uploaded file." ); 1728 1729 return [ 'uploaded-setting-event-handler-svg', $strippedElement, $stripped, $value ]; 1730 } 1731 1732 # use set to add href attribute to parent element 1733 if ( $strippedElement == 'set' 1734 && $stripped == 'attributename' 1735 && strpos( $value, 'href' ) !== false 1736 ) { 1737 wfDebug( __METHOD__ . ": Found svg setting href attribute '$value' in uploaded file." ); 1738 1739 return [ 'uploaded-setting-href-svg' ]; 1740 } 1741 1742 # use set to add a remote / data / script target to an element 1743 if ( $strippedElement == 'set' 1744 && $stripped == 'to' 1745 && preg_match( '!(http|https|data|script):!sim', $value ) 1746 ) { 1747 wfDebug( __METHOD__ . ": Found svg setting attribute to '$value' in uploaded file." ); 1748 1749 return [ 'uploaded-wrong-setting-svg', $value ]; 1750 } 1751 1752 # use handler attribute with remote / data / script 1753 if ( $stripped == 'handler' && preg_match( '!(http|https|data|script):!sim', $value ) ) { 1754 wfDebug( __METHOD__ . ": Found svg setting handler with remote/data/script " 1755 . "'$attrib'='$value' in uploaded file." ); 1756 1757 return [ 'uploaded-setting-handler-svg', $attrib, $value ]; 1758 } 1759 1760 # use CSS styles to bring in remote code 1761 if ( $stripped == 'style' 1762 && self::checkCssFragment( Sanitizer::normalizeCss( $value ) ) 1763 ) { 1764 wfDebug( __METHOD__ . ": Found svg setting a style with " 1765 . "remote url '$attrib'='$value' in uploaded file." ); 1766 return [ 'uploaded-remote-url-svg', $attrib, $value ]; 1767 } 1768 1769 # Several attributes can include css, css character escaping isn't allowed 1770 $cssAttrs = [ 'font', 'clip-path', 'fill', 'filter', 'marker', 1771 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke' ]; 1772 if ( in_array( $stripped, $cssAttrs ) 1773 && self::checkCssFragment( $value ) 1774 ) { 1775 wfDebug( __METHOD__ . ": Found svg setting a style with " 1776 . "remote url '$attrib'='$value' in uploaded file." ); 1777 return [ 'uploaded-remote-url-svg', $attrib, $value ]; 1778 } 1779 1780 # image filters can pull in url, which could be svg that executes scripts 1781 # Only allow url( "#foo" ). Do not allow url( http://example.com ) 1782 if ( $strippedElement == 'image' 1783 && $stripped == 'filter' 1784 && preg_match( '!url\s*\(\s*["\']?[^#]!sim', $value ) 1785 ) { 1786 wfDebug( __METHOD__ . ": Found image filter with url: " 1787 . "\"<$strippedElement $stripped='$value'...\" in uploaded file." ); 1788 1789 return [ 'uploaded-image-filter-svg', $strippedElement, $stripped, $value ]; 1790 } 1791 } 1792 1793 return false; // No scripts detected 1794 } 1795 1796 /** 1797 * Check a block of CSS or CSS fragment for anything that looks like 1798 * it is bringing in remote code. 1799 * @param string $value a string of CSS 1800 * @return bool true if the CSS contains an illegal string, false if otherwise 1801 */ 1802 private static function checkCssFragment( $value ) { 1803 # Forbid external stylesheets, for both reliability and to protect viewer's privacy 1804 if ( stripos( $value, '@import' ) !== false ) { 1805 return true; 1806 } 1807 1808 # We allow @font-face to embed fonts with data: urls, so we snip the string 1809 # 'url' out so this case won't match when we check for urls below 1810 $pattern = '!(@font-face\s*{[^}]*src:)url(\("data:;base64,)!im'; 1811 $value = preg_replace( $pattern, '$1$2', $value ); 1812 1813 # Check for remote and executable CSS. Unlike in Sanitizer::checkCss, the CSS 1814 # properties filter and accelerator don't seem to be useful for xss in SVG files. 1815 # Expression and -o-link don't seem to work either, but filtering them here in case. 1816 # Additionally, we catch remote urls like url("http:..., url('http:..., url(http:..., 1817 # but not local ones such as url("#..., url('#..., url(#.... 1818 if ( preg_match( '!expression 1819 | -o-link\s*: 1820 | -o-link-source\s*: 1821 | -o-replace\s*:!imx', $value ) ) { 1822 return true; 1823 } 1824 1825 if ( preg_match_all( 1826 "!(\s*(url|image|image-set)\s*\(\s*[\"']?\s*[^#]+.*?\))!sim", 1827 $value, 1828 $matches 1829 ) !== 0 1830 ) { 1831 # TODO: redo this in one regex. Until then, url("#whatever") matches the first 1832 foreach ( $matches[1] as $match ) { 1833 if ( !preg_match( "!\s*(url|image|image-set)\s*\(\s*(#|'#|\"#)!im", $match ) ) { 1834 return true; 1835 } 1836 } 1837 } 1838 1839 if ( preg_match( '/[\000-\010\013\016-\037\177]/', $value ) ) { 1840 return true; 1841 } 1842 1843 return false; 1844 } 1845 1846 /** 1847 * Divide the element name passed by the xml parser to the callback into URI and prifix. 1848 * @param string $element 1849 * @return array Containing the namespace URI and prefix 1850 */ 1851 private static function splitXmlNamespace( $element ) { 1852 // 'http://www.w3.org/2000/svg:script' -> [ 'http://www.w3.org/2000/svg', 'script' ] 1853 $parts = explode( ':', strtolower( $element ) ); 1854 $name = array_pop( $parts ); 1855 $ns = implode( ':', $parts ); 1856 1857 return [ $ns, $name ]; 1858 } 1859 1860 /** 1861 * @param string $name 1862 * @return string 1863 */ 1864 private function stripXmlNamespace( $name ) { 1865 // 'http://www.w3.org/2000/svg:script' -> 'script' 1866 $parts = explode( ':', strtolower( $name ) ); 1867 1868 return array_pop( $parts ); 1869 } 1870 1871 /** 1872 * Generic wrapper function for a virus scanner program. 1873 * This relies on the $wgAntivirus and $wgAntivirusSetup variables. 1874 * $wgAntivirusRequired may be used to deny upload if the scan fails. 1875 * 1876 * @param string $file Pathname to the temporary upload file 1877 * @return bool|null|string False if not virus is found, null if the scan fails or is disabled, 1878 * or a string containing feedback from the virus scanner if a virus was found. 1879 * If textual feedback is missing but a virus was found, this function returns true. 1880 */ 1881 public static function detectVirus( $file ) { 1882 global $wgAntivirus, $wgAntivirusSetup, $wgAntivirusRequired, $wgOut; 1883 1884 if ( !$wgAntivirus ) { 1885 wfDebug( __METHOD__ . ": virus scanner disabled" ); 1886 1887 return null; 1888 } 1889 1890 if ( !$wgAntivirusSetup[$wgAntivirus] ) { 1891 wfDebug( __METHOD__ . ": unknown virus scanner: $wgAntivirus" ); 1892 $wgOut->wrapWikiMsg( "<div class=\"error\">\n$1\n</div>", 1893 [ 'virus-badscanner', $wgAntivirus ] ); 1894 1895 return wfMessage( 'virus-unknownscanner' )->text() . " $wgAntivirus"; 1896 } 1897 1898 # look up scanner configuration 1899 $command = $wgAntivirusSetup[$wgAntivirus]['command']; 1900 $exitCodeMap = $wgAntivirusSetup[$wgAntivirus]['codemap']; 1901 $msgPattern = $wgAntivirusSetup[$wgAntivirus]['messagepattern'] ?? null; 1902 1903 if ( strpos( $command, "%f" ) === false ) { 1904 # simple pattern: append file to scan 1905 $command .= " " . Shell::escape( $file ); 1906 } else { 1907 # complex pattern: replace "%f" with file to scan 1908 $command = str_replace( "%f", Shell::escape( $file ), $command ); 1909 } 1910 1911 wfDebug( __METHOD__ . ": running virus scan: $command " ); 1912 1913 # execute virus scanner 1914 $exitCode = false; 1915 1916 # NOTE: there's a 50 line workaround to make stderr redirection work on windows, too. 1917 # that does not seem to be worth the pain. 1918 # Ask me (Duesentrieb) about it if it's ever needed. 1919 $output = wfShellExecWithStderr( $command, $exitCode ); 1920 1921 # map exit code to AV_xxx constants. 1922 $mappedCode = $exitCode; 1923 if ( $exitCodeMap ) { 1924 if ( isset( $exitCodeMap[$exitCode] ) ) { 1925 $mappedCode = $exitCodeMap[$exitCode]; 1926 } elseif ( isset( $exitCodeMap["*"] ) ) { 1927 $mappedCode = $exitCodeMap["*"]; 1928 } 1929 } 1930 1931 /* NB: AV_NO_VIRUS is 0 but AV_SCAN_FAILED is false, 1932 * so we need the strict equalities === and thus can't use a switch here 1933 */ 1934 if ( $mappedCode === AV_SCAN_FAILED ) { 1935 # scan failed (code was mapped to false by $exitCodeMap) 1936 wfDebug( __METHOD__ . ": failed to scan $file (code $exitCode)." ); 1937 1938 $output = $wgAntivirusRequired 1939 ? wfMessage( 'virus-scanfailed', [ $exitCode ] )->text() 1940 : null; 1941 } elseif ( $mappedCode === AV_SCAN_ABORTED ) { 1942 # scan failed because filetype is unknown (probably imune) 1943 wfDebug( __METHOD__ . ": unsupported file type $file (code $exitCode)." ); 1944 $output = null; 1945 } elseif ( $mappedCode === AV_NO_VIRUS ) { 1946 # no virus found 1947 wfDebug( __METHOD__ . ": file passed virus scan." ); 1948 $output = false; 1949 } else { 1950 $output = trim( $output ); 1951 1952 if ( !$output ) { 1953 $output = true; # if there's no output, return true 1954 } elseif ( $msgPattern ) { 1955 $groups = []; 1956 if ( preg_match( $msgPattern, $output, $groups ) && $groups[1] ) { 1957 $output = $groups[1]; 1958 } 1959 } 1960 1961 wfDebug( __METHOD__ . ": FOUND VIRUS! scanner feedback: $output" ); 1962 } 1963 1964 return $output; 1965 } 1966 1967 /** 1968 * Check if there's an overwrite conflict and, if so, if restrictions 1969 * forbid this user from performing the upload. 1970 * 1971 * @param Authority $performer 1972 * 1973 * @return bool|array 1974 */ 1975 private function checkOverwrite( Authority $performer ) { 1976 // First check whether the local file can be overwritten 1977 $file = $this->getLocalFile(); 1978 $file->load( File::READ_LATEST ); 1979 if ( $file->exists() ) { 1980 if ( !self::userCanReUpload( $performer, $file ) ) { 1981 return [ 'fileexists-forbidden', $file->getName() ]; 1982 } else { 1983 return true; 1984 } 1985 } 1986 1987 $services = MediaWikiServices::getInstance(); 1988 1989 /* Check shared conflicts: if the local file does not exist, but 1990 * RepoGroup::findFile finds a file, it exists in a shared repository. 1991 */ 1992 $file = $services->getRepoGroup()->findFile( $this->getTitle(), [ 'latest' => true ] ); 1993 if ( $file && !$performer->isAllowed( 'reupload-shared' ) 1994 ) { 1995 return [ 'fileexists-shared-forbidden', $file->getName() ]; 1996 } 1997 1998 return true; 1999 } 2000 2001 /** 2002 * Check if a user is the last uploader 2003 * 2004 * @param Authority $performer 2005 * @param File $img 2006 * @return bool 2007 */ 2008 public static function userCanReUpload( Authority $performer, File $img ) { 2009 if ( $performer->isAllowed( 'reupload' ) ) { 2010 return true; // non-conditional 2011 } elseif ( !$performer->isAllowed( 'reupload-own' ) ) { 2012 return false; 2013 } 2014 2015 if ( !( $img instanceof LocalFile ) ) { 2016 return false; 2017 } 2018 2019 return $performer->getUser()->equals( $img->getUploader( File::RAW ) ); 2020 } 2021 2022 /** 2023 * Helper function that does various existence checks for a file. 2024 * The following checks are performed: 2025 * - The file exists 2026 * - Article with the same name as the file exists 2027 * - File exists with normalized extension 2028 * - The file looks like a thumbnail and the original exists 2029 * 2030 * @param File $file The File object to check 2031 * @return array|bool False if the file does not exist, else an array 2032 */ 2033 public static function getExistsWarning( $file ) { 2034 if ( $file->exists() ) { 2035 return [ 'warning' => 'exists', 'file' => $file ]; 2036 } 2037 2038 if ( $file->getTitle()->getArticleID() ) { 2039 return [ 'warning' => 'page-exists', 'file' => $file ]; 2040 } 2041 2042 if ( !strpos( $file->getName(), '.' ) ) { 2043 $partname = $file->getName(); 2044 $extension = ''; 2045 } else { 2046 $n = strrpos( $file->getName(), '.' ); 2047 $extension = substr( $file->getName(), $n + 1 ); 2048 $partname = substr( $file->getName(), 0, $n ); 2049 } 2050 $normalizedExtension = File::normalizeExtension( $extension ); 2051 $localRepo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo(); 2052 2053 if ( $normalizedExtension != $extension ) { 2054 // We're not using the normalized form of the extension. 2055 // Normal form is lowercase, using most common of alternate 2056 // extensions (eg 'jpg' rather than 'JPEG'). 2057 2058 // Check for another file using the normalized form... 2059 $nt_lc = Title::makeTitle( NS_FILE, "{$partname}.{$normalizedExtension}" ); 2060 $file_lc = $localRepo->newFile( $nt_lc ); 2061 2062 if ( $file_lc->exists() ) { 2063 return [ 2064 'warning' => 'exists-normalized', 2065 'file' => $file, 2066 'normalizedFile' => $file_lc 2067 ]; 2068 } 2069 } 2070 2071 // Check for files with the same name but a different extension 2072 $similarFiles = $localRepo->findFilesByPrefix( "{$partname}.", 1 ); 2073 if ( count( $similarFiles ) ) { 2074 return [ 2075 'warning' => 'exists-normalized', 2076 'file' => $file, 2077 'normalizedFile' => $similarFiles[0], 2078 ]; 2079 } 2080 2081 if ( self::isThumbName( $file->getName() ) ) { 2082 # Check for filenames like 50px- or 180px-, these are mostly thumbnails 2083 $nt_thb = Title::newFromText( 2084 substr( $partname, strpos( $partname, '-' ) + 1 ) . '.' . $extension, 2085 NS_FILE 2086 ); 2087 $file_thb = $localRepo->newFile( $nt_thb ); 2088 if ( $file_thb->exists() ) { 2089 return [ 2090 'warning' => 'thumb', 2091 'file' => $file, 2092 'thumbFile' => $file_thb 2093 ]; 2094 } else { 2095 // File does not exist, but we just don't like the name 2096 return [ 2097 'warning' => 'thumb-name', 2098 'file' => $file, 2099 'thumbFile' => $file_thb 2100 ]; 2101 } 2102 } 2103 2104 foreach ( self::getFilenamePrefixBlacklist() as $prefix ) { 2105 if ( substr( $partname, 0, strlen( $prefix ) ) == $prefix ) { 2106 return [ 2107 'warning' => 'bad-prefix', 2108 'file' => $file, 2109 'prefix' => $prefix 2110 ]; 2111 } 2112 } 2113 2114 return false; 2115 } 2116 2117 /** 2118 * Helper function that checks whether the filename looks like a thumbnail 2119 * @param string $filename 2120 * @return bool 2121 */ 2122 public static function isThumbName( $filename ) { 2123 $n = strrpos( $filename, '.' ); 2124 $partname = $n ? substr( $filename, 0, $n ) : $filename; 2125 2126 return ( 2127 substr( $partname, 3, 3 ) == 'px-' || 2128 substr( $partname, 2, 3 ) == 'px-' 2129 ) && 2130 preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) ); 2131 } 2132 2133 /** 2134 * Get a list of blacklisted filename prefixes from [[MediaWiki:Filename-prefix-blacklist]] 2135 * 2136 * @return string[] List of prefixes 2137 */ 2138 public static function getFilenamePrefixBlacklist() { 2139 $list = []; 2140 $message = wfMessage( 'filename-prefix-blacklist' )->inContentLanguage(); 2141 if ( !$message->isDisabled() ) { 2142 $lines = explode( "\n", $message->plain() ); 2143 foreach ( $lines as $line ) { 2144 // Remove comment lines 2145 $comment = substr( trim( $line ), 0, 1 ); 2146 if ( $comment == '#' || $comment == '' ) { 2147 continue; 2148 } 2149 // Remove additional comments after a prefix 2150 $comment = strpos( $line, '#' ); 2151 if ( $comment > 0 ) { 2152 $line = substr( $line, 0, $comment - 1 ); 2153 } 2154 $list[] = trim( $line ); 2155 } 2156 } 2157 2158 return $list; 2159 } 2160 2161 /** 2162 * Gets image info about the file just uploaded. 2163 * 2164 * Also has the effect of setting metadata to be an 'indexed tag name' in 2165 * returned API result if 'metadata' was requested. Oddly, we have to pass 2166 * the "result" object down just so it can do that with the appropriate 2167 * format, presumably. 2168 * 2169 * @param ApiResult $result 2170 * @return array Image info 2171 */ 2172 public function getImageInfo( $result ) { 2173 $localFile = $this->getLocalFile(); 2174 $stashFile = $this->getStashFile(); 2175 // Calling a different API module depending on whether the file was stashed is less than optimal. 2176 // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored. 2177 if ( $stashFile ) { 2178 $imParam = ApiQueryStashImageInfo::getPropertyNames(); 2179 $info = ApiQueryStashImageInfo::getInfo( $stashFile, array_fill_keys( $imParam, true ), $result ); 2180 } else { 2181 $imParam = ApiQueryImageInfo::getPropertyNames(); 2182 $info = ApiQueryImageInfo::getInfo( $localFile, array_fill_keys( $imParam, true ), $result ); 2183 } 2184 2185 return $info; 2186 } 2187 2188 /** 2189 * @param array $error 2190 * @return Status 2191 */ 2192 public function convertVerifyErrorToStatus( $error ) { 2193 $code = $error['status']; 2194 unset( $code['status'] ); 2195 2196 return Status::newFatal( $this->getVerificationErrorCode( $code ), $error ); 2197 } 2198 2199 /** 2200 * Get MediaWiki's maximum uploaded file size for given type of upload, based on 2201 * $wgMaxUploadSize. 2202 * 2203 * @param null|string $forType 2204 * @return int 2205 */ 2206 public static function getMaxUploadSize( $forType = null ) { 2207 global $wgMaxUploadSize; 2208 2209 if ( is_array( $wgMaxUploadSize ) ) { 2210 if ( $forType !== null && isset( $wgMaxUploadSize[$forType] ) ) { 2211 return $wgMaxUploadSize[$forType]; 2212 } else { 2213 return $wgMaxUploadSize['*']; 2214 } 2215 } else { 2216 return intval( $wgMaxUploadSize ); 2217 } 2218 } 2219 2220 /** 2221 * Get the PHP maximum uploaded file size, based on ini settings. If there is no limit or the 2222 * limit can't be guessed, returns a very large number (PHP_INT_MAX). 2223 * 2224 * @since 1.27 2225 * @return int 2226 */ 2227 public static function getMaxPhpUploadSize() { 2228 $phpMaxFileSize = wfShorthandToInteger( 2229 ini_get( 'upload_max_filesize' ), 2230 PHP_INT_MAX 2231 ); 2232 $phpMaxPostSize = wfShorthandToInteger( 2233 ini_get( 'post_max_size' ), 2234 PHP_INT_MAX 2235 ) ?: PHP_INT_MAX; 2236 return min( $phpMaxFileSize, $phpMaxPostSize ); 2237 } 2238 2239 /** 2240 * Get the current status of a chunked upload (used for polling) 2241 * 2242 * The value will be read from cache. 2243 * 2244 * @param UserIdentity $user 2245 * @param string $statusKey 2246 * @return Status[]|bool 2247 */ 2248 public static function getSessionStatus( UserIdentity $user, $statusKey ) { 2249 $store = self::getUploadSessionStore(); 2250 $key = self::getUploadSessionKey( $store, $user, $statusKey ); 2251 2252 return $store->get( $key ); 2253 } 2254 2255 /** 2256 * Set the current status of a chunked upload (used for polling) 2257 * 2258 * The value will be set in cache for 1 day 2259 * 2260 * Avoid triggering this method on HTTP GET/HEAD requests 2261 * 2262 * @param UserIdentity $user 2263 * @param string $statusKey 2264 * @param array|bool $value 2265 * @return void 2266 */ 2267 public static function setSessionStatus( UserIdentity $user, $statusKey, $value ) { 2268 $store = self::getUploadSessionStore(); 2269 $key = self::getUploadSessionKey( $store, $user, $statusKey ); 2270 2271 if ( $value === false ) { 2272 $store->delete( $key ); 2273 } else { 2274 $store->set( $key, $value, $store::TTL_DAY ); 2275 } 2276 } 2277 2278 /** 2279 * @param BagOStuff $store 2280 * @param UserIdentity $user 2281 * @param string $statusKey 2282 * @return string 2283 */ 2284 private static function getUploadSessionKey( BagOStuff $store, UserIdentity $user, $statusKey ) { 2285 return $store->makeKey( 2286 'uploadstatus', 2287 $user->getId() ?: md5( $user->getName() ), 2288 $statusKey 2289 ); 2290 } 2291 2292 /** 2293 * @return BagOStuff 2294 */ 2295 private static function getUploadSessionStore() { 2296 return ObjectCache::getInstance( 'db-replicated' ); 2297 } 2298} 2299