Completed
Branch master (62f6c6)
by
unknown
21:31
created

UploadBase::checkFileExtension()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 2
dl 0
loc 3
rs 10
c 0
b 0
f 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
		$this->mFileSize = $fileSize ?: null;
245
		if ( strlen( $this->mTempPath ) && file_exists( $this->mTempPath ) ) {
246
			$this->tempFileObj = new TempFSFile( $this->mTempPath );
247
			if ( !$fileSize ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fileSize of type integer|null is loosely compared to false; this is ambiguous if the integer can be zero. 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 integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

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