Completed
Branch master (8ef871)
by
unknown
29:40
created

UploadBase::getLocalFile()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 8
rs 9.4285
cc 3
eloc 5
nc 3
nop 0
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
	/** @var string Local file system path to the file to upload (or a local copy) */
40
	protected $mTempPath;
41
	/** @var TempFSFile|null Wrapper to handle deleting the temp file */
42
	protected $tempFileObj;
43
44
	protected $mDesiredDestName, $mDestName, $mRemoveTempFile, $mSourceType;
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
45
	protected $mTitle = false, $mTitleError = 0;
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
46
	protected $mFilteredName, $mFinalExtension;
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
47
	protected $mLocalFile, $mFileSize, $mFileProps;
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
48
	protected $mBlackListedExtensions;
49
	protected $mJavaDetected, $mSVGNSError;
0 ignored issues
show
Coding Style introduced by
It is generally advisable to only define one property per statement.

Only declaring a single property per statement allows you to later on add doc comments more easily.

It is also recommended by PSR2, so it is a common style that many people expect.

Loading history...
50
51
	protected static $safeXmlEncodings = [
52
		'UTF-8',
53
		'ISO-8859-1',
54
		'ISO-8859-2',
55
		'UTF-16',
56
		'UTF-32'
57
	];
58
59
	const SUCCESS = 0;
60
	const OK = 0;
61
	const EMPTY_FILE = 3;
62
	const MIN_LENGTH_PARTNAME = 4;
63
	const ILLEGAL_FILENAME = 5;
64
	const OVERWRITE_EXISTING_FILE = 7; # Not used anymore; handled by verifyTitlePermissions()
65
	const FILETYPE_MISSING = 8;
66
	const FILETYPE_BADTYPE = 9;
67
	const VERIFICATION_ERROR = 10;
68
	const HOOK_ABORTED = 11;
69
	const FILE_TOO_LARGE = 12;
70
	const WINDOWS_NONASCII_FILENAME = 13;
71
	const FILENAME_TOO_LONG = 14;
72
73
	/**
74
	 * @param int $error
75
	 * @return string
76
	 */
77
	public function getVerificationErrorCode( $error ) {
78
		$code_to_status = [
79
			self::EMPTY_FILE => 'empty-file',
80
			self::FILE_TOO_LARGE => 'file-too-large',
81
			self::FILETYPE_MISSING => 'filetype-missing',
82
			self::FILETYPE_BADTYPE => 'filetype-banned',
83
			self::MIN_LENGTH_PARTNAME => 'filename-tooshort',
84
			self::ILLEGAL_FILENAME => 'illegal-filename',
85
			self::OVERWRITE_EXISTING_FILE => 'overwrite',
86
			self::VERIFICATION_ERROR => 'verification-error',
87
			self::HOOK_ABORTED => 'hookaborted',
88
			self::WINDOWS_NONASCII_FILENAME => 'windows-nonascii-filename',
89
			self::FILENAME_TOO_LONG => 'filename-toolong',
90
		];
91
		if ( isset( $code_to_status[$error] ) ) {
92
			return $code_to_status[$error];
93
		}
94
95
		return 'unknown-error';
96
	}
97
98
	/**
99
	 * Returns true if uploads are enabled.
100
	 * Can be override by subclasses.
101
	 * @return bool
102
	 */
103
	public static function isEnabled() {
104
		global $wgEnableUploads;
105
106
		if ( !$wgEnableUploads ) {
107
			return false;
108
		}
109
110
		# Check php's file_uploads setting
111
		return wfIsHHVM() || wfIniGetBool( 'file_uploads' );
112
	}
113
114
	/**
115
	 * Returns true if the user can use this upload module or else a string
116
	 * identifying the missing permission.
117
	 * Can be overridden by subclasses.
118
	 *
119
	 * @param User $user
120
	 * @return bool|string
121
	 */
122
	public static function isAllowed( $user ) {
123
		foreach ( [ 'upload', 'edit' ] as $permission ) {
124
			if ( !$user->isAllowed( $permission ) ) {
125
				return $permission;
126
			}
127
		}
128
129
		return true;
130
	}
131
132
	/**
133
	 * Returns true if the user has surpassed the upload rate limit, false otherwise.
134
	 *
135
	 * @param User $user
136
	 * @return bool
137
	 */
138
	public static function isThrottled( $user ) {
139
		return $user->pingLimiter( 'upload' );
140
	}
141
142
	// Upload handlers. Should probably just be a global.
143
	private static $uploadHandlers = [ 'Stash', 'File', 'Url' ];
144
145
	/**
146
	 * Create a form of UploadBase depending on wpSourceType and initializes it
147
	 *
148
	 * @param WebRequest $request
149
	 * @param string|null $type
150
	 * @return null|UploadBase
151
	 */
152
	public static function createFromRequest( &$request, $type = null ) {
153
		$type = $type ? $type : $request->getVal( 'wpSourceType', 'File' );
154
155
		if ( !$type ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $type of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
156
			return null;
157
		}
158
159
		// Get the upload class
160
		$type = ucfirst( $type );
161
162
		// Give hooks the chance to handle this request
163
		$className = null;
164
		Hooks::run( 'UploadCreateFromRequest', [ $type, &$className ] );
165
		if ( is_null( $className ) ) {
166
			$className = 'UploadFrom' . $type;
167
			wfDebug( __METHOD__ . ": class name: $className\n" );
168
			if ( !in_array( $type, self::$uploadHandlers ) ) {
169
				return null;
170
			}
171
		}
172
173
		// Check whether this upload class is enabled
174
		if ( !call_user_func( [ $className, 'isEnabled' ] ) ) {
175
			return null;
176
		}
177
178
		// Check whether the request is valid
179
		if ( !call_user_func( [ $className, 'isValidRequest' ], $request ) ) {
180
			return null;
181
		}
182
183
		/** @var UploadBase $handler */
184
		$handler = new $className;
185
186
		$handler->initializeFromRequest( $request );
187
188
		return $handler;
189
	}
190
191
	/**
192
	 * Check whether a request if valid for this handler
193
	 * @param WebRequest $request
194
	 * @return bool
195
	 */
196
	public static function isValidRequest( $request ) {
197
		return false;
198
	}
199
200
	public function __construct() {
201
	}
202
203
	/**
204
	 * Returns the upload type. Should be overridden by child classes
205
	 *
206
	 * @since 1.18
207
	 * @return string
208
	 */
209
	public function getSourceType() {
210
		return null;
211
	}
212
213
	/**
214
	 * Initialize the path information
215
	 * @param string $name The desired destination name
216
	 * @param string $tempPath The temporary path
217
	 * @param int $fileSize The file size
218
	 * @param bool $removeTempFile (false) remove the temporary file?
219
	 * @throws MWException
220
	 */
221
	public function initializePathInfo( $name, $tempPath, $fileSize, $removeTempFile = false ) {
222
		$this->mDesiredDestName = $name;
223
		if ( FileBackend::isStoragePath( $tempPath ) ) {
224
			throw new MWException( __METHOD__ . " given storage path `$tempPath`." );
225
		}
226
227
		$this->setTempFile( $tempPath, $fileSize );
228
		$this->mRemoveTempFile = $removeTempFile;
229
	}
230
231
	/**
232
	 * Initialize from a WebRequest. Override this in a subclass.
233
	 *
234
	 * @param WebRequest $request
235
	 */
236
	abstract public function initializeFromRequest( &$request );
237
238
	/**
239
	 * @param string $tempPath File system path to temporary file containing the upload
240
	 * @param integer $fileSize
241
	 */
242
	protected function setTempFile( $tempPath, $fileSize = null ) {
243
		$this->mTempPath = $tempPath;
244
		if ( strlen( $this->mTempPath ) && file_exists( $this->mTempPath ) ) {
245
			$this->tempFileObj = new TempFSFile( $this->mTempPath );
246
			$this->mFileSize = $fileSize ?: filesize( $this->mTempPath );
247
		} else {
248
			$this->tempFileObj = null;
249
			$this->mFileSize = null;
250
		}
251
	}
252
253
	/**
254
	 * Fetch the file. Usually a no-op
255
	 * @return Status
256
	 */
257
	public function fetchFile() {
258
		return Status::newGood();
259
	}
260
261
	/**
262
	 * Return true if the file is empty
263
	 * @return bool
264
	 */
265
	public function isEmptyFile() {
266
		return empty( $this->mFileSize );
267
	}
268
269
	/**
270
	 * Return the file size
271
	 * @return int
272
	 */
273
	public function getFileSize() {
274
		return $this->mFileSize;
275
	}
276
277
	/**
278
	 * Get the base 36 SHA1 of the file
279
	 * @return string
280
	 */
281
	public function getTempFileSha1Base36() {
282
		return FSFile::getSha1Base36FromPath( $this->mTempPath );
283
	}
284
285
	/**
286
	 * @param string $srcPath The source path
287
	 * @return string|bool The real path if it was a virtual URL Returns false on failure
288
	 */
289
	function getRealPath( $srcPath ) {
290
		$repo = RepoGroup::singleton()->getLocalRepo();
291
		if ( $repo->isVirtualUrl( $srcPath ) ) {
292
			/** @todo Just make uploads work with storage paths UploadFromStash
293
			 *  loads files via virtual URLs.
294
			 */
295
			$tmpFile = $repo->getLocalCopy( $srcPath );
296
			if ( $tmpFile ) {
297
				$tmpFile->bind( $this ); // keep alive with $this
298
			}
299
			$path = $tmpFile ? $tmpFile->getPath() : false;
300
		} else {
301
			$path = $srcPath;
302
		}
303
304
		return $path;
305
	}
306
307
	/**
308
	 * Verify whether the upload is sane.
309
	 * @return mixed Const self::OK or else an array with error information
310
	 */
