Completed
Branch master (bbf110)
by
unknown
25:51
created

UploadBase::tryStashFile()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 17
Code Lines 13

Duplication

Lines 6
Ratio 35.29 %

Importance

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