[ Index ] |
PHP Cross Reference of MediaWiki-1.24.0 |
[Summary view] [Print] [Text view]
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 24 /** 25 * @defgroup Upload Upload related 26 */ 27 28 /** 29 * @ingroup Upload 30 * 31 * UploadBase and subclasses are the backend of MediaWiki's file uploads. 32 * The frontends are formed by ApiUpload and SpecialUpload. 33 * 34 * @author Brion Vibber 35 * @author Bryan Tong Minh 36 * @author Michael Dale 37 */ 38 abstract class UploadBase { 39 protected $mTempPath; 40 protected $mDesiredDestName, $mDestName, $mRemoveTempFile, $mSourceType; 41 protected $mTitle = false, $mTitleError = 0; 42 protected $mFilteredName, $mFinalExtension; 43 protected $mLocalFile, $mFileSize, $mFileProps; 44 protected $mBlackListedExtensions; 45 protected $mJavaDetected, $mSVGNSError; 46 47 protected static $safeXmlEncodings = array( 48 'UTF-8', 49 'ISO-8859-1', 50 'ISO-8859-2', 51 'UTF-16', 52 'UTF-32' 53 ); 54 55 const SUCCESS = 0; 56 const OK = 0; 57 const EMPTY_FILE = 3; 58 const MIN_LENGTH_PARTNAME = 4; 59 const ILLEGAL_FILENAME = 5; 60 const OVERWRITE_EXISTING_FILE = 7; # Not used anymore; handled by verifyTitlePermissions() 61 const FILETYPE_MISSING = 8; 62 const FILETYPE_BADTYPE = 9; 63 const VERIFICATION_ERROR = 10; 64 65 # HOOK_ABORTED is the new name of UPLOAD_VERIFICATION_ERROR 66 const UPLOAD_VERIFICATION_ERROR = 11; 67 const HOOK_ABORTED = 11; 68 const FILE_TOO_LARGE = 12; 69 const WINDOWS_NONASCII_FILENAME = 13; 70 const FILENAME_TOO_LONG = 14; 71 72 const SESSION_STATUS_KEY = 'wsUploadStatusData'; 73 74 /** 75 * @param int $error 76 * @return string 77 */ 78 public function getVerificationErrorCode( $error ) { 79 $code_to_status = array( 80 self::EMPTY_FILE => 'empty-file', 81 self::FILE_TOO_LARGE => 'file-too-large', 82 self::FILETYPE_MISSING => 'filetype-missing', 83 self::FILETYPE_BADTYPE => 'filetype-banned', 84 self::MIN_LENGTH_PARTNAME => 'filename-tooshort', 85 self::ILLEGAL_FILENAME => 'illegal-filename', 86 self::OVERWRITE_EXISTING_FILE => 'overwrite', 87 self::VERIFICATION_ERROR => 'verification-error', 88 self::HOOK_ABORTED => 'hookaborted', 89 self::WINDOWS_NONASCII_FILENAME => 'windows-nonascii-filename', 90 self::FILENAME_TOO_LONG => 'filename-toolong', 91 ); 92 if ( isset( $code_to_status[$error] ) ) { 93 return $code_to_status[$error]; 94 } 95 96 return 'unknown-error'; 97 } 98 99 /** 100 * Returns true if uploads are enabled. 101 * Can be override by subclasses. 102 * @return bool 103 */ 104 public static function isEnabled() { 105 global $wgEnableUploads; 106 107 if ( !$wgEnableUploads ) { 108 return false; 109 } 110 111 # Check php's file_uploads setting 112 return wfIsHHVM() || wfIniGetBool( 'file_uploads' ); 113 } 114 115 /** 116 * Returns true if the user can use this upload module or else a string 117 * identifying the missing permission. 118 * Can be overridden by subclasses. 119 * 120 * @param User $user 121 * @return bool|string 122 */ 123 public static function isAllowed( $user ) { 124 foreach ( array( 'upload', 'edit' ) as $permission ) { 125 if ( !$user->isAllowed( $permission ) ) { 126 return $permission; 127 } 128 } 129 130 return true; 131 } 132 133 // Upload handlers. Should probably just be a global. 134 private static $uploadHandlers = array( 'Stash', 'File', 'Url' ); 135 136 /** 137 * Create a form of UploadBase depending on wpSourceType and initializes it 138 * 139 * @param WebRequest $request 140 * @param string|null $type 141 * @return null|UploadBase 142 */ 143 public static function createFromRequest( &$request, $type = null ) { 144 $type = $type ? $type : $request->getVal( 'wpSourceType', 'File' ); 145 146 if ( !$type ) { 147 return null; 148 } 149 150 // Get the upload class 151 $type = ucfirst( $type ); 152 153 // Give hooks the chance to handle this request 154 $className = null; 155 wfRunHooks( 'UploadCreateFromRequest', array( $type, &$className ) ); 156 if ( is_null( $className ) ) { 157 $className = 'UploadFrom' . $type; 158 wfDebug( __METHOD__ . ": class name: $className\n" ); 159 if ( !in_array( $type, self::$uploadHandlers ) ) { 160 return null; 161 } 162 } 163 164 // Check whether this upload class is enabled 165 if ( !call_user_func( array( $className, 'isEnabled' ) ) ) { 166 return null; 167 } 168 169 // Check whether the request is valid 170 if ( !call_user_func( array( $className, 'isValidRequest' ), $request ) ) { 171 return null; 172 } 173 174 /** @var UploadBase $handler */ 175 $handler = new $className; 176 177 $handler->initializeFromRequest( $request ); 178 179 return $handler; 180 } 181 182 /** 183 * Check whether a request if valid for this handler 184 * @param WebRequest $request 185 * @return bool 186 */ 187 public static function isValidRequest( $request ) { 188 return false; 189 } 190 191 public function __construct() { 192 } 193 194 /** 195 * Returns the upload type. Should be overridden by child classes 196 * 197 * @since 1.18 198 * @return string 199 */ 200 public function getSourceType() { 201 return null; 202 } 203 204 /** 205 * Initialize the path information 206 * @param string $name The desired destination name 207 * @param string $tempPath The temporary path 208 * @param int $fileSize The file size 209 * @param bool $removeTempFile (false) remove the temporary file? 210 * @throws MWException 211 */ 212 public function initializePathInfo( $name, $tempPath, $fileSize, $removeTempFile = false ) { 213 $this->mDesiredDestName = $name; 214 if ( FileBackend::isStoragePath( $tempPath ) ) { 215 throw new MWException( __METHOD__ . " given storage path `$tempPath`." ); 216 } 217 $this->mTempPath = $tempPath; 218 $this->mFileSize = $fileSize; 219 $this->mRemoveTempFile = $removeTempFile; 220 } 221 222 /** 223 * Initialize from a WebRequest. Override this in a subclass. 224 * 225 * @param WebRequest $request 226 */ 227 abstract public function initializeFromRequest( &$request ); 228 229 /** 230 * Fetch the file. Usually a no-op 231 * @return Status 232 */ 233 public function fetchFile() { 234 return Status::newGood(); 235 } 236 237 /** 238 * Return true if the file is empty 239 * @return bool 240 */ 241 public function isEmptyFile() { 242 return empty( $this->mFileSize ); 243 } 244 245 /** 246 * Return the file size 247 * @return int 248 */ 249 public function getFileSize() { 250 return $this->mFileSize; 251 } 252 253 /** 254 * Get the base 36 SHA1 of the file 255 * @return string 256 */ 257 public function getTempFileSha1Base36() { 258 return FSFile::getSha1Base36FromPath( $this->mTempPath ); 259 } 260 261 /** 262 * @param string $srcPath The source path 263 * @return string|bool The real path if it was a virtual URL Returns false on failure 264 */ 265 function getRealPath( $srcPath ) { 266 wfProfileIn( __METHOD__ ); 267 $repo = RepoGroup::singleton()->getLocalRepo(); 268 if ( $repo->isVirtualUrl( $srcPath ) ) { 269 /** @todo Just make uploads work with storage paths UploadFromStash 270 * loads files via virtual URLs. 271 */ 272 $tmpFile = $repo->getLocalCopy( $srcPath ); 273 if ( $tmpFile ) { 274 $tmpFile->bind( $this ); // keep alive with $this 275 } 276 $path = $tmpFile ? $tmpFile->getPath() : false; 277 } else { 278 $path = $srcPath; 279 } 280 wfProfileOut( __METHOD__ ); 281 282 return $path; 283 } 284 285 /** 286 * Verify whether the upload is sane. 287 * @return mixed Const self::OK or else an array with error information 288 */ 289 public function verifyUpload() { 290 wfProfileIn( __METHOD__ ); 291 292 /** 293 * If there was no filename or a zero size given, give up quick. 294 */ 295 if ( $this->isEmptyFile() ) { 296 wfProfileOut( __METHOD__ ); 297 298 return array( 'status' => self::EMPTY_FILE ); 299 } 300 301 /** 302 * Honor $wgMaxUploadSize 303 */ 304 $maxSize = self::getMaxUploadSize( $this->getSourceType() ); 305 if ( $this->mFileSize > $maxSize ) { 306 wfProfileOut( __METHOD__ ); 307 308 return array( 309 'status' => self::FILE_TOO_LARGE, 310 'max' => $maxSize, 311 ); 312 } 313 314 /** 315 * Look at the contents of the file; if we can recognize the 316 * type but it's corrupt or data of the wrong type, we should 317 * probably not accept it. 318 */ 319 $verification = $this->verifyFile(); 320 if ( $verification !== true ) { 321 wfProfileOut( __METHOD__ ); 322 323 return array( 324 'status' => self::VERIFICATION_ERROR, 325 'details' => $verification 326 ); 327 } 328 329 /** 330 * Make sure this file can be created 331 */ 332 $result = $this->validateName(); 333 if ( $result !== true ) { 334 wfProfileOut( __METHOD__ ); 335 336 return $result; 337 } 338 339 $error = ''; 340 if ( !wfRunHooks( 'UploadVerification', 341 array( $this->mDestName, $this->mTempPath, &$error ) ) 342 ) { 343 wfProfileOut( __METHOD__ ); 344 345 return array( 'status' => self::HOOK_ABORTED, 'error' => $error ); 346 } 347 348 wfProfileOut( __METHOD__ ); 349 350 return array( 'status' => self::OK ); 351 } 352 353 /** 354 * Verify that the name is valid and, if necessary, that we can overwrite 355 * 356 * @return mixed True if valid, otherwise and array with 'status' 357 * and other keys 358 */ 359 public function validateName() { 360 $nt = $this->getTitle(); 361 if ( is_null( $nt ) ) { 362 $result = array( 'status' => $this->mTitleError ); 363 if ( $this->mTitleError == self::ILLEGAL_FILENAME ) { 364 $result['filtered'] = $this->mFilteredName; 365 } 366 if ( $this->mTitleError == self::FILETYPE_BADTYPE ) { 367 $result['finalExt'] = $this->mFinalExtension; 368 if ( count( $this->mBlackListedExtensions ) ) { 369 $result['blacklistedExt'] = $this->mBlackListedExtensions; 370 } 371 } 372 373 return $result; 374 } 375 $this->mDestName = $this->getLocalFile()->getName(); 376 377 return true; 378 } 379 380 /** 381 * Verify the MIME type. 382 * 383 * @note Only checks that it is not an evil MIME. The "does it have 384 * correct extension given its MIME type?" check is in verifyFile. 385 * in `verifyFile()` that MIME type and file extension correlate. 386 * @param string $mime Representing the MIME 387 * @return mixed True if the file is verified, an array otherwise 388 */ 389 protected function verifyMimeType( $mime ) { 390 global $wgVerifyMimeType; 391 wfProfileIn( __METHOD__ ); 392 if ( $wgVerifyMimeType ) { 393 wfDebug( "mime: <$mime> extension: <{$this->mFinalExtension}>\n" ); 394 global $wgMimeTypeBlacklist; 395 if ( $this->checkFileExtension( $mime, $wgMimeTypeBlacklist ) ) { 396 wfProfileOut( __METHOD__ ); 397 398 return array( 'filetype-badmime', $mime ); 399 } 400 401 # Check what Internet Explorer would detect 402 $fp = fopen( $this->mTempPath, 'rb' ); 403 $chunk = fread( $fp, 256 ); 404 fclose( $fp ); 405 406 $magic = MimeMagic::singleton(); 407 $extMime = $magic->guessTypesForExtension( $this->mFinalExtension ); 408 $ieTypes = $magic->getIEMimeTypes( $this->mTempPath, $chunk, $extMime ); 409 foreach ( $ieTypes as $ieType ) { 410 if ( $this->checkFileExtension( $ieType, $wgMimeTypeBlacklist ) ) { 411 wfProfileOut( __METHOD__ ); 412 413 return array( 'filetype-bad-ie-mime', $ieType ); 414 } 415 } 416 } 417 418 wfProfileOut( __METHOD__ ); 419 420 return true; 421 } 422 423 /** 424 * Verifies that it's ok to include the uploaded file 425 * 426 * @return mixed True of the file is verified, array otherwise. 427 */ 428 protected function verifyFile() { 429 global $wgVerifyMimeType; 430 wfProfileIn( __METHOD__ ); 431 432 $status = $this->verifyPartialFile(); 433 if ( $status !== true ) { 434 wfProfileOut( __METHOD__ ); 435 436 return $status; 437 } 438 439 $this->mFileProps = FSFile::getPropsFromPath( $this->mTempPath, $this->mFinalExtension ); 440 $mime = $this->mFileProps['mime']; 441 442 if ( $wgVerifyMimeType ) { 443 # XXX: Missing extension will be caught by validateName() via getTitle() 444 if ( $this->mFinalExtension != '' && !$this->verifyExtension( $mime, $this->mFinalExtension ) ) { 445 wfProfileOut( __METHOD__ ); 446 447 return array( 'filetype-mime-mismatch', $this->mFinalExtension, $mime ); 448 } 449 } 450 451 $handler = MediaHandler::getHandler( $mime ); 452 if ( $handler ) { 453 $handlerStatus = $handler->verifyUpload( $this->mTempPath ); 454 if ( !$handlerStatus->isOK() ) { 455 $errors = $handlerStatus->getErrorsArray(); 456 wfProfileOut( __METHOD__ ); 457 458 return reset( $errors ); 459 } 460 } 461 462 wfRunHooks( 'UploadVerifyFile', array( $this, $mime, &$status ) ); 463 if ( $status !== true ) { 464 wfProfileOut( __METHOD__ ); 465 466 return $status; 467 } 468 469 wfDebug( __METHOD__ . ": all clear; passing.\n" ); 470 wfProfileOut( __METHOD__ ); 471 472 return true; 473 } 474 475 /** 476 * A verification routine suitable for partial files 477 * 478 * Runs the blacklist checks, but not any checks that may 479 * assume the entire file is present. 480 * 481 * @return mixed True for valid or array with error message key. 482 */ 483 protected function verifyPartialFile() { 484 global $wgAllowJavaUploads, $wgDisableUploadScriptChecks; 485 wfProfileIn( __METHOD__ ); 486 487 # getTitle() sets some internal parameters like $this->mFinalExtension 488 $this->getTitle(); 489 490 $this->mFileProps = FSFile::getPropsFromPath( $this->mTempPath, $this->mFinalExtension ); 491 492 # check MIME type, if desired 493 $mime = $this->mFileProps['file-mime']; 494 $status = $this->verifyMimeType( $mime ); 495 if ( $status !== true ) { 496 wfProfileOut( __METHOD__ ); 497 498 return $status; 499 } 500 501 # check for htmlish code and javascript 502 if ( !$wgDisableUploadScriptChecks ) { 503 if ( self::detectScript( $this->mTempPath, $mime, $this->mFinalExtension ) ) { 504 wfProfileOut( __METHOD__ ); 505 506 return array( 'uploadscripted' ); 507 } 508 if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) { 509 $svgStatus = $this->detectScriptInSvg( $this->mTempPath ); 510 if ( $svgStatus !== false ) { 511 wfProfileOut( __METHOD__ ); 512 513 return $svgStatus; 514 } 515 } 516 } 517 518 # Check for Java applets, which if uploaded can bypass cross-site 519 # restrictions. 520 if ( !$wgAllowJavaUploads ) { 521 $this->mJavaDetected = false; 522 $zipStatus = ZipDirectoryReader::read( $this->mTempPath, 523 array( $this, 'zipEntryCallback' ) ); 524 if ( !$zipStatus->isOK() ) { 525 $errors = $zipStatus->getErrorsArray(); 526 $error = reset( $errors ); 527 if ( $error[0] !== 'zip-wrong-format' ) { 528 wfProfileOut( __METHOD__ ); 529 530 return $error; 531 } 532 } 533 if ( $this->mJavaDetected ) { 534 wfProfileOut( __METHOD__ ); 535 536 return array( 'uploadjava' ); 537 } 538 } 539 540 # Scan the uploaded file for viruses 541 $virus = $this->detectVirus( $this->mTempPath ); 542 if ( $virus ) { 543 wfProfileOut( __METHOD__ ); 544 545 return array( 'uploadvirus', $virus ); 546 } 547 548 wfProfileOut( __METHOD__ ); 549 550 return true; 551 } 552 553 /** 554 * Callback for ZipDirectoryReader to detect Java class files. 555 * 556 * @param array $entry 557 */ 558 function zipEntryCallback( $entry ) { 559 $names = array( $entry['name'] ); 560 561 // If there is a null character, cut off the name at it, because JDK's 562 // ZIP_GetEntry() uses strcmp() if the name hashes match. If a file name 563 // were constructed which had ".class\0" followed by a string chosen to 564 // make the hash collide with the truncated name, that file could be 565 // returned in response to a request for the .class file. 566 $nullPos = strpos( $entry['name'], "\000" ); 567 if ( $nullPos !== false ) { 568 $names[] = substr( $entry['name'], 0, $nullPos ); 569 } 570 571 // If there is a trailing slash in the file name, we have to strip it, 572 // because that's what ZIP_GetEntry() does. 573 if ( preg_grep( '!\.class/?$!', $names ) ) { 574 $this->mJavaDetected = true; 575 } 576 } 577 578 /** 579 * Alias for verifyTitlePermissions. The function was originally 580 * 'verifyPermissions', but that suggests it's checking the user, when it's 581 * really checking the title + user combination. 582 * 583 * @param User $user User object to verify the permissions against 584 * @return mixed An array as returned by getUserPermissionsErrors or true 585 * in case the user has proper permissions. 586 */ 587 public function verifyPermissions( $user ) { 588 return $this->verifyTitlePermissions( $user ); 589 } 590 591 /** 592 * Check whether the user can edit, upload and create the image. This 593 * checks only against the current title; if it returns errors, it may 594 * very well be that another title will not give errors. Therefore 595 * isAllowed() should be called as well for generic is-user-blocked or 596 * can-user-upload checking. 597 * 598 * @param User $user User object to verify the permissions against 599 * @return mixed An array as returned by getUserPermissionsErrors or true 600 * in case the user has proper permissions. 601 */ 602 public function verifyTitlePermissions( $user ) { 603 /** 604 * If the image is protected, non-sysop users won't be able 605 * to modify it by uploading a new revision. 606 */ 607 $nt = $this->getTitle(); 608 if ( is_null( $nt ) ) { 609 return true; 610 } 611 $permErrors = $nt->getUserPermissionsErrors( 'edit', $user ); 612 $permErrorsUpload = $nt->getUserPermissionsErrors( 'upload', $user ); 613 if ( !$nt->exists() ) { 614 $permErrorsCreate = $nt->getUserPermissionsErrors( 'create', $user ); 615 } else { 616 $permErrorsCreate = array(); 617 } 618 if ( $permErrors || $permErrorsUpload || $permErrorsCreate ) { 619 $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsUpload, $permErrors ) ); 620 $permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsCreate, $permErrors ) ); 621 622 return $permErrors; 623 } 624 625 $overwriteError = $this->checkOverwrite( $user ); 626 if ( $overwriteError !== true ) { 627 return array( $overwriteError ); 628 } 629 630 return true; 631 } 632 633 /** 634 * Check for non fatal problems with the file. 635 * 636 * This should not assume that mTempPath is set. 637 * 638 * @return array Array of warnings 639 */ 640 public function checkWarnings() { 641 global $wgLang; 642 wfProfileIn( __METHOD__ ); 643 644 $warnings = array(); 645 646 $localFile = $this->getLocalFile(); 647 $filename = $localFile->getName(); 648 649 /** 650 * Check whether the resulting filename is different from the desired one, 651 * but ignore things like ucfirst() and spaces/underscore things 652 */ 653 $comparableName = str_replace( ' ', '_', $this->mDesiredDestName ); 654 $comparableName = Title::capitalize( $comparableName, NS_FILE ); 655 656 if ( $this->mDesiredDestName != $filename && $comparableName != $filename ) { 657 $warnings['badfilename'] = $filename; 658 // Debugging for bug 62241 659 wfDebugLog( 'upload', "Filename: '$filename', mDesiredDestName: " 660 . "'$this->mDesiredDestName', comparableName: '$comparableName'" ); 661 } 662 663 // Check whether the file extension is on the unwanted list 664 global $wgCheckFileExtensions, $wgFileExtensions; 665 if ( $wgCheckFileExtensions ) { 666 $extensions = array_unique( $wgFileExtensions ); 667 if ( !$this->checkFileExtension( $this->mFinalExtension, $extensions ) ) { 668 $warnings['filetype-unwanted-type'] = array( $this->mFinalExtension, 669 $wgLang->commaList( $extensions ), count( $extensions ) ); 670 } 671 } 672 673 global $wgUploadSizeWarning; 674 if ( $wgUploadSizeWarning && ( $this->mFileSize > $wgUploadSizeWarning ) ) { 675 $warnings['large-file'] = array( $wgUploadSizeWarning, $this->mFileSize ); 676 } 677 678 if ( $this->mFileSize == 0 ) { 679 $warnings['emptyfile'] = true; 680 } 681 682 $exists = self::getExistsWarning( $localFile ); 683 if ( $exists !== false ) { 684 $warnings['exists'] = $exists; 685 } 686 687 // Check dupes against existing files 688 $hash = $this->getTempFileSha1Base36(); 689 $dupes = RepoGroup::singleton()->findBySha1( $hash ); 690 $title = $this->getTitle(); 691 // Remove all matches against self 692 foreach ( $dupes as $key => $dupe ) { 693 if ( $title->equals( $dupe->getTitle() ) ) { 694 unset( $dupes[$key] ); 695 } 696 } 697 if ( $dupes ) { 698 $warnings['duplicate'] = $dupes; 699 } 700 701 // Check dupes against archives 702 $archivedImage = new ArchivedFile( null, 0, "{$hash}.{$this->mFinalExtension}" ); 703 if ( $archivedImage->getID() > 0 ) { 704 if ( $archivedImage->userCan( File::DELETED_FILE ) ) { 705 $warnings['duplicate-archive'] = $archivedImage->getName(); 706 } else { 707 $warnings['duplicate-archive'] = ''; 708 } 709 } 710 711 wfProfileOut( __METHOD__ ); 712 713 return $warnings; 714 } 715 716 /** 717 * Really perform the upload. Stores the file in the local repo, watches 718 * if necessary and runs the UploadComplete hook. 719 * 720 * @param string $comment 721 * @param string $pageText 722 * @param bool $watch 723 * @param User $user 724 * 725 * @return Status Indicating the whether the upload succeeded. 726 */ 727 public function performUpload( $comment, $pageText, $watch, $user ) { 728 wfProfileIn( __METHOD__ ); 729 730 $status = $this->getLocalFile()->upload( 731 $this->mTempPath, 732 $comment, 733 $pageText, 734 File::DELETE_SOURCE, 735 $this->mFileProps, 736 false, 737 $user 738 ); 739 740 if ( $status->isGood() ) { 741 if ( $watch ) { 742 WatchAction::doWatch( 743 $this->getLocalFile()->getTitle(), 744 $user, 745 WatchedItem::IGNORE_USER_RIGHTS 746 ); 747 } 748 wfRunHooks( 'UploadComplete', array( &$this ) ); 749 } 750 751 wfProfileOut( __METHOD__ ); 752 753 return $status; 754 } 755 756 /** 757 * Returns the title of the file to be uploaded. Sets mTitleError in case 758 * the name was illegal. 759 * 760 * @return Title The title of the file or null in case the name was illegal 761 */ 762 public function getTitle() { 763 if ( $this->mTitle !== false ) { 764 return $this->mTitle; 765 } 766 /* Assume that if a user specified File:Something.jpg, this is an error 767 * and that the namespace prefix needs to be stripped of. 768 */ 769 $title = Title::newFromText( $this->mDesiredDestName ); 770 if ( $title && $title->getNamespace() == NS_FILE ) { 771 $this->mFilteredName = $title->getDBkey(); 772 } else { 773 $this->mFilteredName = $this->mDesiredDestName; 774 } 775 776 # oi_archive_name is max 255 bytes, which include a timestamp and an 777 # exclamation mark, so restrict file name to 240 bytes. 778 if ( strlen( $this->mFilteredName ) > 240 ) { 779 $this->mTitleError = self::FILENAME_TOO_LONG; 780 $this->mTitle = null; 781 782 return $this->mTitle; 783 } 784 785 /** 786 * Chop off any directories in the given filename. Then 787 * filter out illegal characters, and try to make a legible name 788 * out of it. We'll strip some silently that Title would die on. 789 */ 790 $this->mFilteredName = wfStripIllegalFilenameChars( $this->mFilteredName ); 791 /* Normalize to title form before we do any further processing */ 792 $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName ); 793 if ( is_null( $nt ) ) { 794 $this->mTitleError = self::ILLEGAL_FILENAME; 795 $this->mTitle = null; 796 797 return $this->mTitle; 798 } 799 $this->mFilteredName = $nt->getDBkey(); 800 801 /** 802 * We'll want to blacklist against *any* 'extension', and use 803 * only the final one for the whitelist. 804 */ 805 list( $partname, $ext ) = $this->splitExtensions( $this->mFilteredName ); 806 807 if ( count( $ext ) ) { 808 $this->mFinalExtension = trim( $ext[count( $ext ) - 1] ); 809 } else { 810 $this->mFinalExtension = ''; 811 812 # No extension, try guessing one 813 $magic = MimeMagic::singleton(); 814 $mime = $magic->guessMimeType( $this->mTempPath ); 815 if ( $mime !== 'unknown/unknown' ) { 816 # Get a space separated list of extensions 817 $extList = $magic->getExtensionsForType( $mime ); 818 if ( $extList ) { 819 # Set the extension to the canonical extension 820 $this->mFinalExtension = strtok( $extList, ' ' ); 821 822 # Fix up the other variables 823 $this->mFilteredName .= ".{$this->mFinalExtension}"; 824 $nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName ); 825 $ext = array( $this->mFinalExtension ); 826 } 827 } 828 } 829 830 /* Don't allow users to override the blacklist (check file extension) */ 831 global $wgCheckFileExtensions, $wgStrictFileExtensions; 832 global $wgFileExtensions, $wgFileBlacklist; 833 834 $blackListedExtensions = $this->checkFileExtensionList( $ext, $wgFileBlacklist ); 835 836 if ( $this->mFinalExtension == '' ) { 837 $this->mTitleError = self::FILETYPE_MISSING; 838 $this->mTitle = null; 839 840 return $this->mTitle; 841 } elseif ( $blackListedExtensions || 842 ( $wgCheckFileExtensions && $wgStrictFileExtensions && 843 !$this->checkFileExtension( $this->mFinalExtension, $wgFileExtensions ) ) 844 ) { 845 $this->mBlackListedExtensions = $blackListedExtensions; 846 $this->mTitleError = self::FILETYPE_BADTYPE; 847 $this->mTitle = null; 848 849 return $this->mTitle; 850 } 851 852 // Windows may be broken with special characters, see bug 1780 853 if ( !preg_match( '/^[\x0-\x7f]*$/', $nt->getText() ) 854 && !RepoGroup::singleton()->getLocalRepo()->backendSupportsUnicodePaths() 855 ) { 856 $this->mTitleError = self::WINDOWS_NONASCII_FILENAME; 857 $this->mTitle = null; 858 859 return $this->mTitle; 860 } 861 862 # If there was more than one "extension", reassemble the base 863 # filename to prevent bogus complaints about length 864 if ( count( $ext ) > 1 ) { 865 $iterations = count( $ext ) - 1; 866 for ( $i = 0; $i < $iterations; $i++ ) { 867 $partname .= '.' . $ext[$i]; 868 } 869 } 870 871 if ( strlen( $partname ) < 1 ) { 872 $this->mTitleError = self::MIN_LENGTH_PARTNAME; 873 $this->mTitle = null; 874 875 return $this->mTitle; 876 } 877 878 $this->mTitle = $nt; 879 880 return $this->mTitle; 881 } 882 883 /** 884 * Return the local file and initializes if necessary. 885 * 886 * @return LocalFile|UploadStashFile|null 887 */ 888 public function getLocalFile() { 889 if ( is_null( $this->mLocalFile ) ) { 890 $nt = $this->getTitle(); 891 $this->mLocalFile = is_null( $nt ) ? null : wfLocalFile( $nt ); 892 } 893 894 return $this->mLocalFile; 895 } 896 897 /** 898 * If the user does not supply all necessary information in the first upload 899 * form submission (either by accident or by design) then we may want to 900 * stash the file temporarily, get more information, and publish the file 901 * later. 902 * 903 * This method will stash a file in a temporary directory for later 904 * processing, and save the necessary descriptive info into the database. 905 * This method returns the file object, which also has a 'fileKey' property 906 * which can be passed through a form or API request to find this stashed 907 * file again. 908 * 909 * @param User $user 910 * @return UploadStashFile Stashed file 911 */ 912 public function stashFile( User $user = null ) { 913 // was stashSessionFile 914 wfProfileIn( __METHOD__ ); 915 916 $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $user ); 917 $file = $stash->stashFile( $this->mTempPath, $this->getSourceType() ); 918 $this->mLocalFile = $file; 919 920 wfProfileOut( __METHOD__ ); 921 922 return $file; 923 } 924 925 /** 926 * Stash a file in a temporary directory, returning a key which can be used 927 * to find the file again. See stashFile(). 928 * 929 * @return string File key 930 */ 931 public function stashFileGetKey() { 932 return $this->stashFile()->getFileKey(); 933 } 934 935 /** 936 * alias for stashFileGetKey, for backwards compatibility 937 * 938 * @return string File key 939 */ 940 public function stashSession() { 941 return $this->stashFileGetKey(); 942 } 943 944 /** 945 * If we've modified the upload file we need to manually remove it 946 * on exit to clean up. 947 */ 948 public function cleanupTempFile() { 949 if ( $this->mRemoveTempFile && $this->mTempPath && file_exists( $this->mTempPath ) ) { 950 wfDebug( __METHOD__ . ": Removing temporary file {$this->mTempPath}\n" ); 951 unlink( $this->mTempPath ); 952 } 953 } 954 955 public function getTempPath() { 956 return $this->mTempPath; 957 } 958 959 /** 960 * Split a file into a base name and all dot-delimited 'extensions' 961 * on the end. Some web server configurations will fall back to 962 * earlier pseudo-'extensions' to determine type and execute 963 * scripts, so the blacklist needs to check them all. 964 * 965 * @param string $filename 966 * @return array 967 */ 968 public static function splitExtensions( $filename ) { 969 $bits = explode( '.', $filename ); 970 $basename = array_shift( $bits ); 971 972 return array( $basename, $bits ); 973 } 974 975 /** 976 * Perform case-insensitive match against a list of file extensions. 977 * Returns true if the extension is in the list. 978 * 979 * @param string $ext 980 * @param array $list 981 * @return bool 982 */ 983 public static function checkFileExtension( $ext, $list ) { 984 return in_array( strtolower( $ext ), $list ); 985 } 986 987 /** 988 * Perform case-insensitive match against a list of file extensions. 989 * Returns an array of matching extensions. 990 * 991 * @param array $ext 992 * @param array $list 993 * @return bool 994 */ 995 public static function checkFileExtensionList( $ext, $list ) { 996 return array_intersect( array_map( 'strtolower', $ext ), $list ); 997 } 998 999 /** 1000 * Checks if the MIME type of the uploaded file matches the file extension. 1001 * 1002 * @param string $mime The MIME type of the uploaded file 1003 * @param string $extension The filename extension that the file is to be served with 1004 * @return bool 1005 */ 1006 public static function verifyExtension( $mime, $extension ) { 1007 $magic = MimeMagic::singleton(); 1008 1009 if ( !$mime || $mime == 'unknown' || $mime == 'unknown/unknown' ) { 1010 if ( !$magic->isRecognizableExtension( $extension ) ) { 1011 wfDebug( __METHOD__ . ": passing file with unknown detected mime type; " . 1012 "unrecognized extension '$extension', can't verify\n" ); 1013 1014 return true; 1015 } else { 1016 wfDebug( __METHOD__ . ": rejecting file with unknown detected mime type; " . 1017 "recognized extension '$extension', so probably invalid file\n" ); 1018 1019 return false; 1020 } 1021 } 1022 1023 $match = $magic->isMatchingExtension( $extension, $mime ); 1024 1025 if ( $match === null ) { 1026 if ( $magic->getTypesForExtension( $extension ) !== null ) { 1027 wfDebug( __METHOD__ . ": No extension known for $mime, but we know a mime for $extension\n" ); 1028 1029 return false; 1030 } else { 1031 wfDebug( __METHOD__ . ": no file extension known for mime type $mime, passing file\n" ); 1032 1033 return true; 1034 } 1035 } elseif ( $match === true ) { 1036 wfDebug( __METHOD__ . ": mime type $mime matches extension $extension, passing file\n" ); 1037 1038 /** @todo If it's a bitmap, make sure PHP or ImageMagick resp. can handle it! */ 1039 return true; 1040 } else { 1041 wfDebug( __METHOD__ 1042 . ": mime type $mime mismatches file extension $extension, rejecting file\n" ); 1043 1044 return false; 1045 } 1046 } 1047 1048 /** 1049 * Heuristic for detecting files that *could* contain JavaScript instructions or 1050 * things that may look like HTML to a browser and are thus 1051 * potentially harmful. The present implementation will produce false 1052 * positives in some situations. 1053 * 1054 * @param string $file Pathname to the temporary upload file 1055 * @param string $mime The MIME type of the file 1056 * @param string $extension The extension of the file 1057 * @return bool True if the file contains something looking like embedded scripts 1058 */ 1059 public static function detectScript( $file, $mime, $extension ) { 1060 global $wgAllowTitlesInSVG; 1061 wfProfileIn( __METHOD__ ); 1062 1063 # ugly hack: for text files, always look at the entire file. 1064 # For binary field, just check the first K. 1065 1066 if ( strpos( $mime, 'text/' ) === 0 ) { 1067 $chunk = file_get_contents( $file ); 1068 } else { 1069 $fp = fopen( $file, 'rb' ); 1070 $chunk = fread( $fp, 1024 ); 1071 fclose( $fp ); 1072 } 1073 1074 $chunk = strtolower( $chunk ); 1075 1076 if ( !$chunk ) { 1077 wfProfileOut( __METHOD__ ); 1078 1079 return false; 1080 } 1081 1082 # decode from UTF-16 if needed (could be used for obfuscation). 1083 if ( substr( $chunk, 0, 2 ) == "\xfe\xff" ) { 1084 $enc = 'UTF-16BE'; 1085 } elseif ( substr( $chunk, 0, 2 ) == "\xff\xfe" ) { 1086 $enc = 'UTF-16LE'; 1087 } else { 1088 $enc = null; 1089 } 1090 1091 if ( $enc ) { 1092 $chunk = iconv( $enc, "ASCII//IGNORE", $chunk ); 1093 } 1094 1095 $chunk = trim( $chunk ); 1096 1097 /** @todo FIXME: Convert from UTF-16 if necessary! */ 1098 wfDebug( __METHOD__ . ": checking for embedded scripts and HTML stuff\n" ); 1099 1100 # check for HTML doctype 1101 if ( preg_match( "/<!DOCTYPE *X?HTML/i", $chunk ) ) { 1102 wfProfileOut( __METHOD__ ); 1103 1104 return true; 1105 } 1106 1107 // Some browsers will interpret obscure xml encodings as UTF-8, while 1108 // PHP/expat will interpret the given encoding in the xml declaration (bug 47304) 1109 if ( $extension == 'svg' || strpos( $mime, 'image/svg' ) === 0 ) { 1110 if ( self::checkXMLEncodingMissmatch( $file ) ) { 1111 wfProfileOut( __METHOD__ ); 1112 1113 return true; 1114 } 1115 } 1116 1117 /** 1118 * Internet Explorer for Windows performs some really stupid file type 1119 * autodetection which can cause it to interpret valid image files as HTML 1120 * and potentially execute JavaScript, creating a cross-site scripting 1121 * attack vectors. 1122 * 1123 * Apple's Safari browser also performs some unsafe file type autodetection 1124 * which can cause legitimate files to be interpreted as HTML if the 1125 * web server is not correctly configured to send the right content-type 1126 * (or if you're really uploading plain text and octet streams!) 1127 * 1128 * Returns true if IE is likely to mistake the given file for HTML. 1129 * Also returns true if Safari would mistake the given file for HTML 1130 * when served with a generic content-type. 1131 */ 1132 $tags = array( 1133 '<a href', 1134 '<body', 1135 '<head', 1136 '<html', #also in safari 1137 '<img', 1138 '<pre', 1139 '<script', #also in safari 1140 '<table' 1141 ); 1142 1143 if ( !$wgAllowTitlesInSVG && $extension !== 'svg' && $mime !== 'image/svg' ) { 1144 $tags[] = '<title'; 1145 } 1146 1147 foreach ( $tags as $tag ) { 1148 if ( false !== strpos( $chunk, $tag ) ) { 1149 wfDebug( __METHOD__ . ": found something that may make it be mistaken for html: $tag\n" ); 1150 wfProfileOut( __METHOD__ ); 1151 1152 return true; 1153 } 1154 } 1155 1156 /* 1157 * look for JavaScript 1158 */ 1159 1160 # resolve entity-refs to look at attributes. may be harsh on big files... cache result? 1161 $chunk = Sanitizer::decodeCharReferences( $chunk ); 1162 1163 # look for script-types 1164 if ( preg_match( '!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!sim', $chunk ) ) { 1165 wfDebug( __METHOD__ . ": found script types\n" ); 1166 wfProfileOut( __METHOD__ ); 1167 1168 return true; 1169 } 1170 1171 # look for html-style script-urls 1172 if ( preg_match( '!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) { 1173 wfDebug( __METHOD__ . ": found html-style script urls\n" ); 1174 wfProfileOut( __METHOD__ ); 1175 1176 return true; 1177 } 1178 1179 # look for css-style script-urls 1180 if ( preg_match( '!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) { 1181 wfDebug( __METHOD__ . ": found css-style script urls\n" ); 1182 wfProfileOut( __METHOD__ ); 1183 1184 return true; 1185 } 1186 1187 wfDebug( __METHOD__ . ": no scripts found\n" ); 1188 wfProfileOut( __METHOD__ ); 1189 1190 return false; 1191 } 1192 1193 /** 1194 * Check a whitelist of xml encodings that are known not to be interpreted differently 1195 * by the server's xml parser (expat) and some common browsers. 1196 * 1197 * @param string $file Pathname to the temporary upload file 1198 * @return bool True if the file contains an encoding that could be misinterpreted 1199 */ 1200 public static function checkXMLEncodingMissmatch( $file ) { 1201 global $wgSVGMetadataCutoff; 1202 $contents = file_get_contents( $file, false, null, -1, $wgSVGMetadataCutoff ); 1203 $encodingRegex = '!encoding[ \t\n\r]*=[ \t\n\r]*[\'"](.*?)[\'"]!si'; 1204 1205 if ( preg_match( "!<\?xml\b(.*?)\?>!si", $contents, $matches ) ) { 1206 if ( preg_match( $encodingRegex, $matches[1], $encMatch ) 1207 && !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings ) 1208 ) { 1209 wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" ); 1210 1211 return true; 1212 } 1213 } elseif ( preg_match( "!<\?xml\b!si", $contents ) ) { 1214 // Start of XML declaration without an end in the first $wgSVGMetadataCutoff 1215 // bytes. There shouldn't be a legitimate reason for this to happen. 1216 wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" ); 1217 1218 return true; 1219 } elseif ( substr( $contents, 0, 4 ) == "\x4C\x6F\xA7\x94" ) { 1220 // EBCDIC encoded XML 1221 wfDebug( __METHOD__ . ": EBCDIC Encoded XML\n" ); 1222 1223 return true; 1224 } 1225 1226 // It's possible the file is encoded with multi-byte encoding, so re-encode attempt to 1227 // detect the encoding in case is specifies an encoding not whitelisted in self::$safeXmlEncodings 1228 $attemptEncodings = array( 'UTF-16', 'UTF-16BE', 'UTF-32', 'UTF-32BE' ); 1229 foreach ( $attemptEncodings as $encoding ) { 1230 wfSuppressWarnings(); 1231 $str = iconv( $encoding, 'UTF-8', $contents ); 1232 wfRestoreWarnings(); 1233 if ( $str != '' && preg_match( "!<\?xml\b(.*?)\?>!si", $str, $matches ) ) { 1234 if ( preg_match( $encodingRegex, $matches[1], $encMatch ) 1235 && !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings ) 1236 ) { 1237 wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" ); 1238 1239 return true; 1240 } 1241 } elseif ( $str != '' && preg_match( "!<\?xml\b!si", $str ) ) { 1242 // Start of XML declaration without an end in the first $wgSVGMetadataCutoff 1243 // bytes. There shouldn't be a legitimate reason for this to happen. 1244 wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" ); 1245 1246 return true; 1247 } 1248 } 1249 1250 return false; 1251 } 1252 1253 /** 1254 * @param string $filename 1255 * @return mixed False of the file is verified (does not contain scripts), array otherwise. 1256 */ 1257 protected function detectScriptInSvg( $filename ) { 1258 $this->mSVGNSError = false; 1259 $check = new XmlTypeCheck( 1260 $filename, 1261 array( $this, 'checkSvgScriptCallback' ), 1262 true, 1263 array( 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback' ) 1264 ); 1265 if ( $check->wellFormed !== true ) { 1266 // Invalid xml (bug 58553) 1267 return array( 'uploadinvalidxml' ); 1268 } elseif ( $check->filterMatch ) { 1269 if ( $this->mSVGNSError ) { 1270 return array( 'uploadscriptednamespace', $this->mSVGNSError ); 1271 } 1272 1273 return array( 'uploadscripted' ); 1274 } 1275 1276 return false; 1277 } 1278 1279 /** 1280 * Callback to filter SVG Processing Instructions. 1281 * @param string $target Processing instruction name 1282 * @param string $data Processing instruction attribute and value 1283 * @return bool (true if the filter identified something bad) 1284 */ 1285 public static function checkSvgPICallback( $target, $data ) { 1286 // Don't allow external stylesheets (bug 57550) 1287 if ( preg_match( '/xml-stylesheet/i', $target ) ) { 1288 return true; 1289 } 1290 1291 return false; 1292 } 1293 1294 /** 1295 * @todo Replace this with a whitelist filter! 1296 * @param string $element 1297 * @param array $attribs 1298 * @return bool 1299 */ 1300 public function checkSvgScriptCallback( $element, $attribs, $data = null ) { 1301 1302 list( $namespace, $strippedElement ) = $this->splitXmlNamespace( $element ); 1303 1304 // We specifically don't include: 1305 // http://www.w3.org/1999/xhtml (bug 60771) 1306 static $validNamespaces = array( 1307 '', 1308 'adobe:ns:meta/', 1309 'http://creativecommons.org/ns#', 1310 'http://inkscape.sourceforge.net/dtd/sodipodi-0.dtd', 1311 'http://ns.adobe.com/adobeillustrator/10.0/', 1312 'http://ns.adobe.com/adobesvgviewerextensions/3.0/', 1313 'http://ns.adobe.com/extensibility/1.0/', 1314 'http://ns.adobe.com/flows/1.0/', 1315 'http://ns.adobe.com/illustrator/1.0/', 1316 'http://ns.adobe.com/imagereplacement/1.0/', 1317 'http://ns.adobe.com/pdf/1.3/', 1318 'http://ns.adobe.com/photoshop/1.0/', 1319 'http://ns.adobe.com/saveforweb/1.0/', 1320 'http://ns.adobe.com/variables/1.0/', 1321 'http://ns.adobe.com/xap/1.0/', 1322 'http://ns.adobe.com/xap/1.0/g/', 1323 'http://ns.adobe.com/xap/1.0/g/img/', 1324 'http://ns.adobe.com/xap/1.0/mm/', 1325 'http://ns.adobe.com/xap/1.0/rights/', 1326 'http://ns.adobe.com/xap/1.0/stype/dimensions#', 1327 'http://ns.adobe.com/xap/1.0/stype/font#', 1328 'http://ns.adobe.com/xap/1.0/stype/manifestitem#', 1329 'http://ns.adobe.com/xap/1.0/stype/resourceevent#', 1330 'http://ns.adobe.com/xap/1.0/stype/resourceref#', 1331 'http://ns.adobe.com/xap/1.0/t/pg/', 1332 'http://purl.org/dc/elements/1.1/', 1333 'http://purl.org/dc/elements/1.1', 1334 'http://schemas.microsoft.com/visio/2003/svgextensions/', 1335 'http://sodipodi.sourceforge.net/dtd/sodipodi-0.dtd', 1336 'http://taptrix.com/inkpad/svg_extensions', 1337 'http://web.resource.org/cc/', 1338 'http://www.freesoftware.fsf.org/bkchem/cdml', 1339 'http://www.inkscape.org/namespaces/inkscape', 1340 'http://www.opengis.net/gml', 1341 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 1342 'http://www.w3.org/2000/svg', 1343 'http://www.w3.org/tr/rec-rdf-syntax/', 1344 ); 1345 1346 if ( !in_array( $namespace, $validNamespaces ) ) { 1347 wfDebug( __METHOD__ . ": Non-svg namespace '$namespace' in uploaded file.\n" ); 1348 /** @todo Return a status object to a closure in XmlTypeCheck, for MW1.21+ */ 1349 $this->mSVGNSError = $namespace; 1350 1351 return true; 1352 } 1353 1354 /* 1355 * check for elements that can contain javascript 1356 */ 1357 if ( $strippedElement == 'script' ) { 1358 wfDebug( __METHOD__ . ": Found script element '$element' in uploaded file.\n" ); 1359 1360 return true; 1361 } 1362 1363 # e.g., <svg xmlns="http://www.w3.org/2000/svg"> 1364 # <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg> 1365 if ( $strippedElement == 'handler' ) { 1366 wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" ); 1367 1368 return true; 1369 } 1370 1371 # SVG reported in Feb '12 that used xml:stylesheet to generate javascript block 1372 if ( $strippedElement == 'stylesheet' ) { 1373 wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" ); 1374 1375 return true; 1376 } 1377 1378 # Block iframes, in case they pass the namespace check 1379 if ( $strippedElement == 'iframe' ) { 1380 wfDebug( __METHOD__ . ": iframe in uploaded file.\n" ); 1381 1382 return true; 1383 } 1384 1385 # Check <style> css 1386 if ( $strippedElement == 'style' 1387 && self::checkCssFragment( Sanitizer::normalizeCss( $data ) ) 1388 ) { 1389 wfDebug( __METHOD__ . ": hostile css in style element.\n" ); 1390 return true; 1391 } 1392 1393 foreach ( $attribs as $attrib => $value ) { 1394 $stripped = $this->stripXmlNamespace( $attrib ); 1395 $value = strtolower( $value ); 1396 1397 if ( substr( $stripped, 0, 2 ) == 'on' ) { 1398 wfDebug( __METHOD__ 1399 . ": Found event-handler attribute '$attrib'='$value' in uploaded file.\n" ); 1400 1401 return true; 1402 } 1403 1404 # href with non-local target (don't allow http://, javascript:, etc) 1405 if ( $stripped == 'href' 1406 && strpos( $value, 'data:' ) !== 0 1407 && strpos( $value, '#' ) !== 0 1408 ) { 1409 if ( !( $strippedElement === 'a' 1410 && preg_match( '!^https?://!im', $value ) ) 1411 ) { 1412 wfDebug( __METHOD__ . ": Found href attribute <$strippedElement " 1413 . "'$attrib'='$value' in uploaded file.\n" ); 1414 1415 return true; 1416 } 1417 } 1418 1419 # href with embedded svg as target 1420 if ( $stripped == 'href' && preg_match( '!data:[^,]*image/svg[^,]*,!sim', $value ) ) { 1421 wfDebug( __METHOD__ . ": Found href to embedded svg " 1422 . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" ); 1423 1424 return true; 1425 } 1426 1427 # href with embedded (text/xml) svg as target 1428 if ( $stripped == 'href' && preg_match( '!data:[^,]*text/xml[^,]*,!sim', $value ) ) { 1429 wfDebug( __METHOD__ . ": Found href to embedded svg " 1430 . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" ); 1431 1432 return true; 1433 } 1434 1435 # Change href with animate from (http://html5sec.org/#137). This doesn't seem 1436 # possible without embedding the svg, but filter here in case. 1437 if ( $stripped == 'from' 1438 && $strippedElement === 'animate' 1439 && !preg_match( '!^https?://!im', $value ) 1440 ) { 1441 wfDebug( __METHOD__ . ": Found animate that might be changing href using from " 1442 . "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" ); 1443 1444 return true; 1445 } 1446 1447 # use set/animate to add event-handler attribute to parent 1448 if ( ( $strippedElement == 'set' || $strippedElement == 'animate' ) 1449 && $stripped == 'attributename' 1450 && substr( $value, 0, 2 ) == 'on' 1451 ) { 1452 wfDebug( __METHOD__ . ": Found svg setting event-handler attribute with " 1453 . "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" ); 1454 1455 return true; 1456 } 1457 1458 # use set to add href attribute to parent element 1459 if ( $strippedElement == 'set' 1460 && $stripped == 'attributename' 1461 && strpos( $value, 'href' ) !== false 1462 ) { 1463 wfDebug( __METHOD__ . ": Found svg setting href attribute '$value' in uploaded file.\n" ); 1464 1465 return true; 1466 } 1467 1468 # use set to add a remote / data / script target to an element 1469 if ( $strippedElement == 'set' 1470 && $stripped == 'to' 1471 && preg_match( '!(http|https|data|script):!sim', $value ) 1472 ) { 1473 wfDebug( __METHOD__ . ": Found svg setting attribute to '$value' in uploaded file.\n" ); 1474 1475 return true; 1476 } 1477 1478 # use handler attribute with remote / data / script 1479 if ( $stripped == 'handler' && preg_match( '!(http|https|data|script):!sim', $value ) ) { 1480 wfDebug( __METHOD__ . ": Found svg setting handler with remote/data/script " 1481 . "'$attrib'='$value' in uploaded file.\n" ); 1482 1483 return true; 1484 } 1485 1486 # use CSS styles to bring in remote code 1487 if ( $stripped == 'style' 1488 && self::checkCssFragment( Sanitizer::normalizeCss( $value ) ) 1489 ) { 1490 wfDebug( __METHOD__ . ": Found svg setting a style with " 1491 . "remote url '$attrib'='$value' in uploaded file.\n" ); 1492 return true; 1493 } 1494 1495 # Several attributes can include css, css character escaping isn't allowed 1496 $cssAttrs = array( 'font', 'clip-path', 'fill', 'filter', 'marker', 1497 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke' ); 1498 if ( in_array( $stripped, $cssAttrs ) 1499 && self::checkCssFragment( $value ) 1500 ) { 1501 wfDebug( __METHOD__ . ": Found svg setting a style with " 1502 . "remote url '$attrib'='$value' in uploaded file.\n" ); 1503 return true; 1504 } 1505 1506 # image filters can pull in url, which could be svg that executes scripts 1507 if ( $strippedElement == 'image' 1508 && $stripped == 'filter' 1509 && preg_match( '!url\s*\(!sim', $value ) 1510 ) { 1511 wfDebug( __METHOD__ . ": Found image filter with url: " 1512 . "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" ); 1513 1514 return true; 1515 } 1516 } 1517 1518 return false; //No scripts detected 1519 } 1520 1521 /** 1522 * Check a block of CSS or CSS fragment for anything that looks like 1523 * it is bringing in remote code. 1524 * @param string $value a string of CSS 1525 * @param bool $propOnly only check css properties (start regex with :) 1526 * @return bool true if the CSS contains an illegal string, false if otherwise 1527 */ 1528 private static function checkCssFragment( $value ) { 1529 1530 # Forbid external stylesheets, for both reliability and to protect viewer's privacy 1531 if ( strpos( $value, '@import' ) !== false ) { 1532 return true; 1533 } 1534 1535 # We allow @font-face to embed fonts with data: urls, so we snip the string 1536 # 'url' out so this case won't match when we check for urls below 1537 $pattern = '!(@font-face\s*{[^}]*src:)url(\("data:;base64,)!im'; 1538 $value = preg_replace( $pattern, '$1$2', $value ); 1539 1540 # Check for remote and executable CSS. Unlike in Sanitizer::checkCss, the CSS 1541 # properties filter and accelerator don't seem to be useful for xss in SVG files. 1542 # Expression and -o-link don't seem to work either, but filtering them here in case. 1543 # Additionally, we catch remote urls like url("http:..., url('http:..., url(http:..., 1544 # but not local ones such as url("#..., url('#..., url(#.... 1545 if ( preg_match( '!expression 1546 | -o-link\s*: 1547 | -o-link-source\s*: 1548 | -o-replace\s*:!imx', $value ) ) { 1549 return true; 1550 } 1551 1552 if ( preg_match_all( 1553 "!(\s*(url|image|image-set)\s*\(\s*[\"']?\s*[^#]+.*?\))!sim", 1554 $value, 1555 $matches 1556 ) !== 0 1557 ) { 1558 # TODO: redo this in one regex. Until then, url("#whatever") matches the first 1559 foreach ( $matches[1] as $match ) { 1560 if ( !preg_match( "!\s*(url|image|image-set)\s*\(\s*(#|'#|\"#)!im", $match ) ) { 1561 return true; 1562 } 1563 } 1564 } 1565 1566 if ( preg_match( '/[\000-\010\013\016-\037\177]/', $value ) ) { 1567 return true; 1568 } 1569 1570 return false; 1571 } 1572 1573 /** 1574 * Divide the element name passed by the xml parser to the callback into URI and prifix. 1575 * @param string $element 1576 * @return array Containing the namespace URI and prefix 1577 */ 1578 private static function splitXmlNamespace( $element ) { 1579 // 'http://www.w3.org/2000/svg:script' -> array( 'http://www.w3.org/2000/svg', 'script' ) 1580 $parts = explode( ':', strtolower( $element ) ); 1581 $name = array_pop( $parts ); 1582 $ns = implode( ':', $parts ); 1583 1584 return array( $ns, $name ); 1585 } 1586 1587 /** 1588 * @param string $name 1589 * @return string 1590 */ 1591 private function stripXmlNamespace( $name ) { 1592 // 'http://www.w3.org/2000/svg:script' -> 'script' 1593 $parts = explode( ':', strtolower( $name ) ); 1594 1595 return array_pop( $parts ); 1596 } 1597 1598 /** 1599 * Generic wrapper function for a virus scanner program. 1600 * This relies on the $wgAntivirus and $wgAntivirusSetup variables. 1601 * $wgAntivirusRequired may be used to deny upload if the scan fails. 1602 * 1603 * @param string $file Pathname to the temporary upload file 1604 * @return mixed False if not virus is found, null if the scan fails or is disabled, 1605 * or a string containing feedback from the virus scanner if a virus was found. 1606 * If textual feedback is missing but a virus was found, this function returns true. 1607 */ 1608 public static function detectVirus( $file ) { 1609 global $wgAntivirus, $wgAntivirusSetup, $wgAntivirusRequired, $wgOut; 1610 wfProfileIn( __METHOD__ ); 1611 1612 if ( !$wgAntivirus ) { 1613 wfDebug( __METHOD__ . ": virus scanner disabled\n" ); 1614 wfProfileOut( __METHOD__ ); 1615 1616 return null; 1617 } 1618 1619 if ( !$wgAntivirusSetup[$wgAntivirus] ) { 1620 wfDebug( __METHOD__ . ": unknown virus scanner: $wgAntivirus\n" ); 1621 $wgOut->wrapWikiMsg( "<div class=\"error\">\n$1\n</div>", 1622 array( 'virus-badscanner', $wgAntivirus ) ); 1623 wfProfileOut( __METHOD__ ); 1624 1625 return wfMessage( 'virus-unknownscanner' )->text() . " $wgAntivirus"; 1626 } 1627 1628 # look up scanner configuration 1629 $command = $wgAntivirusSetup[$wgAntivirus]['command']; 1630 $exitCodeMap = $wgAntivirusSetup[$wgAntivirus]['codemap']; 1631 $msgPattern = isset( $wgAntivirusSetup[$wgAntivirus]['messagepattern'] ) ? 1632 $wgAntivirusSetup[$wgAntivirus]['messagepattern'] : null; 1633 1634 if ( strpos( $command, "%f" ) === false ) { 1635 # simple pattern: append file to scan 1636 $command .= " " . wfEscapeShellArg( $file ); 1637 } else { 1638 # complex pattern: replace "%f" with file to scan 1639 $command = str_replace( "%f", wfEscapeShellArg( $file ), $command ); 1640 } 1641 1642 wfDebug( __METHOD__ . ": running virus scan: $command \n" ); 1643 1644 # execute virus scanner 1645 $exitCode = false; 1646 1647 # NOTE: there's a 50 line workaround to make stderr redirection work on windows, too. 1648 # that does not seem to be worth the pain. 1649 # Ask me (Duesentrieb) about it if it's ever needed. 1650 $output = wfShellExecWithStderr( $command, $exitCode ); 1651 1652 # map exit code to AV_xxx constants. 1653 $mappedCode = $exitCode; 1654 if ( $exitCodeMap ) { 1655 if ( isset( $exitCodeMap[$exitCode] ) ) { 1656 $mappedCode = $exitCodeMap[$exitCode]; 1657 } elseif ( isset( $exitCodeMap["*"] ) ) { 1658 $mappedCode = $exitCodeMap["*"]; 1659 } 1660 } 1661 1662 /* NB: AV_NO_VIRUS is 0 but AV_SCAN_FAILED is false, 1663 * so we need the strict equalities === and thus can't use a switch here 1664 */ 1665 if ( $mappedCode === AV_SCAN_FAILED ) { 1666 # scan failed (code was mapped to false by $exitCodeMap) 1667 wfDebug( __METHOD__ . ": failed to scan $file (code $exitCode).\n" ); 1668 1669 $output = $wgAntivirusRequired 1670 ? wfMessage( 'virus-scanfailed', array( $exitCode ) )->text() 1671 : null; 1672 } elseif ( $mappedCode === AV_SCAN_ABORTED ) { 1673 # scan failed because filetype is unknown (probably imune) 1674 wfDebug( __METHOD__ . ": unsupported file type $file (code $exitCode).\n" ); 1675 $output = null; 1676 } elseif ( $mappedCode === AV_NO_VIRUS ) { 1677 # no virus found 1678 wfDebug( __METHOD__ . ": file passed virus scan.\n" ); 1679 $output = false; 1680 } else { 1681 $output = trim( $output ); 1682 1683 if ( !$output ) { 1684 $output = true; #if there's no output, return true 1685 } elseif ( $msgPattern ) { 1686 $groups = array(); 1687 if ( preg_match( $msgPattern, $output, $groups ) ) { 1688 if ( $groups[1] ) { 1689 $output = $groups[1]; 1690 } 1691 } 1692 } 1693 1694 wfDebug( __METHOD__ . ": FOUND VIRUS! scanner feedback: $output \n" ); 1695 } 1696 1697 wfProfileOut( __METHOD__ ); 1698 1699 return $output; 1700 } 1701 1702 /** 1703 * Check if there's an overwrite conflict and, if so, if restrictions 1704 * forbid this user from performing the upload. 1705 * 1706 * @param User $user 1707 * 1708 * @return mixed True on success, array on failure 1709 */ 1710 private function checkOverwrite( $user ) { 1711 // First check whether the local file can be overwritten 1712 $file = $this->getLocalFile(); 1713 if ( $file->exists() ) { 1714 if ( !self::userCanReUpload( $user, $file ) ) { 1715 return array( 'fileexists-forbidden', $file->getName() ); 1716 } else { 1717 return true; 1718 } 1719 } 1720 1721 /* Check shared conflicts: if the local file does not exist, but 1722 * wfFindFile finds a file, it exists in a shared repository. 1723 */ 1724 $file = wfFindFile( $this->getTitle() ); 1725 if ( $file && !$user->isAllowed( 'reupload-shared' ) ) { 1726 return array( 'fileexists-shared-forbidden', $file->getName() ); 1727 } 1728 1729 return true; 1730 } 1731 1732 /** 1733 * Check if a user is the last uploader 1734 * 1735 * @param User $user 1736 * @param string $img Image name 1737 * @return bool 1738 */ 1739 public static function userCanReUpload( User $user, $img ) { 1740 if ( $user->isAllowed( 'reupload' ) ) { 1741 return true; // non-conditional 1742 } 1743 if ( !$user->isAllowed( 'reupload-own' ) ) { 1744 return false; 1745 } 1746 if ( is_string( $img ) ) { 1747 $img = wfLocalFile( $img ); 1748 } 1749 if ( !( $img instanceof LocalFile ) ) { 1750 return false; 1751 } 1752 1753 return $user->getId() == $img->getUser( 'id' ); 1754 } 1755 1756 /** 1757 * Helper function that does various existence checks for a file. 1758 * The following checks are performed: 1759 * - The file exists 1760 * - Article with the same name as the file exists 1761 * - File exists with normalized extension 1762 * - The file looks like a thumbnail and the original exists 1763 * 1764 * @param File $file The File object to check 1765 * @return mixed False if the file does not exists, else an array 1766 */ 1767 public static function getExistsWarning( $file ) { 1768 if ( $file->exists() ) { 1769 return array( 'warning' => 'exists', 'file' => $file ); 1770 } 1771 1772 if ( $file->getTitle()->getArticleID() ) { 1773 return array( 'warning' => 'page-exists', 'file' => $file ); 1774 } 1775 1776 if ( $file->wasDeleted() && !$file->exists() ) { 1777 return array( 'warning' => 'was-deleted', 'file' => $file ); 1778 } 1779 1780 if ( strpos( $file->getName(), '.' ) == false ) { 1781 $partname = $file->getName(); 1782 $extension = ''; 1783 } else { 1784 $n = strrpos( $file->getName(), '.' ); 1785 $extension = substr( $file->getName(), $n + 1 ); 1786 $partname = substr( $file->getName(), 0, $n ); 1787 } 1788 $normalizedExtension = File::normalizeExtension( $extension ); 1789 1790 if ( $normalizedExtension != $extension ) { 1791 // We're not using the normalized form of the extension. 1792 // Normal form is lowercase, using most common of alternate 1793 // extensions (eg 'jpg' rather than 'JPEG'). 1794 // 1795 // Check for another file using the normalized form... 1796 $nt_lc = Title::makeTitle( NS_FILE, "{$partname}.{$normalizedExtension}" ); 1797 $file_lc = wfLocalFile( $nt_lc ); 1798 1799 if ( $file_lc->exists() ) { 1800 return array( 1801 'warning' => 'exists-normalized', 1802 'file' => $file, 1803 'normalizedFile' => $file_lc 1804 ); 1805 } 1806 } 1807 1808 // Check for files with the same name but a different extension 1809 $similarFiles = RepoGroup::singleton()->getLocalRepo()->findFilesByPrefix( 1810 "{$partname}.", 1 ); 1811 if ( count( $similarFiles ) ) { 1812 return array( 1813 'warning' => 'exists-normalized', 1814 'file' => $file, 1815 'normalizedFile' => $similarFiles[0], 1816 ); 1817 } 1818 1819 if ( self::isThumbName( $file->getName() ) ) { 1820 # Check for filenames like 50px- or 180px-, these are mostly thumbnails 1821 $nt_thb = Title::newFromText( 1822 substr( $partname, strpos( $partname, '-' ) + 1 ) . '.' . $extension, 1823 NS_FILE 1824 ); 1825 $file_thb = wfLocalFile( $nt_thb ); 1826 if ( $file_thb->exists() ) { 1827 return array( 1828 'warning' => 'thumb', 1829 'file' => $file, 1830 'thumbFile' => $file_thb 1831 ); 1832 } else { 1833 // File does not exist, but we just don't like the name 1834 return array( 1835 'warning' => 'thumb-name', 1836 'file' => $file, 1837 'thumbFile' => $file_thb 1838 ); 1839 } 1840 } 1841 1842 foreach ( self::getFilenamePrefixBlacklist() as $prefix ) { 1843 if ( substr( $partname, 0, strlen( $prefix ) ) == $prefix ) { 1844 return array( 1845 'warning' => 'bad-prefix', 1846 'file' => $file, 1847 'prefix' => $prefix 1848 ); 1849 } 1850 } 1851 1852 return false; 1853 } 1854 1855 /** 1856 * Helper function that checks whether the filename looks like a thumbnail 1857 * @param string $filename 1858 * @return bool 1859 */ 1860 public static function isThumbName( $filename ) { 1861 $n = strrpos( $filename, '.' ); 1862 $partname = $n ? substr( $filename, 0, $n ) : $filename; 1863 1864 return ( 1865 substr( $partname, 3, 3 ) == 'px-' || 1866 substr( $partname, 2, 3 ) == 'px-' 1867 ) && 1868 preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) ); 1869 } 1870 1871 /** 1872 * Get a list of blacklisted filename prefixes from [[MediaWiki:Filename-prefix-blacklist]] 1873 * 1874 * @return array List of prefixes 1875 */ 1876 public static function getFilenamePrefixBlacklist() { 1877 $blacklist = array(); 1878 $message = wfMessage( 'filename-prefix-blacklist' )->inContentLanguage(); 1879 if ( !$message->isDisabled() ) { 1880 $lines = explode( "\n", $message->plain() ); 1881 foreach ( $lines as $line ) { 1882 // Remove comment lines 1883 $comment = substr( trim( $line ), 0, 1 ); 1884 if ( $comment == '#' || $comment == '' ) { 1885 continue; 1886 } 1887 // Remove additional comments after a prefix 1888 $comment = strpos( $line, '#' ); 1889 if ( $comment > 0 ) { 1890 $line = substr( $line, 0, $comment - 1 ); 1891 } 1892 $blacklist[] = trim( $line ); 1893 } 1894 } 1895 1896 return $blacklist; 1897 } 1898 1899 /** 1900 * Gets image info about the file just uploaded. 1901 * 1902 * Also has the effect of setting metadata to be an 'indexed tag name' in 1903 * returned API result if 'metadata' was requested. Oddly, we have to pass 1904 * the "result" object down just so it can do that with the appropriate 1905 * format, presumably. 1906 * 1907 * @param ApiResult $result 1908 * @return array Image info 1909 */ 1910 public function getImageInfo( $result ) { 1911 $file = $this->getLocalFile(); 1912 /** @todo This cries out for refactoring. 1913 * We really want to say $file->getAllInfo(); here. 1914 * Perhaps "info" methods should be moved into files, and the API should 1915 * just wrap them in queries. 1916 */ 1917 if ( $file instanceof UploadStashFile ) { 1918 $imParam = ApiQueryStashImageInfo::getPropertyNames(); 1919 $info = ApiQueryStashImageInfo::getInfo( $file, array_flip( $imParam ), $result ); 1920 } else { 1921 $imParam = ApiQueryImageInfo::getPropertyNames(); 1922 $info = ApiQueryImageInfo::getInfo( $file, array_flip( $imParam ), $result ); 1923 } 1924 1925 return $info; 1926 } 1927 1928 /** 1929 * @param array $error 1930 * @return Status 1931 */ 1932 public function convertVerifyErrorToStatus( $error ) { 1933 $code = $error['status']; 1934 unset( $code['status'] ); 1935 1936 return Status::newFatal( $this->getVerificationErrorCode( $code ), $error ); 1937 } 1938 1939 /** 1940 * @param null|string $forType 1941 * @return int 1942 */ 1943 public static function getMaxUploadSize( $forType = null ) { 1944 global $wgMaxUploadSize; 1945 1946 if ( is_array( $wgMaxUploadSize ) ) { 1947 if ( !is_null( $forType ) && isset( $wgMaxUploadSize[$forType] ) ) { 1948 return $wgMaxUploadSize[$forType]; 1949 } else { 1950 return $wgMaxUploadSize['*']; 1951 } 1952 } else { 1953 return intval( $wgMaxUploadSize ); 1954 } 1955 } 1956 1957 /** 1958 * Get the current status of a chunked upload (used for polling). 1959 * The status will be read from the *current* user session. 1960 * @param string $statusKey 1961 * @return Status[]|bool 1962 */ 1963 public static function getSessionStatus( $statusKey ) { 1964 return isset( $_SESSION[self::SESSION_STATUS_KEY][$statusKey] ) 1965 ? $_SESSION[self::SESSION_STATUS_KEY][$statusKey] 1966 : false; 1967 } 1968 1969 /** 1970 * Set the current status of a chunked upload (used for polling). 1971 * The status will be stored in the *current* user session. 1972 * @param string $statusKey 1973 * @param array|bool $value 1974 * @return void 1975 */ 1976 public static function setSessionStatus( $statusKey, $value ) { 1977 if ( $value === false ) { 1978 unset( $_SESSION[self::SESSION_STATUS_KEY][$statusKey] ); 1979 } else { 1980 $_SESSION[self::SESSION_STATUS_KEY][$statusKey] = $value; 1981 } 1982 } 1983 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Fri Nov 28 14:03:12 2014 | Cross-referenced by PHPXref 0.7.1 |