311
	public function verifyUpload() {
312
313
		/**
314
		 * If there was no filename or a zero size given, give up quick.
315
		 */
316
		if ( $this->isEmptyFile() ) {
317
			return [ 'status' => self::EMPTY_FILE ];
318
		}
319
320
		/**
321
		 * Honor $wgMaxUploadSize
322
		 */
323
		$maxSize = self::getMaxUploadSize( $this->getSourceType() );
324
		if ( $this->mFileSize > $maxSize ) {
325
			return [
326
				'status' => self::FILE_TOO_LARGE,
327
				'max' => $maxSize,
328
			];
329
		}
330
331
		/**
332
		 * Look at the contents of the file; if we can recognize the
333
		 * type but it's corrupt or data of the wrong type, we should
334
		 * probably not accept it.
335
		 */
336
		$verification = $this->verifyFile();
337
		if ( $verification !== true ) {
338
			return [
339
				'status' => self::VERIFICATION_ERROR,
340
				'details' => $verification
341
			];
342
		}
343
344
		/**
345
		 * Make sure this file can be created
346
		 */
347
		$result = $this->validateName();
348
		if ( $result !== true ) {
349
			return $result;
350
		}
351
352
		$error = '';
353
		if ( !Hooks::run( 'UploadVerification',
354
			[ $this->mDestName, $this->mTempPath, &$error ] )
355
		) {
356
			return [ 'status' => self::HOOK_ABORTED, 'error' => $error ];
357
		}
358
359
		return [ 'status' => self::OK ];
360
	}
361
362
	/**
363
	 * Verify that the name is valid and, if necessary, that we can overwrite
364
	 *
365
	 * @return mixed True if valid, otherwise and array with 'status'
366
	 * and other keys
367
	 */
368
	public function validateName() {
369
		$nt = $this->getTitle();
370
		if ( is_null( $nt ) ) {
371
			$result = [ 'status' => $this->mTitleError ];
372
			if ( $this->mTitleError == self::ILLEGAL_FILENAME ) {
373
				$result['filtered'] = $this->mFilteredName;
374
			}
375
			if ( $this->mTitleError == self::FILETYPE_BADTYPE ) {
376
				$result['finalExt'] = $this->mFinalExtension;
377
				if ( count( $this->mBlackListedExtensions ) ) {
378
					$result['blacklistedExt'] = $this->mBlackListedExtensions;
379
				}
380
			}
381
382
			return $result;
383
		}
384
		$this->mDestName = $this->getLocalFile()->getName();
385
386
		return true;
387
	}
388
389
	/**
390
	 * Verify the MIME type.
391
	 *
392
	 * @note Only checks that it is not an evil MIME. The "does it have
393
	 *  correct extension given its MIME type?" check is in verifyFile.
394
	 *  in `verifyFile()` that MIME type and file extension correlate.
395
	 * @param string $mime Representing the MIME
396
	 * @return mixed True if the file is verified, an array otherwise
397
	 */
398
	protected function verifyMimeType( $mime ) {
399
		global $wgVerifyMimeType;
400
		if ( $wgVerifyMimeType ) {
401
			wfDebug( "mime: <$mime> extension: <{$this->mFinalExtension}>\n" );
402
			global $wgMimeTypeBlacklist;
403
			if ( $this->checkFileExtension( $mime, $wgMimeTypeBlacklist ) ) {
404
				return [ 'filetype-badmime', $mime ];
405
			}
406
407
			# Check what Internet Explorer would detect
408
			$fp = fopen( $this->mTempPath, 'rb' );
409
			$chunk = fread( $fp, 256 );
410
			fclose( $fp );
411
412
			$magic = MimeMagic::singleton();
413
			$extMime = $magic->guessTypesForExtension( $this->mFinalExtension );
414
			$ieTypes = $magic->getIEMimeTypes( $this->mTempPath, $chunk, $extMime );
415
			foreach ( $ieTypes as $ieType ) {
416
				if ( $this->checkFileExtension( $ieType, $wgMimeTypeBlacklist ) ) {
417
					return [ 'filetype-bad-ie-mime', $ieType ];
418
				}
419
			}
420
		}
421
422
		return true;
423
	}
424
425
	/**
426
	 * Verifies that it's ok to include the uploaded file
427
	 *
428
	 * @return mixed True of the file is verified, array otherwise.
429
	 */
430
	protected function verifyFile() {
431
		global $wgVerifyMimeType, $wgDisableUploadScriptChecks;
432
433
		$status = $this->verifyPartialFile();
434
		if ( $status !== true ) {
435
			return $status;
436
		}
437
438
		$this->mFileProps = FSFile::getPropsFromPath( $this->mTempPath, $this->mFinalExtension );
439
		$mime = $this->mFileProps['mime'];
440
441
		if ( $wgVerifyMimeType ) {
442
			# XXX: Missing extension will be caught by validateName() via getTitle()
443
			if ( $this->mFinalExtension != '' && !$this->verifyExtension( $mime, $this->mFinalExtension ) ) {
444
				return [ 'filetype-mime-mismatch', $this->mFinalExtension, $mime ];
445
			}
446
		}
447
448
		# check for htmlish code and javascript
449 View Code Duplication
		if ( !$wgDisableUploadScriptChecks ) {
450
			if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) {
451
				$svgStatus = $this->detectScriptInSvg( $this->mTempPath, false );
452
				if ( $svgStatus !== false ) {
453
					return $svgStatus;
454
				}
455
			}
456
		}
457
458
		$handler = MediaHandler::getHandler( $mime );
459
		if ( $handler ) {
460
			$handlerStatus = $handler->verifyUpload( $this->mTempPath );
461
			if ( !$handlerStatus->isOK() ) {
462
				$errors = $handlerStatus->getErrorsArray();
0 ignored issues
show
Deprecated Code introduced by
The method Status::getErrorsArray() has been deprecated with message: 1.25

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
463
464
				return reset( $errors );
465
			}
466
		}
467
468
		Hooks::run( 'UploadVerifyFile', [ $this, $mime, &$status ] );
469
		if ( $status !== true ) {
470
			return $status;
471
		}
472
473
		wfDebug( __METHOD__ . ": all clear; passing.\n" );
474
475
		return true;
476
	}
477
478
	/**
479
	 * A verification routine suitable for partial files
480
	 *
481
	 * Runs the blacklist checks, but not any checks that may
482
	 * assume the entire file is present.
483
	 *
484
	 * @return mixed True for valid or array with error message key.
485
	 */
486
	protected function verifyPartialFile() {
487
		global $wgAllowJavaUploads, $wgDisableUploadScriptChecks;
488
489
		# getTitle() sets some internal parameters like $this->mFinalExtension
490
		$this->getTitle();
491
492
		$this->mFileProps = FSFile::getPropsFromPath( $this->mTempPath, $this->mFinalExtension );
493
494
		# check MIME type, if desired
495
		$mime = $this->mFileProps['file-mime'];
496
		$status = $this->verifyMimeType( $mime );
497
		if ( $status !== true ) {
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
				return [ 'uploadscripted' ];
505
			}
506 View Code Duplication
			if ( $this->mFinalExtension == 'svg' || $mime == 'image/svg+xml' ) {
507
				$svgStatus = $this->detectScriptInSvg( $this->mTempPath, true );
508
				if ( $svgStatus !== false ) {
509
					return $svgStatus;
510
				}
511
			}
512
		}
513
514
		# Check for Java applets, which if uploaded can bypass cross-site
515
		# restrictions.
516
		if ( !$wgAllowJavaUploads ) {
517
			$this->mJavaDetected = false;
518
			$zipStatus = ZipDirectoryReader::read( $this->mTempPath,
519
				[ $this, 'zipEntryCallback' ] );
520
			if ( !$zipStatus->isOK() ) {
521
				$errors = $zipStatus->getErrorsArray();
0 ignored issues
show
Deprecated Code introduced by
The method Status::getErrorsArray() has been deprecated with message: 1.25

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
522
				$error = reset( $errors );
523
				if ( $error[0] !== 'zip-wrong-format' ) {
524
					return $error;
525
				}
526
			}
527
			if ( $this->mJavaDetected ) {
528
				return [ 'uploadjava' ];
529
			}
530
		}
531
532
		# Scan the uploaded file for viruses
533
		$virus = $this->detectVirus( $this->mTempPath );
534
		if ( $virus ) {
535
			return [ 'uploadvirus', $virus ];
536
		}
537
538
		return true;
539
	}
540
541
	/**
542
	 * Callback for ZipDirectoryReader to detect Java class files.
543
	 *
544
	 * @param array $entry
545
	 */
546
	function zipEntryCallback( $entry ) {
547
		$names = [ $entry['name'] ];
548
549
		// If there is a null character, cut off the name at it, because JDK's
550
		// ZIP_GetEntry() uses strcmp() if the name hashes match. If a file name
551
		// were constructed which had ".class\0" followed by a string chosen to
552
		// make the hash collide with the truncated name, that file could be
553
		// returned in response to a request for the .class file.
554
		$nullPos = strpos( $entry['name'], "\000" );
555
		if ( $nullPos !== false ) {
556
			$names[] = substr( $entry['name'], 0, $nullPos );
557
		}
558
559
		// If there is a trailing slash in the file name, we have to strip it,
560
		// because that's what ZIP_GetEntry() does.
561
		if ( preg_grep( '!\.class/?$!', $names ) ) {
562
			$this->mJavaDetected = true;
563
		}
564
	}
565
566
	/**
567
	 * Alias for verifyTitlePermissions. The function was originally
568
	 * 'verifyPermissions', but that suggests it's checking the user, when it's
569
	 * really checking the title + user combination.
570
	 *
571
	 * @param User $user User object to verify the permissions against
572
	 * @return mixed An array as returned by getUserPermissionsErrors or true
573
	 *   in case the user has proper permissions.
574
	 */
575
	public function verifyPermissions( $user ) {
576
		return $this->verifyTitlePermissions( $user );
577
	}
578
579
	/**
580
	 * Check whether the user can edit, upload and create the image. This
581
	 * checks only against the current title; if it returns errors, it may
582
	 * very well be that another title will not give errors. Therefore
583
	 * isAllowed() should be called as well for generic is-user-blocked or
584
	 * can-user-upload checking.
585
	 *
586
	 * @param User $user User object to verify the permissions against
587
	 * @return mixed An array as returned by getUserPermissionsErrors or true
588
	 *   in case the user has proper permissions.
589
	 */
590
	public function verifyTitlePermissions( $user ) {
591
		/**
592
		 * If the image is protected, non-sysop users won't be able
593
		 * to modify it by uploading a new revision.
594
		 */
595
		$nt = $this->getTitle();
596
		if ( is_null( $nt ) ) {
597
			return true;
598
		}
599
		$permErrors = $nt->getUserPermissionsErrors( 'edit', $user );
600
		$permErrorsUpload = $nt->getUserPermissionsErrors( 'upload', $user );
601
		if ( !$nt->exists() ) {
602
			$permErrorsCreate = $nt->getUserPermissionsErrors( 'create', $user );
603
		} else {
604
			$permErrorsCreate = [];
605
		}
606
		if ( $permErrors || $permErrorsUpload || $permErrorsCreate ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $permErrors of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $permErrorsUpload of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug Best Practice introduced by
The expression $permErrorsCreate of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
607
			$permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsUpload, $permErrors ) );
608
			$permErrors = array_merge( $permErrors, wfArrayDiff2( $permErrorsCreate, $permErrors ) );
609
610
			return $permErrors;
611
		}
612
613
		$overwriteError = $this->checkOverwrite( $user );
614
		if ( $overwriteError !== true ) {
615
			return [ $overwriteError ];
616
		}
617
618
		return true;
619
	}
620
621
	/**
622
	 * Check for non fatal problems with the file.
623
	 *
624
	 * This should not assume that mTempPath is set.
625
	 *
626
	 * @return array Array of warnings
627
	 */
628
	public function checkWarnings() {
629
		global $wgLang;
630
631
		$warnings = [];
632
633
		$localFile = $this->getLocalFile();
634
		$localFile->load( File::READ_LATEST );
635
		$filename = $localFile->getName();
636
637
		/**
638
		 * Check whether the resulting filename is different from the desired one,
639
		 * but ignore things like ucfirst() and spaces/underscore things
640
		 */
641
		$comparableName = str_replace( ' ', '_', $this->mDesiredDestName );
642
		$comparableName = Title::capitalize( $comparableName, NS_FILE );
643
644
		if ( $this->mDesiredDestName != $filename && $comparableName != $filename ) {
645
			$warnings['badfilename'] = $filename;
646
			// Debugging for bug 62241
647
			wfDebugLog( 'upload', "Filename: '$filename', mDesiredDestName: "
648
				. "'$this->mDesiredDestName', comparableName: '$comparableName'" );
649
		}
650
651
		// Check whether the file extension is on the unwanted list
652
		global $wgCheckFileExtensions, $wgFileExtensions;
653
		if ( $wgCheckFileExtensions ) {
654
			$extensions = array_unique( $wgFileExtensions );
655
			if ( !$this->checkFileExtension( $this->mFinalExtension, $extensions ) ) {
656
				$warnings['filetype-unwanted-type'] = [ $this->mFinalExtension,
657
					$wgLang->commaList( $extensions ), count( $extensions ) ];
658
			}
659
		}
660
661
		global $wgUploadSizeWarning;
662
		if ( $wgUploadSizeWarning && ( $this->mFileSize > $wgUploadSizeWarning ) ) {
663
			$warnings['large-file'] = [ $wgUploadSizeWarning, $this->mFileSize ];
664
		}
665
666
		if ( $this->mFileSize == 0 ) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $this->mFileSize of type integer|null to 0; this is ambiguous as not only 0 == 0 is true, but null == 0 is true, too. Consider using a strict comparison ===.
Loading history...
667
			$warnings['emptyfile'] = true;
668
		}
669
670
		$exists = self::getExistsWarning( $localFile );
0 ignored issues
show
Bug introduced by
It seems like $localFile defined by $this->getLocalFile() on line 633 can also be of type null; however, UploadBase::getExistsWarning() does only seem to accept object<File>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
671
		if ( $exists !== false ) {
672
			$warnings['exists'] = $exists;
673
		}
674
675
		if ( $localFile->wasDeleted() && !$localFile->exists() ) {
676
			$warnings['was-deleted'] = $filename;
677
		}
678
679
		// Check dupes against existing files
680
		$hash = $this->getTempFileSha1Base36();
681
		$dupes = RepoGroup::singleton()->findBySha1( $hash );
0 ignored issues
show
Security Bug introduced by
It seems like $hash defined by $this->getTempFileSha1Base36() on line 680 can also be of type false; however, RepoGroup::findBySha1() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
682
		$title = $this->getTitle();
683
		// Remove all matches against self
684
		foreach ( $dupes as $key => $dupe ) {
685
			if ( $title->equals( $dupe->getTitle() ) ) {
686
				unset( $dupes[$key] );
687
			}
688
		}
689
		if ( $dupes ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $dupes of type File[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
690
			$warnings['duplicate'] = $dupes;
691
		}
692
693
		// Check dupes against archives
694
		$archivedFile = new ArchivedFile( null, 0, '', $hash );
0 ignored issues
show
Security Bug introduced by
It seems like $hash defined by $this->getTempFileSha1Base36() on line 680 can also be of type false; however, ArchivedFile::__construct() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
695
		if ( $archivedFile->getID() > 0 ) {
696
			if ( $archivedFile->userCan( File::DELETED_FILE ) ) {
697
				$warnings['duplicate-archive'] = $archivedFile->getName();
698
			} else {
699
				$warnings['duplicate-archive'] = '';
700
			}
701
		}
702
703
		return $warnings;
704
	}
705
706
	/**
707
	 * Really perform the upload. Stores the file in the local repo, watches
708
	 * if necessary and runs the UploadComplete hook.
709
	 *
710
	 * @param string $comment
711
	 * @param string $pageText
712
	 * @param bool $watch Whether the file page should be added to user's watchlist.
713
	 *   (This doesn't check $user's permissions.)
714
	 * @param User $user
715
	 * @param string[] $tags Change tags to add to the log entry and page revision.
716
	 *   (This doesn't check $user's permissions.)
717
	 * @return Status Indicating the whether the upload succeeded.
718
	 */
719
	public function performUpload( $comment, $pageText, $watch, $user, $tags = [] ) {
720
		$this->getLocalFile()->load( File::READ_LATEST );
721
722
		$status = $this->getLocalFile()->upload(
0 ignored issues
show
Bug introduced by
The method upload does only exist in LocalFile, but not in UploadStashFile.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
723
			$this->mTempPath,
724
			$comment,
725
			$pageText,
726
			File::DELETE_SOURCE,
727
			$this->mFileProps,
728
			false,
729
			$user,
730
			$tags
731
		);
732
733
		if ( $status->isGood() ) {
734
			if ( $watch ) {
735
				WatchAction::doWatch(
736
					$this->getLocalFile()->getTitle(),
737
					$user,
738
					User::IGNORE_USER_RIGHTS
739
				);
740
			}
741
			Hooks::run( 'UploadComplete', [ &$this ] );
742
743
			$this->postProcessUpload();
744
		}
745
746
		return $status;
747
	}
748
749
	/**
750
	 * Perform extra steps after a successful upload.
751
	 *
752
	 * @since  1.25
753
	 */
754
	public function postProcessUpload() {
755
		global $wgUploadThumbnailRenderMap;
756
757
		$jobs = [];
758
759
		$sizes = $wgUploadThumbnailRenderMap;
760
		rsort( $sizes );
761
762
		$file = $this->getLocalFile();
763
764
		foreach ( $sizes as $size ) {
765
			if ( $file->isVectorized() || $file->getWidth() > $size ) {
766
				$jobs[] = new ThumbnailRenderJob(
767
					$file->getTitle(),
768
					[ 'transformParams' => [ 'width' => $size ] ]
769
				);
770
			}
771
		}
772
773
		if ( $jobs ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $jobs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
774
			JobQueueGroup::singleton()->push( $jobs );
775
		}
776
	}
777
778
	/**
779
	 * Returns the title of the file to be uploaded. Sets mTitleError in case
780
	 * the name was illegal.
781
	 *
782
	 * @return Title The title of the file or null in case the name was illegal
783
	 */
784
	public function getTitle() {
785
		if ( $this->mTitle !== false ) {
786
			return $this->mTitle;
787
		}
788
		if ( !is_string( $this->mDesiredDestName ) ) {
789
			$this->mTitleError = self::ILLEGAL_FILENAME;
790
			$this->mTitle = null;
791
792
			return $this->mTitle;
793
		}
794
		/* Assume that if a user specified File:Something.jpg, this is an error
795
		 * and that the namespace prefix needs to be stripped of.
796
		 */
797
		$title = Title::newFromText( $this->mDesiredDestName );
798
		if ( $title && $title->getNamespace() == NS_FILE ) {
799
			$this->mFilteredName = $title->getDBkey();
800
		} else {
801
			$this->mFilteredName = $this->mDesiredDestName;
802
		}
803
804
		# oi_archive_name is max 255 bytes, which include a timestamp and an
805
		# exclamation mark, so restrict file name to 240 bytes.
806
		if ( strlen( $this->mFilteredName ) > 240 ) {
807
			$this->mTitleError = self::FILENAME_TOO_LONG;
808
			$this->mTitle = null;
809
810
			return $this->mTitle;
811
		}
812
813
		/**
814
		 * Chop off any directories in the given filename. Then
815
		 * filter out illegal characters, and try to make a legible name
816
		 * out of it. We'll strip some silently that Title would die on.
817
		 */
818
		$this->mFilteredName = wfStripIllegalFilenameChars( $this->mFilteredName );
819
		/* Normalize to title form before we do any further processing */
820
		$nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
821
		if ( is_null( $nt ) ) {
822
			$this->mTitleError = self::ILLEGAL_FILENAME;
823
			$this->mTitle = null;
824
825
			return $this->mTitle;
826
		}
827
		$this->mFilteredName = $nt->getDBkey();
828
829
		/**
830
		 * We'll want to blacklist against *any* 'extension', and use
831
		 * only the final one for the whitelist.
832
		 */
833
		list( $partname, $ext ) = $this->splitExtensions( $this->mFilteredName );
834
835
		if ( count( $ext ) ) {
836
			$this->mFinalExtension = trim( $ext[count( $ext ) - 1] );
837
		} else {
838
			$this->mFinalExtension = '';
839
840
			# No extension, try guessing one
841
			$magic = MimeMagic::singleton();
842
			$mime = $magic->guessMimeType( $this->mTempPath );
843
			if ( $mime !== 'unknown/unknown' ) {
844
				# Get a space separated list of extensions
845
				$extList = $magic->getExtensionsForType( $mime );
846
				if ( $extList ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $extList of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
847
					# Set the extension to the canonical extension
848
					$this->mFinalExtension = strtok( $extList, ' ' );
849
850
					# Fix up the other variables
851
					$this->mFilteredName .= ".{$this->mFinalExtension}";
852
					$nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
853
					$ext = [ $this->mFinalExtension ];
854
				}
855
			}
856
		}
857
858
		/* Don't allow users to override the blacklist (check file extension) */
859
		global $wgCheckFileExtensions, $wgStrictFileExtensions;
860
		global $wgFileExtensions, $wgFileBlacklist;
861
862
		$blackListedExtensions = $this->checkFileExtensionList( $ext, $wgFileBlacklist );
863
864
		if ( $this->mFinalExtension == '' ) {
865
			$this->mTitleError = self::FILETYPE_MISSING;
866
			$this->mTitle = null;
867
868
			return $this->mTitle;
869
		} elseif ( $blackListedExtensions ||
0 ignored issues
show
Bug Best Practice introduced by
The expression $blackListedExtensions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
870
			( $wgCheckFileExtensions && $wgStrictFileExtensions &&
871
				!$this->checkFileExtension( $this->mFinalExtension, $wgFileExtensions ) )
872
		) {
873
			$this->mBlackListedExtensions = $blackListedExtensions;
874
			$this->mTitleError = self::FILETYPE_BADTYPE;
875
			$this->mTitle = null;
876
877
			return $this->mTitle;
878
		}
879
880
		// Windows may be broken with special characters, see bug 1780
881
		if ( !preg_match( '/^[\x0-\x7f]*$/', $nt->getText() )
882
			&& !RepoGroup::singleton()->getLocalRepo()->backendSupportsUnicodePaths()
883
		) {
884
			$this->mTitleError = self::WINDOWS_NONASCII_FILENAME;
885
			$this->mTitle = null;
886
887
			return $this->mTitle;
888
		}
889
890
		# If there was more than one "extension", reassemble the base
891
		# filename to prevent bogus complaints about length
892
		if ( count( $ext ) > 1 ) {
893
			$iterations = count( $ext ) - 1;
894
			for ( $i = 0; $i < $iterations; $i++ ) {
895
				$partname .= '.' . $ext[$i];
896
			}
897
		}
898
899
		if ( strlen( $partname ) < 1 ) {
900
			$this->mTitleError = self::MIN_LENGTH_PARTNAME;
901
			$this->mTitle = null;
902
903
			return $this->mTitle;
904
		}
905
906
		$this->mTitle = $nt;
0 ignored issues
show
Documentation Bug introduced by
It seems like $nt can also be of type object<Title>. However, the property $mTitle is declared as type boolean. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
907
908
		return $this->mTitle;
909
	}
910
911
	/**
912
	 * Return the local file and initializes if necessary.
913
	 *
914
	 * @return LocalFile|UploadStashFile|null
915
	 */
916
	public function getLocalFile() {
917
		if ( is_null( $this->mLocalFile ) ) {
918
			$nt = $this->getTitle();
919
			$this->mLocalFile = is_null( $nt ) ? null : wfLocalFile( $nt );
920
		}
921
922
		return $this->mLocalFile;
923
	}
924
925
	/**
926
	 * If the user does not supply all necessary information in the first upload
927
	 * form submission (either by accident or by design) then we may want to
928
	 * stash the file temporarily, get more information, and publish the file
929
	 * later.
930
	 *
931
	 * This method will stash a file in a temporary directory for later
932
	 * processing, and save the necessary descriptive info into the database.
933
	 * This method returns the file object, which also has a 'fileKey' property
934
	 * which can be passed through a form or API request to find this stashed
935
	 * file again.
936
	 *
937
	 * @param User $user
938
	 * @return UploadStashFile Stashed file
939
	 */
940
	public function stashFile( User $user = null ) {
941
		// was stashSessionFile
942
943
		$stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $user );
944
		$file = $stash->stashFile( $this->mTempPath, $this->getSourceType() );
945
		$this->mLocalFile = $file;
946
947
		return $file;
948
	}
949
950
	/**
951
	 * Stash a file in a temporary directory, returning a key which can be used
952
	 * to find the file again. See stashFile().
953
	 *
954
	 * @return string File key
955
	 */
956
	public function stashFileGetKey() {
957
		return $this->stashFile()->getFileKey();
958
	}
959
960
	/**
961
	 * alias for stashFileGetKey, for backwards compatibility
962
	 *
963
	 * @return string File key
964
	 */
965
	public function stashSession() {
966
		return $this->stashFileGetKey();
967
	}
968
969
	/**
970
	 * If we've modified the upload file we need to manually remove it
971
	 * on exit to clean up.
972
	 */
973
	public function cleanupTempFile() {
974
		if ( $this->mRemoveTempFile && $this->tempFileObj ) {
975
			// Delete when all relevant TempFSFile handles go out of scope
976
			wfDebug( __METHOD__ . ": Marked temporary file '{$this->mTempPath}' for removal\n" );
977
			$this->tempFileObj->autocollect();
978
		}
979
	}
980
981
	public function getTempPath() {
982
		return $this->mTempPath;
983
	}
984
985
	/**
986
	 * Split a file into a base name and all dot-delimited 'extensions'
987
	 * on the end. Some web server configurations will fall back to
988
	 * earlier pseudo-'extensions' to determine type and execute
989
	 * scripts, so the blacklist needs to check them all.
990
	 *
991
	 * @param string $filename
992
	 * @return array
993
	 */
994
	public static function splitExtensions( $filename ) {
995
		$bits = explode( '.', $filename );
996
		$basename = array_shift( $bits );
997
998
		return [ $basename, $bits ];
999
	}
1000
1001
	/**
1002
	 * Perform case-insensitive match against a list of file extensions.
1003
	 * Returns true if the extension is in the list.
1004
	 *
1005
	 * @param string $ext
1006
	 * @param array $list
1007
	 * @return bool
1008
	 */
1009
	public static function checkFileExtension( $ext, $list ) {
1010
		return in_array( strtolower( $ext ), $list );
1011
	}
1012
1013
	/**
1014
	 * Perform case-insensitive match against a list of file extensions.
1015
	 * Returns an array of matching extensions.
1016
	 *
1017
	 * @param array $ext
1018
	 * @param array $list
1019
	 * @return bool
1020
	 */
1021
	public static function checkFileExtensionList( $ext, $list ) {
1022
		return array_intersect( array_map( 'strtolower', $ext ), $list );
1023
	}
1024
1025
	/**
1026
	 * Checks if the MIME type of the uploaded file matches the file extension.
1027
	 *
1028
	 * @param string $mime The MIME type of the uploaded file
1029
	 * @param string $extension The filename extension that the file is to be served with
1030
	 * @return bool
1031
	 */
1032
	public static function verifyExtension( $mime, $extension ) {
1033
		$magic = MimeMagic::singleton();
1034
1035
		if ( !$mime || $mime == 'unknown' || $mime == 'unknown/unknown' ) {
1036
			if ( !$magic->isRecognizableExtension( $extension ) ) {
1037
				wfDebug( __METHOD__ . ": passing file with unknown detected mime type; " .
1038
					"unrecognized extension '$extension', can't verify\n" );
1039
1040
				return true;
1041
			} else {
1042
				wfDebug( __METHOD__ . ": rejecting file with unknown detected mime type; " .
1043
					"recognized extension '$extension', so probably invalid file\n" );
1044
1045
				return false;
1046
			}
1047
		}
1048
1049
		$match = $magic->isMatchingExtension( $extension, $mime );
1050
1051
		if ( $match === null ) {
1052
			if ( $magic->getTypesForExtension( $extension ) !== null ) {
1053
				wfDebug( __METHOD__ . ": No extension known for $mime, but we know a mime for $extension\n" );
1054
1055
				return false;
1056
			} else {
1057
				wfDebug( __METHOD__ . ": no file extension known for mime type $mime, passing file\n" );
1058
1059
				return true;
1060
			}
1061
		} elseif ( $match === true ) {
1062
			wfDebug( __METHOD__ . ": mime type $mime matches extension $extension, passing file\n" );
1063
1064
			/** @todo If it's a bitmap, make sure PHP or ImageMagick resp. can handle it! */
1065
			return true;
1066
		} else {
1067
			wfDebug( __METHOD__
1068
				. ": mime type $mime mismatches file extension $extension, rejecting file\n" );
1069
1070
			return false;
1071
		}
1072
	}
1073
1074
	/**
1075
	 * Heuristic for detecting files that *could* contain JavaScript instructions or
1076
	 * things that may look like HTML to a browser and are thus
1077
	 * potentially harmful. The present implementation will produce false
1078
	 * positives in some situations.
1079
	 *
1080
	 * @param string $file Pathname to the temporary upload file
1081
	 * @param string $mime The MIME type of the file
1082
	 * @param string $extension The extension of the file
1083
	 * @return bool True if the file contains something looking like embedded scripts
1084
	 */
1085
	public static function detectScript( $file, $mime, $extension ) {
1086
		global $wgAllowTitlesInSVG;
1087
1088
		# ugly hack: for text files, always look at the entire file.
1089
		# For binary field, just check the first K.
1090
1091
		if ( strpos( $mime, 'text/' ) === 0 ) {
1092
			$chunk = file_get_contents( $file );
1093
		} else {
1094
			$fp = fopen( $file, 'rb' );
1095
			$chunk = fread( $fp, 1024 );
1096
			fclose( $fp );
1097
		}
1098
1099
		$chunk = strtolower( $chunk );
1100
1101
		if ( !$chunk ) {
1102
			return false;
1103
		}
1104
1105
		# decode from UTF-16 if needed (could be used for obfuscation).
1106
		if ( substr( $chunk, 0, 2 ) == "\xfe\xff" ) {
1107
			$enc = 'UTF-16BE';
1108
		} elseif ( substr( $chunk, 0, 2 ) == "\xff\xfe" ) {
1109
			$enc = 'UTF-16LE';
1110
		} else {
1111
			$enc = null;
1112
		}
1113
1114
		if ( $enc ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $enc of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1115
			$chunk = iconv( $enc, "ASCII//IGNORE", $chunk );
1116
		}
1117
1118
		$chunk = trim( $chunk );
1119
1120
		/** @todo FIXME: Convert from UTF-16 if necessary! */
1121
		wfDebug( __METHOD__ . ": checking for embedded scripts and HTML stuff\n" );
1122
1123
		# check for HTML doctype
1124
		if ( preg_match( "/<!DOCTYPE *X?HTML/i", $chunk ) ) {
1125
			return true;
1126
		}
1127
1128
		// Some browsers will interpret obscure xml encodings as UTF-8, while
1129
		// PHP/expat will interpret the given encoding in the xml declaration (bug 47304)
1130
		if ( $extension == 'svg' || strpos( $mime, 'image/svg' ) === 0 ) {
1131
			if ( self::checkXMLEncodingMissmatch( $file ) ) {
1132
				return true;
1133
			}
1134
		}
1135
1136
		/**
1137
		 * Internet Explorer for Windows performs some really stupid file type
1138
		 * autodetection which can cause it to interpret valid image files as HTML
1139
		 * and potentially execute JavaScript, creating a cross-site scripting
1140
		 * attack vectors.
1141
		 *
1142
		 * Apple's Safari browser also performs some unsafe file type autodetection
1143
		 * which can cause legitimate files to be interpreted as HTML if the
1144
		 * web server is not correctly configured to send the right content-type
1145
		 * (or if you're really uploading plain text and octet streams!)
1146
		 *
1147
		 * Returns true if IE is likely to mistake the given file for HTML.
1148
		 * Also returns true if Safari would mistake the given file for HTML
1149
		 * when served with a generic content-type.
1150
		 */
1151
		$tags = [
1152
			'<a href',
1153
			'<body',
1154
			'<head',
1155
			'<html', # also in safari
1156
			'<img',
1157
			'<pre',
1158
			'<script', # also in safari
1159
			'<table'
1160
		];
1161
1162
		if ( !$wgAllowTitlesInSVG && $extension !== 'svg' && $mime !== 'image/svg' ) {
1163
			$tags[] = '<title';
1164
		}
1165
1166
		foreach ( $tags as $tag ) {
1167
			if ( false !== strpos( $chunk, $tag ) ) {
1168
				wfDebug( __METHOD__ . ": found something that may make it be mistaken for html: $tag\n" );
1169
1170
				return true;
1171
			}
1172
		}
1173
1174
		/*
1175
		 * look for JavaScript
1176
		 */
1177
1178
		# resolve entity-refs to look at attributes. may be harsh on big files... cache result?
1179
		$chunk = Sanitizer::decodeCharReferences( $chunk );
1180
1181
		# look for script-types
1182
		if ( preg_match( '!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!sim', $chunk ) ) {
1183
			wfDebug( __METHOD__ . ": found script types\n" );
1184
1185
			return true;
1186
		}
1187
1188
		# look for html-style script-urls
1189
		if ( preg_match( '!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) {
1190
			wfDebug( __METHOD__ . ": found html-style script urls\n" );
1191
1192
			return true;
1193
		}
1194
1195
		# look for css-style script-urls
1196
		if ( preg_match( '!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) {
1197
			wfDebug( __METHOD__ . ": found css-style script urls\n" );
1198
1199
			return true;
1200
		}
1201
1202
		wfDebug( __METHOD__ . ": no scripts found\n" );
1203
1204
		return false;
1205
	}
1206
1207
	/**
1208
	 * Check a whitelist of xml encodings that are known not to be interpreted differently
1209
	 * by the server's xml parser (expat) and some common browsers.
1210
	 *
1211
	 * @param string $file Pathname to the temporary upload file
1212
	 * @return bool True if the file contains an encoding that could be misinterpreted
1213
	 */
1214
	public static function checkXMLEncodingMissmatch( $file ) {
1215
		global $wgSVGMetadataCutoff;
1216
		$contents = file_get_contents( $file, false, null, -1, $wgSVGMetadataCutoff );
1217
		$encodingRegex = '!encoding[ \t\n\r]*=[ \t\n\r]*[\'"](.*?)[\'"]!si';
1218
1219
		if ( preg_match( "!<\?xml\b(.*?)\?>!si", $contents, $matches ) ) {
1220 View Code Duplication
			if ( preg_match( $encodingRegex, $matches[1], $encMatch )
1221
				&& !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings )
1222
			) {
1223
				wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" );
1224
1225
				return true;
1226
			}
1227
		} elseif ( preg_match( "!<\?xml\b!si", $contents ) ) {
1228
			// Start of XML declaration without an end in the first $wgSVGMetadataCutoff
1229
			// bytes. There shouldn't be a legitimate reason for this to happen.
1230
			wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" );
1231
1232
			return true;
1233
		} elseif ( substr( $contents, 0, 4 ) == "\x4C\x6F\xA7\x94" ) {
1234
			// EBCDIC encoded XML
1235
			wfDebug( __METHOD__ . ": EBCDIC Encoded XML\n" );
1236
1237
			return true;
1238
		}
1239
1240
		// It's possible the file is encoded with multi-byte encoding, so re-encode attempt to
1241
		// detect the encoding in case is specifies an encoding not whitelisted in self::$safeXmlEncodings
1242
		$attemptEncodings = [ 'UTF-16', 'UTF-16BE', 'UTF-32', 'UTF-32BE' ];
1243
		foreach ( $attemptEncodings as $encoding ) {
1244
			MediaWiki\suppressWarnings();
1245
			$str = iconv( $encoding, 'UTF-8', $contents );
1246
			MediaWiki\restoreWarnings();
1247
			if ( $str != '' && preg_match( "!<\?xml\b(.*?)\?>!si", $str, $matches ) ) {
1248 View Code Duplication
				if ( preg_match( $encodingRegex, $matches[1], $encMatch )
1249
					&& !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings )
1250
				) {
1251
					wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" );
1252
1253
					return true;
1254
				}
1255
			} elseif ( $str != '' && preg_match( "!<\?xml\b!si", $str ) ) {
1256
				// Start of XML declaration without an end in the first $wgSVGMetadataCutoff
1257
				// bytes. There shouldn't be a legitimate reason for this to happen.
1258
				wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" );
1259
1260
				return true;
1261
			}
1262
		}
1263
1264
		return false;
1265
	}
1266
1267
	/**
1268
	 * @param string $filename
1269
	 * @param bool $partial
1270
	 * @return mixed False of the file is verified (does not contain scripts), array otherwise.
1271
	 */
1272
	protected function detectScriptInSvg( $filename, $partial ) {
1273
		$this->mSVGNSError = false;
1274
		$check = new XmlTypeCheck(
1275
			$filename,
1276
			[ $this, 'checkSvgScriptCallback' ],
1277
			true,
1278
			[ 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback' ]
1279
		);
1280
		if ( $check->wellFormed !== true ) {
1281
			// Invalid xml (bug 58553)
1282
			// But only when non-partial (bug 65724)
1283
			return $partial ? false : [ 'uploadinvalidxml' ];
1284
		} elseif ( $check->filterMatch ) {
1285
			if ( $this->mSVGNSError ) {
1286
				return [ 'uploadscriptednamespace', $this->mSVGNSError ];
1287
			}
1288
1289
			return $check->filterMatchType;
1290
		}
1291
1292
		return false;
1293
	}
1294
1295
	/**
1296
	 * Callback to filter SVG Processing Instructions.
1297
	 * @param string $target Processing instruction name
1298
	 * @param string $data Processing instruction attribute and value
1299
	 * @return bool (true if the filter identified something bad)
1300
	 */
1301
	public static function checkSvgPICallback( $target, $data ) {
1302
		// Don't allow external stylesheets (bug 57550)
1303
		if ( preg_match( '/xml-stylesheet/i', $target ) ) {
1304
			return [ 'upload-scripted-pi-callback' ];
1305
		}
1306
1307
		return false;
1308
	}
1309
1310
	/**
1311
	 * @todo Replace this with a whitelist filter!
1312
	 * @param string $element
1313
	 * @param array $attribs
1314
	 * @return bool
1315
	 */
1316
	public function checkSvgScriptCallback( $element, $attribs, $data = null ) {
1317
1318
		list( $namespace, $strippedElement ) = $this->splitXmlNamespace( $element );
1319
1320
		// We specifically don't include:
1321
		// http://www.w3.org/1999/xhtml (bug 60771)
1322
		static $validNamespaces = [
1323
			'',
1324
			'adobe:ns:meta/',
1325
			'http://creativecommons.org/ns#',
1326
			'http://inkscape.sourceforge.net/dtd/sodipodi-0.dtd',
1327
			'http://ns.adobe.com/adobeillustrator/10.0/',
1328
			'http://ns.adobe.com/adobesvgviewerextensions/3.0/',
1329
			'http://ns.adobe.com/extensibility/1.0/',
1330
			'http://ns.adobe.com/flows/1.0/',
1331
			'http://ns.adobe.com/illustrator/1.0/',
1332
			'http://ns.adobe.com/imagereplacement/1.0/',
1333
			'http://ns.adobe.com/pdf/1.3/',
1334
			'http://ns.adobe.com/photoshop/1.0/',
1335
			'http://ns.adobe.com/saveforweb/1.0/',
1336
			'http://ns.adobe.com/variables/1.0/',
1337
			'http://ns.adobe.com/xap/1.0/',
1338
			'http://ns.adobe.com/xap/1.0/g/',
1339
			'http://ns.adobe.com/xap/1.0/g/img/',
1340
			'http://ns.adobe.com/xap/1.0/mm/',
1341
			'http://ns.adobe.com/xap/1.0/rights/',
1342
			'http://ns.adobe.com/xap/1.0/stype/dimensions#',
1343
			'http://ns.adobe.com/xap/1.0/stype/font#',
1344
			'http://ns.adobe.com/xap/1.0/stype/manifestitem#',
1345
			'http://ns.adobe.com/xap/1.0/stype/resourceevent#',
1346
			'http://ns.adobe.com/xap/1.0/stype/resourceref#',
1347
			'http://ns.adobe.com/xap/1.0/t/pg/',
1348
			'http://purl.org/dc/elements/1.1/',
1349
			'http://purl.org/dc/elements/1.1',
1350
			'http://schemas.microsoft.com/visio/2003/svgextensions/',
1351
			'http://sodipodi.sourceforge.net/dtd/sodipodi-0.dtd',
1352
			'http://taptrix.com/inkpad/svg_extensions',
1353
			'http://web.resource.org/cc/',
1354
			'http://www.freesoftware.fsf.org/bkchem/cdml',
1355
			'http://www.inkscape.org/namespaces/inkscape',
1356
			'http://www.opengis.net/gml',
1357
			'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
1358
			'http://www.w3.org/2000/svg',
1359
			'http://www.w3.org/tr/rec-rdf-syntax/',
1360
		];
1361
1362
		if ( !in_array( $namespace, $validNamespaces ) ) {
1363
			wfDebug( __METHOD__ . ": Non-svg namespace '$namespace' in uploaded file.\n" );
1364
			/** @todo Return a status object to a closure in XmlTypeCheck, for MW1.21+ */
1365
			$this->mSVGNSError = $namespace;
1366
1367
			return true;
1368
		}
1369
1370
		/*
1371
		 * check for elements that can contain javascript
1372
		 */
1373 View Code Duplication
		if ( $strippedElement == 'script' ) {
1374
			wfDebug( __METHOD__ . ": Found script element '$element' in uploaded file.\n" );
1375
1376
			return [ 'uploaded-script-svg', $strippedElement ];
1377
		}
1378
1379
		# e.g., <svg xmlns="http://www.w3.org/2000/svg">
1380
		#  <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>
1381 View Code Duplication
		if ( $strippedElement == 'handler' ) {
1382
			wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" );
1383
1384
			return [ 'uploaded-script-svg', $strippedElement ];
1385
		}
1386
1387
		# SVG reported in Feb '12 that used xml:stylesheet to generate javascript block
1388 View Code Duplication
		if ( $strippedElement == 'stylesheet' ) {
1389
			wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" );
1390
1391
			return [ 'uploaded-script-svg', $strippedElement ];
1392
		}
1393
1394
		# Block iframes, in case they pass the namespace check
1395
		if ( $strippedElement == 'iframe' ) {
1396
			wfDebug( __METHOD__ . ": iframe in uploaded file.\n" );
1397
1398
			return [ 'uploaded-script-svg', $strippedElement ];
1399
		}
1400
1401
		# Check <style> css
1402
		if ( $strippedElement == 'style'
1403
			&& self::checkCssFragment( Sanitizer::normalizeCss( $data ) )
1404
		) {
1405
			wfDebug( __METHOD__ . ": hostile css in style element.\n" );
1406
			return [ 'uploaded-hostile-svg' ];
1407
		}
1408
1409
		foreach ( $attribs as $attrib => $value ) {
1410
			$stripped = $this->stripXmlNamespace( $attrib );
1411
			$value = strtolower( $value );
1412
1413
			if ( substr( $stripped, 0, 2 ) == 'on' ) {
1414
				wfDebug( __METHOD__
1415
					. ": Found event-handler attribute '$attrib'='$value' in uploaded file.\n" );
1416
1417
				return [ 'uploaded-event-handler-on-svg', $attrib, $value ];
1418
			}
1419
1420
			# href with non-local target (don't allow http://, javascript:, etc)
1421
			if ( $stripped == 'href'
1422
				&& strpos( $value, 'data:' ) !== 0
1423
				&& strpos( $value, '#' ) !== 0
1424
			) {
1425 View Code Duplication
				if ( !( $strippedElement === 'a'
1426
					&& preg_match( '!^https?://!im', $value ) )
1427
				) {
1428
					wfDebug( __METHOD__ . ": Found href attribute <$strippedElement "
1429
						. "'$attrib'='$value' in uploaded file.\n" );
1430
1431
					return [ 'uploaded-href-attribute-svg', $strippedElement, $attrib, $value ];
1432
				}
1433
			}
1434
1435
			# only allow data: targets that should be safe. This prevents vectors like,
1436
			# image/svg, text/xml, application/xml, and text/html, which can contain scripts
1437
			if ( $stripped == 'href' && strncasecmp( 'data:', $value, 5 ) === 0 ) {
1438
				// rfc2397 parameters. This is only slightly slower than (;[\w;]+)*.
1439
				// @codingStandardsIgnoreStart Generic.Files.LineLength
1440
				$parameters = '(?>;[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+=(?>[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+|"(?>[\0-\x0c\x0e-\x21\x23-\x5b\x5d-\x7f]+|\\\\[\0-\x7f])*"))*(?:;base64)?';
1441
				// @codingStandardsIgnoreEnd
1442
1443 View Code Duplication
				if ( !preg_match( "!^data:\s*image/(gif|jpeg|jpg|png)$parameters,!i", $value ) ) {
1444
					wfDebug( __METHOD__ . ": Found href to unwhitelisted data: uri "
1445
						. "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" );
1446
					return [ 'uploaded-href-unsafe-target-svg', $strippedElement, $attrib, $value ];
1447
				}
1448
			}
1449
1450
			# Change href with animate from (http://html5sec.org/#137).
1451
			if ( $stripped === 'attributename'
1452
				&& $strippedElement === 'animate'
1453
				&& $this->stripXmlNamespace( $value ) == 'href'
1454
			) {
1455
				wfDebug( __METHOD__ . ": Found animate that might be changing href using from "
1456
					. "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" );
1457
1458
				return [ 'uploaded-animate-svg', $strippedElement, $attrib, $value ];
1459
			}
1460
1461
			# use set/animate to add event-handler attribute to parent
1462
			if ( ( $strippedElement == 'set' || $strippedElement == 'animate' )
1463
				&& $stripped == 'attributename'
1464
				&& substr( $value, 0, 2 ) == 'on'
1465
			) {
1466
				wfDebug( __METHOD__ . ": Found svg setting event-handler attribute with "
1467
					. "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" );
1468
1469
				return [ 'uploaded-setting-event-handler-svg', $strippedElement, $stripped, $value ];
1470
			}
1471
1472
			# use set to add href attribute to parent element
1473
			if ( $strippedElement == 'set'
1474
				&& $stripped == 'attributename'
1475
				&& strpos( $value, 'href' ) !== false
1476
			) {
1477
				wfDebug( __METHOD__ . ": Found svg setting href attribute '$value' in uploaded file.\n" );
1478
1479
				return [ 'uploaded-setting-href-svg' ];
1480
			}
1481
1482
			# use set to add a remote / data / script target to an element
1483
			if ( $strippedElement == 'set'
1484
				&& $stripped == 'to'
1485
				&& preg_match( '!(http|https|data|script):!sim', $value )
1486
			) {
1487
				wfDebug( __METHOD__ . ": Found svg setting attribute to '$value' in uploaded file.\n" );
1488
1489
				return [ 'uploaded-wrong-setting-svg', $value ];
1490
			}
1491
1492
			# use handler attribute with remote / data / script
1493
			if ( $stripped == 'handler' && preg_match( '!(http|https|data|script):!sim', $value ) ) {
1494
				wfDebug( __METHOD__ . ": Found svg setting handler with remote/data/script "
1495
					. "'$attrib'='$value' in uploaded file.\n" );
1496
1497
				return [ 'uploaded-setting-handler-svg', $attrib, $value ];
1498
			}
1499
1500
			# use CSS styles to bring in remote code
1501
			if ( $stripped == 'style'
1502
				&& self::checkCssFragment( Sanitizer::normalizeCss( $value ) )
1503
			) {
1504
				wfDebug( __METHOD__ . ": Found svg setting a style with "
1505
					. "remote url '$attrib'='$value' in uploaded file.\n" );
1506
				return [ 'uploaded-remote-url-svg', $attrib, $value ];
1507
			}
1508
1509
			# Several attributes can include css, css character escaping isn't allowed
1510
			$cssAttrs = [ 'font', 'clip-path', 'fill', 'filter', 'marker',
1511
				'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke' ];
1512
			if ( in_array( $stripped, $cssAttrs )
1513
				&& self::checkCssFragment( $value )
1514
			) {
1515
				wfDebug( __METHOD__ . ": Found svg setting a style with "
1516
					. "remote url '$attrib'='$value' in uploaded file.\n" );
1517
				return [ 'uploaded-remote-url-svg', $attrib, $value ];
1518
			}
1519
1520
			# image filters can pull in url, which could be svg that executes scripts
1521
			if ( $strippedElement == 'image'
1522
				&& $stripped == 'filter'
1523
				&& preg_match( '!url\s*\(!sim', $value )
1524
			) {
1525
				wfDebug( __METHOD__ . ": Found image filter with url: "
1526
					. "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" );
1527
1528
				return [ 'uploaded-image-filter-svg', $strippedElement, $stripped, $value ];
1529
			}
1530
		}
1531
1532
		return false; // No scripts detected
1533
	}
1534
1535
	/**
1536
	 * Check a block of CSS or CSS fragment for anything that looks like
1537
	 * it is bringing in remote code.
1538
	 * @param string $value a string of CSS
1539
	 * @param bool $propOnly only check css properties (start regex with :)
0 ignored issues
show
Bug introduced by
There is no parameter named $propOnly. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
1540
	 * @return bool true if the CSS contains an illegal string, false if otherwise
1541
	 */
1542
	private static function checkCssFragment( $value ) {
1543
1544
		# Forbid external stylesheets, for both reliability and to protect viewer's privacy
1545
		if ( stripos( $value, '@import' ) !== false ) {
1546
			return true;
1547
		}
1548
1549
		# We allow @font-face to embed fonts with data: urls, so we snip the string
1550
		# 'url' out so this case won't match when we check for urls below
1551
		$pattern = '!(@font-face\s*{[^}]*src:)url(\("data:;base64,)!im';
1552
		$value = preg_replace( $pattern, '$1$2', $value );
1553
1554
		# Check for remote and executable CSS. Unlike in Sanitizer::checkCss, the CSS
1555
		# properties filter and accelerator don't seem to be useful for xss in SVG files.
1556
		# Expression and -o-link don't seem to work either, but filtering them here in case.
1557
		# Additionally, we catch remote urls like url("http:..., url('http:..., url(http:...,
1558
		# but not local ones such as url("#..., url('#..., url(#....
1559
		if ( preg_match( '!expression
1560
				| -o-link\s*:
1561
				| -o-link-source\s*:
1562
				| -o-replace\s*:!imx', $value ) ) {
1563
			return true;
1564
		}
1565
1566
		if ( preg_match_all(
1567
				"!(\s*(url|image|image-set)\s*\(\s*[\"']?\s*[^#]+.*?\))!sim",
1568
				$value,
1569
				$matches
1570
			) !== 0
1571
		) {
1572
			# TODO: redo this in one regex. Until then, url("#whatever") matches the first
1573
			foreach ( $matches[1] as $match ) {
1574
				if ( !preg_match( "!\s*(url|image|image-set)\s*\(\s*(#|'#|\"#)!im", $match ) ) {
1575
					return true;
1576
				}
1577
			}
1578
		}
1579
1580
		if ( preg_match( '/[\000-\010\013\016-\037\177]/', $value ) ) {
1581
			return true;
1582
		}
1583
1584
		return false;
1585
	}
1586
1587
	/**
1588
	 * Divide the element name passed by the xml parser to the callback into URI and prifix.
1589
	 * @param string $element
1590
	 * @return array Containing the namespace URI and prefix
1591
	 */
1592
	private static function splitXmlNamespace( $element ) {
1593
		// 'http://www.w3.org/2000/svg:script' -> array( 'http://www.w3.org/2000/svg', 'script' )
1594
		$parts = explode( ':', strtolower( $element ) );
1595
		$name = array_pop( $parts );
1596
		$ns = implode( ':', $parts );
1597
1598
		return [ $ns, $name ];
1599
	}
1600
1601
	/**
1602
	 * @param string $name
1603
	 * @return string
1604
	 */
1605
	private function stripXmlNamespace( $name ) {
1606
		// 'http://www.w3.org/2000/svg:script' -> 'script'
1607
		$parts = explode( ':', strtolower( $name ) );
1608
1609
		return array_pop( $parts );
1610
	}
1611
1612
	/**
1613
	 * Generic wrapper function for a virus scanner program.
1614
	 * This relies on the $wgAntivirus and $wgAntivirusSetup variables.
1615
	 * $wgAntivirusRequired may be used to deny upload if the scan fails.
1616
	 *
1617
	 * @param string $file Pathname to the temporary upload file
1618
	 * @return mixed False if not virus is found, null if the scan fails or is disabled,
1619
	 *   or a string containing feedback from the virus scanner if a virus was found.
1620
	 *   If textual feedback is missing but a virus was found, this function returns true.
1621
	 */
1622
	public static function detectVirus( $file ) {
1623
		global $wgAntivirus, $wgAntivirusSetup, $wgAntivirusRequired, $wgOut;
1624
1625
		if ( !$wgAntivirus ) {
1626
			wfDebug( __METHOD__ . ": virus scanner disabled\n" );
1627
1628
			return null;
1629
		}
1630
1631
		if ( !$wgAntivirusSetup[$wgAntivirus] ) {
1632
			wfDebug( __METHOD__ . ": unknown virus scanner: $wgAntivirus\n" );
1633
			$wgOut->wrapWikiMsg( "<div class=\"error\">\n$1\n</div>",
1634
				[ 'virus-badscanner', $wgAntivirus ] );
1635
1636
			return wfMessage( 'virus-unknownscanner' )->text() . " $wgAntivirus";
1637
		}
1638
1639
		# look up scanner configuration
1640
		$command = $wgAntivirusSetup[$wgAntivirus]['command'];
1641
		$exitCodeMap = $wgAntivirusSetup[$wgAntivirus]['codemap'];
1642
		$msgPattern = isset( $wgAntivirusSetup[$wgAntivirus]['messagepattern'] ) ?
1643
			$wgAntivirusSetup[$wgAntivirus]['messagepattern'] : null;
1644
1645
		if ( strpos( $command, "%f" ) === false ) {
1646
			# simple pattern: append file to scan
1647
			$command .= " " . wfEscapeShellArg( $file );
1648
		} else {
1649
			# complex pattern: replace "%f" with file to scan
1650
			$command = str_replace( "%f", wfEscapeShellArg( $file ), $command );
1651
		}
1652
1653
		wfDebug( __METHOD__ . ": running virus scan: $command \n" );
1654
1655
		# execute virus scanner
1656
		$exitCode = false;
1657
1658
		# NOTE: there's a 50 line workaround to make stderr redirection work on windows, too.
1659
		#      that does not seem to be worth the pain.
1660
		#      Ask me (Duesentrieb) about it if it's ever needed.
1661
		$output = wfShellExecWithStderr( $command, $exitCode );
1662
1663
		# map exit code to AV_xxx constants.
1664
		$mappedCode = $exitCode;
1665
		if ( $exitCodeMap ) {
1666
			if ( isset( $exitCodeMap[$exitCode] ) ) {
1667
				$mappedCode = $exitCodeMap[$exitCode];
1668
			} elseif ( isset( $exitCodeMap["*"] ) ) {
1669
				$mappedCode = $exitCodeMap["*"];
1670
			}
1671
		}
1672
1673
		/* NB: AV_NO_VIRUS is 0 but AV_SCAN_FAILED is false,
1674
		 * so we need the strict equalities === and thus can't use a switch here
1675
		 */
1676
		if ( $mappedCode === AV_SCAN_FAILED ) {
1677
			# scan failed (code was mapped to false by $exitCodeMap)
1678
			wfDebug( __METHOD__ . ": failed to scan $file (code $exitCode).\n" );
1679
1680
			$output = $wgAntivirusRequired
1681
				? wfMessage( 'virus-scanfailed', [ $exitCode ] )->text()
1682
				: null;
1683
		} elseif ( $mappedCode === AV_SCAN_ABORTED ) {
1684
			# scan failed because filetype is unknown (probably imune)
1685
			wfDebug( __METHOD__ . ": unsupported file type $file (code $exitCode).\n" );
1686
			$output = null;
1687
		} elseif ( $mappedCode === AV_NO_VIRUS ) {
1688
			# no virus found
1689
			wfDebug( __METHOD__ . ": file passed virus scan.\n" );
1690
			$output = false;
1691
		} else {
1692
			$output = trim( $output );
1693
1694
			if ( !$output ) {
1695
				$output = true; # if there's no output, return true
1696
			} elseif ( $msgPattern ) {
1697
				$groups = [];
1698
				if ( preg_match( $msgPattern, $output, $groups ) ) {
1699
					if ( $groups[1] ) {
1700
						$output = $groups[1];
1701
					}
1702
				}
1703
			}
1704
1705
			wfDebug( __METHOD__ . ": FOUND VIRUS! scanner feedback: $output \n" );
1706
		}
1707
1708
		return $output;
1709
	}
1710
1711
	/**
1712
	 * Check if there's an overwrite conflict and, if so, if restrictions
1713
	 * forbid this user from performing the upload.
1714
	 *
1715
	 * @param User $user
1716
	 *
1717
	 * @return mixed True on success, array on failure
1718
	 */
1719
	private function checkOverwrite( $user ) {
1720
		// First check whether the local file can be overwritten
1721
		$file = $this->getLocalFile();
1722
		$file->load( File::READ_LATEST );
1723
		if ( $file->exists() ) {
1724
			if ( !self::userCanReUpload( $user, $file ) ) {
0 ignored issues
show
Bug introduced by
It seems like $file defined by $this->getLocalFile() on line 1721 can also be of type null; however, UploadBase::userCanReUpload() does only seem to accept object<File>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
1725
				return [ 'fileexists-forbidden', $file->getName() ];
1726
			} else {
1727
				return true;
1728
			}
1729
		}
1730
1731
		/* Check shared conflicts: if the local file does not exist, but
1732
		 * wfFindFile finds a file, it exists in a shared repository.
1733
		 */
1734
		$file = wfFindFile( $this->getTitle(), [ 'latest' => true ] );
1735
		if ( $file && !$user->isAllowed( 'reupload-shared' ) ) {
1736
			return [ 'fileexists-shared-forbidden', $file->getName() ];
1737
		}
1738
1739
		return true;
1740
	}
1741
1742
	/**
1743
	 * Check if a user is the last uploader
1744
	 *
1745
	 * @param User $user
1746
	 * @param File $img
1747
	 * @return bool
1748
	 */
1749
	public static function userCanReUpload( User $user, File $img ) {
1750
		if ( $user->isAllowed( 'reupload' ) ) {
1751
			return true; // non-conditional
1752
		} elseif ( !$user->isAllowed( 'reupload-own' ) ) {
1753
			return false;
1754
		}
1755
1756
		if ( !( $img instanceof LocalFile ) ) {
1757
			return false;
1758
		}
1759
1760
		$img->load();
1761
1762
		return $user->getId() == $img->getUser( 'id' );
1763
	}
1764
1765
	/**
1766
	 * Helper function that does various existence checks for a file.
1767
	 * The following checks are performed:
1768
	 * - The file exists
1769
	 * - Article with the same name as the file exists
1770
	 * - File exists with normalized extension
1771
	 * - The file looks like a thumbnail and the original exists
1772
	 *
1773
	 * @param File $file The File object to check
1774
	 * @return mixed False if the file does not exists, else an array
1775
	 */
1776
	public static function getExistsWarning( $file ) {
1777
		if ( $file->exists() ) {
1778
			return [ 'warning' => 'exists', 'file' => $file ];
1779
		}
1780
1781
		if ( $file->getTitle()->getArticleID() ) {
1782
			return [ 'warning' => 'page-exists', 'file' => $file ];
1783
		}
1784
1785
		if ( strpos( $file->getName(), '.' ) == false ) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing strpos($file->getName(), '.') of type integer to the boolean false. If you are specifically checking for 0, consider using something more explicit like === 0 instead.
Loading history...
1786
			$partname = $file->getName();
1787
			$extension = '';
1788
		} else {
1789
			$n = strrpos( $file->getName(), '.' );
1790
			$extension = substr( $file->getName(), $n + 1 );
1791
			$partname = substr( $file->getName(), 0, $n );
1792
		}
1793
		$normalizedExtension = File::normalizeExtension( $extension );
1794
1795
		if ( $normalizedExtension != $extension ) {
1796
			// We're not using the normalized form of the extension.
1797
			// Normal form is lowercase, using most common of alternate
1798
			// extensions (eg 'jpg' rather than 'JPEG').
1799
1800
			// Check for another file using the normalized form...
1801
			$nt_lc = Title::makeTitle( NS_FILE, "{$partname}.{$normalizedExtension}" );
1802
			$file_lc = wfLocalFile( $nt_lc );
1803
1804
			if ( $file_lc->exists() ) {
1805
				return [
1806
					'warning' => 'exists-normalized',
1807
					'file' => $file,
1808
					'normalizedFile' => $file_lc
1809
				];
1810
			}
1811
		}
1812
1813
		// Check for files with the same name but a different extension
1814
		$similarFiles = RepoGroup::singleton()->getLocalRepo()->findFilesByPrefix(
1815
			"{$partname}.", 1 );
1816
		if ( count( $similarFiles ) ) {
1817
			return [
1818
				'warning' => 'exists-normalized',
1819
				'file' => $file,
1820
				'normalizedFile' => $similarFiles[0],
1821
			];
1822
		}
1823
1824
		if ( self::isThumbName( $file->getName() ) ) {
1825
			# Check for filenames like 50px- or 180px-, these are mostly thumbnails
1826
			$nt_thb = Title::newFromText(
1827
				substr( $partname, strpos( $partname, '-' ) + 1 ) . '.' . $extension,
1828
				NS_FILE
1829
			);
1830
			$file_thb = wfLocalFile( $nt_thb );
0 ignored issues
show
Bug introduced by
It seems like $nt_thb defined by \Title::newFromText(subs... . $extension, NS_FILE) on line 1826 can be null; however, wfLocalFile() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1831
			if ( $file_thb->exists() ) {
1832
				return [
1833
					'warning' => 'thumb',
1834
					'file' => $file,
1835
					'thumbFile' => $file_thb
1836
				];
1837
			} else {
1838
				// File does not exist, but we just don't like the name
1839
				return [
1840
					'warning' => 'thumb-name',
1841
					'file' => $file,
1842
					'thumbFile' => $file_thb
1843
				];
1844
			}
1845
		}
1846
1847
		foreach ( self::getFilenamePrefixBlacklist() as $prefix ) {
1848
			if ( substr( $partname, 0, strlen( $prefix ) ) == $prefix ) {
1849
				return [
1850
					'warning' => 'bad-prefix',
1851
					'file' => $file,
1852
					'prefix' => $prefix
1853
				];
1854
			}
1855
		}
1856
1857
		return false;
1858
	}
1859
1860
	/**
1861
	 * Helper function that checks whether the filename looks like a thumbnail
1862
	 * @param string $filename
1863
	 * @return bool
1864
	 */
1865
	public static function isThumbName( $filename ) {
1866
		$n = strrpos( $filename, '.' );
1867
		$partname = $n ? substr( $filename, 0, $n ) : $filename;
1868
1869
		return (
1870
			substr( $partname, 3, 3 ) == 'px-' ||
1871
			substr( $partname, 2, 3 ) == 'px-'
1872
		) &&
1873
		preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) );
1874
	}
1875
1876
	/**
1877
	 * Get a list of blacklisted filename prefixes from [[MediaWiki:Filename-prefix-blacklist]]
1878
	 *
1879
	 * @return array List of prefixes
1880
	 */
1881
	public static function getFilenamePrefixBlacklist() {
1882
		$blacklist = [];
1883
		$message = wfMessage( 'filename-prefix-blacklist' )->inContentLanguage();
1884
		if ( !$message->isDisabled() ) {
1885
			$lines = explode( "\n", $message->plain() );
1886
			foreach ( $lines as $line ) {
1887
				// Remove comment lines
1888
				$comment = substr( trim( $line ), 0, 1 );
1889
				if ( $comment == '#' || $comment == '' ) {
1890
					continue;
1891
				}
1892
				// Remove additional comments after a prefix
1893
				$comment = strpos( $line, '#' );
1894
				if ( $comment > 0 ) {
1895
					$line = substr( $line, 0, $comment - 1 );
1896
				}
1897
				$blacklist[] = trim( $line );
1898
			}
1899
		}
1900
1901
		return $blacklist;
1902
	}
1903
1904
	/**
1905
	 * Gets image info about the file just uploaded.
1906
	 *
1907
	 * Also has the effect of setting metadata to be an 'indexed tag name' in
1908
	 * returned API result if 'metadata' was requested. Oddly, we have to pass
1909
	 * the "result" object down just so it can do that with the appropriate
1910
	 * format, presumably.
1911
	 *
1912
	 * @param ApiResult $result
1913
	 * @return array Image info
1914
	 */
1915
	public function getImageInfo( $result ) {
1916
		$file = $this->getLocalFile();
1917
		/** @todo This cries out for refactoring.
1918
		 *  We really want to say $file->getAllInfo(); here.
1919
		 * Perhaps "info" methods should be moved into files, and the API should
1920
		 * just wrap them in queries.
1921
		 */
1922
		if ( $file instanceof UploadStashFile ) {
1923
			$imParam = ApiQueryStashImageInfo::getPropertyNames();
1924
			$info = ApiQueryStashImageInfo::getInfo( $file, array_flip( $imParam ), $result );
1925
		} else {
1926
			$imParam = ApiQueryImageInfo::getPropertyNames();
1927
			$info = ApiQueryImageInfo::getInfo( $file, array_flip( $imParam ), $result );
0 ignored issues
show
Bug introduced by
It seems like $file defined by $this->getLocalFile() on line 1916 can be null; however, ApiQueryImageInfo::getInfo() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
1928
		}
1929
1930
		return $info;
1931
	}
1932
1933
	/**
1934
	 * @param array $error
1935
	 * @return Status
1936
	 */
1937
	public function convertVerifyErrorToStatus( $error ) {
1938
		$code = $error['status'];
1939
		unset( $code['status'] );
1940
1941
		return Status::newFatal( $this->getVerificationErrorCode( $code ), $error );
1942
	}
1943
1944
	/**
1945
	 * Get the MediaWiki maximum uploaded file size for given type of upload, based on
1946
	 * $wgMaxUploadSize.
1947
	 *
1948
	 * @param null|string $forType
1949
	 * @return int
1950
	 */
1951
	public static function getMaxUploadSize( $forType = null ) {
1952
		global $wgMaxUploadSize;
1953
1954
		if ( is_array( $wgMaxUploadSize ) ) {
1955
			if ( !is_null( $forType ) && isset( $wgMaxUploadSize[$forType] ) ) {
1956
				return $wgMaxUploadSize[$forType];
1957
			} else {
1958
				return $wgMaxUploadSize['*'];
1959
			}
1960
		} else {
1961
			return intval( $wgMaxUploadSize );
1962
		}
1963
	}
1964
1965
	/**
1966
	 * Get the PHP maximum uploaded file size, based on ini settings. If there is no limit or the
1967
	 * limit can't be guessed, returns a very large number (PHP_INT_MAX).
1968
	 *
1969
	 * @since 1.27
1970
	 * @return int
1971
	 */
1972
	public static function getMaxPhpUploadSize() {
1973
		$phpMaxFileSize = wfShorthandToInteger(
1974
			ini_get( 'upload_max_filesize' ) ?: ini_get( 'hhvm.server.upload.upload_max_file_size' ),
1975
			PHP_INT_MAX
1976
		);
1977
		$phpMaxPostSize = wfShorthandToInteger(
1978
			ini_get( 'post_max_size' ) ?: ini_get( 'hhvm.server.max_post_size' ),
1979
			PHP_INT_MAX
1980
		) ?: PHP_INT_MAX;
1981
		return min( $phpMaxFileSize, $phpMaxPostSize );
1982
	}
1983
1984
	/**
1985
	 * Get the current status of a chunked upload (used for polling)
1986
	 *
1987
	 * The value will be read from cache.
1988
	 *
1989
	 * @param User $user
1990
	 * @param string $statusKey
1991
	 * @return Status[]|bool
1992
	 */
1993
	public static function getSessionStatus( User $user, $statusKey ) {
1994
		$key = wfMemcKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey );
1995
1996
		return ObjectCache::getMainStashInstance()->get( $key );
1997
	}
1998
1999
	/**
2000
	 * Set the current status of a chunked upload (used for polling)
2001
	 *
2002
	 * The value will be set in cache for 1 day
2003
	 *
2004
	 * @param User $user
2005
	 * @param string $statusKey
2006
	 * @param array|bool $value
2007
	 * @return void
2008
	 */
2009
	public static function setSessionStatus( User $user, $statusKey, $value ) {
2010
		$key = wfMemcKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey );
2011
2012
		$cache = ObjectCache::getMainStashInstance();
2013
		if ( $value === false ) {
2014
			$cache->delete( $key );
2015
		} else {
2016
			$cache->set( $key, $value, $cache::TTL_DAY );
2017
		}
2018
	}
2019
}
2020