Completed
Branch master (68979b)
by
unknown
26:11
created

UploadBase::verifyPermissions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 3
rs 10
cc 1
eloc 2
nc 1
nop 1
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 ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $permErrors of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

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

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

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

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

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

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

Loading history...
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
			// Debugging for bug 62241
649
			wfDebugLog( 'upload', "Filename: '$filename', mDesiredDestName: "
650
				. "'$this->mDesiredDestName', comparableName: '$comparableName'" );
651
		}
652
653
		// Check whether the file extension is on the unwanted list
654
		global $wgCheckFileExtensions, $wgFileExtensions;
655
		if ( $wgCheckFileExtensions ) {
656
			$extensions = array_unique( $wgFileExtensions );
657
			if ( !$this->checkFileExtension( $this->mFinalExtension, $extensions ) ) {
658
				$warnings['filetype-unwanted-type'] = [ $this->mFinalExtension,
659
					$wgLang->commaList( $extensions ), count( $extensions ) ];
660
			}
661
		}
662
663
		global $wgUploadSizeWarning;
664
		if ( $wgUploadSizeWarning && ( $this->mFileSize > $wgUploadSizeWarning ) ) {
665
			$warnings['large-file'] = [ $wgUploadSizeWarning, $this->mFileSize ];
666
		}
667
668
		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...
669
			$warnings['emptyfile'] = true;
670
		}
671
672
		$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...
673
		if ( $exists !== false ) {
674
			$warnings['exists'] = $exists;
675
		}
676
677
		if ( $localFile->wasDeleted() && !$localFile->exists() ) {
678
			$warnings['was-deleted'] = $filename;
679
		}
680
681
		// Check dupes against existing files
682
		$hash = $this->getTempFileSha1Base36();
683
		$dupes = RepoGroup::singleton()->findBySha1( $hash );
0 ignored issues
show
Security Bug introduced by
It seems like $hash defined by $this->getTempFileSha1Base36() on line 682 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...
684
		$title = $this->getTitle();
685
		// Remove all matches against self
686
		foreach ( $dupes as $key => $dupe ) {
687
			if ( $title->equals( $dupe->getTitle() ) ) {
688
				unset( $dupes[$key] );
689
			}
690
		}
691
		if ( $dupes ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $dupes of type File[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
692
			$warnings['duplicate'] = $dupes;
693
		}
694
695
		// Check dupes against archives
696
		$archivedFile = new ArchivedFile( null, 0, '', $hash );
0 ignored issues
show
Security Bug introduced by
It seems like $hash defined by $this->getTempFileSha1Base36() on line 682 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...
697
		if ( $archivedFile->getID() > 0 ) {
698
			if ( $archivedFile->userCan( File::DELETED_FILE ) ) {
699
				$warnings['duplicate-archive'] = $archivedFile->getName();
700
			} else {
701
				$warnings['duplicate-archive'] = '';
702
			}
703
		}
704
705
		return $warnings;
706
	}
707
708
	/**
709
	 * Really perform the upload. Stores the file in the local repo, watches
710
	 * if necessary and runs the UploadComplete hook.
711
	 *
712
	 * @param string $comment
713
	 * @param string $pageText
714
	 * @param bool $watch Whether the file page should be added to user's watchlist.
715
	 *   (This doesn't check $user's permissions.)
716
	 * @param User $user
717
	 * @param string[] $tags Change tags to add to the log entry and page revision.
718
	 *   (This doesn't check $user's permissions.)
719
	 * @return Status Indicating the whether the upload succeeded.
720
	 */
721
	public function performUpload( $comment, $pageText, $watch, $user, $tags = [] ) {
722
		$this->getLocalFile()->load( File::READ_LATEST );
723
724
		$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...
725
			$this->mTempPath,
726
			$comment,
727
			$pageText,
728
			File::DELETE_SOURCE,
729
			$this->mFileProps,
730
			false,
731
			$user,
732
			$tags
733
		);
734
735
		if ( $status->isGood() ) {
736
			if ( $watch ) {
737
				WatchAction::doWatch(
738
					$this->getLocalFile()->getTitle(),
739
					$user,
740
					User::IGNORE_USER_RIGHTS
741
				);
742
			}
743
			Hooks::run( 'UploadComplete', [ &$this ] );
744
745
			$this->postProcessUpload();
746
		}
747
748
		return $status;
749
	}
750
751
	/**
752
	 * Perform extra steps after a successful upload.
753
	 *
754
	 * @since  1.25
755
	 */
756
	public function postProcessUpload() {
757
		global $wgUploadThumbnailRenderMap;
758
759
		$jobs = [];
760
761
		$sizes = $wgUploadThumbnailRenderMap;
762
		rsort( $sizes );
763
764
		$file = $this->getLocalFile();
765
766
		foreach ( $sizes as $size ) {
767
			if ( $file->isVectorized() || $file->getWidth() > $size ) {
768
				$jobs[] = new ThumbnailRenderJob(
769
					$file->getTitle(),
770
					[ 'transformParams' => [ 'width' => $size ] ]
771
				);
772
			}
773
		}
774
775
		if ( $jobs ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $jobs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
776
			JobQueueGroup::singleton()->push( $jobs );
777
		}
778
	}
779
780
	/**
781
	 * Returns the title of the file to be uploaded. Sets mTitleError in case
782
	 * the name was illegal.
783
	 *
784
	 * @return Title The title of the file or null in case the name was illegal
785
	 */
786
	public function getTitle() {
787
		if ( $this->mTitle !== false ) {
788
			return $this->mTitle;
789
		}
790
		if ( !is_string( $this->mDesiredDestName ) ) {
791
			$this->mTitleError = self::ILLEGAL_FILENAME;
792
			$this->mTitle = null;
793
794
			return $this->mTitle;
795
		}
796
		/* Assume that if a user specified File:Something.jpg, this is an error
797
		 * and that the namespace prefix needs to be stripped of.
798
		 */
799
		$title = Title::newFromText( $this->mDesiredDestName );
800
		if ( $title && $title->getNamespace() == NS_FILE ) {
801
			$this->mFilteredName = $title->getDBkey();
802
		} else {
803
			$this->mFilteredName = $this->mDesiredDestName;
804
		}
805
806
		# oi_archive_name is max 255 bytes, which include a timestamp and an
807
		# exclamation mark, so restrict file name to 240 bytes.
808
		if ( strlen( $this->mFilteredName ) > 240 ) {
809
			$this->mTitleError = self::FILENAME_TOO_LONG;
810
			$this->mTitle = null;
811
812
			return $this->mTitle;
813
		}
814
815
		/**
816
		 * Chop off any directories in the given filename. Then
817
		 * filter out illegal characters, and try to make a legible name
818
		 * out of it. We'll strip some silently that Title would die on.
819
		 */
820
		$this->mFilteredName = wfStripIllegalFilenameChars( $this->mFilteredName );
821
		/* Normalize to title form before we do any further processing */
822
		$nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
823
		if ( is_null( $nt ) ) {
824
			$this->mTitleError = self::ILLEGAL_FILENAME;
825
			$this->mTitle = null;
826
827
			return $this->mTitle;
828
		}
829
		$this->mFilteredName = $nt->getDBkey();
830
831
		/**
832
		 * We'll want to blacklist against *any* 'extension', and use
833
		 * only the final one for the whitelist.
834
		 */
835
		list( $partname, $ext ) = $this->splitExtensions( $this->mFilteredName );
836
837
		if ( count( $ext ) ) {
838
			$this->mFinalExtension = trim( $ext[count( $ext ) - 1] );
839
		} else {
840
			$this->mFinalExtension = '';
841
842
			# No extension, try guessing one
843
			$magic = MimeMagic::singleton();
844
			$mime = $magic->guessMimeType( $this->mTempPath );
845
			if ( $mime !== 'unknown/unknown' ) {
846
				# Get a space separated list of extensions
847
				$extList = $magic->getExtensionsForType( $mime );
848
				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...
849
					# Set the extension to the canonical extension
850
					$this->mFinalExtension = strtok( $extList, ' ' );
851
852
					# Fix up the other variables
853
					$this->mFilteredName .= ".{$this->mFinalExtension}";
854
					$nt = Title::makeTitleSafe( NS_FILE, $this->mFilteredName );
855
					$ext = [ $this->mFinalExtension ];
856
				}
857
			}
858
		}
859
860
		/* Don't allow users to override the blacklist (check file extension) */
861
		global $wgCheckFileExtensions, $wgStrictFileExtensions;
862
		global $wgFileExtensions, $wgFileBlacklist;
863
864
		$blackListedExtensions = $this->checkFileExtensionList( $ext, $wgFileBlacklist );
865
866
		if ( $this->mFinalExtension == '' ) {
867
			$this->mTitleError = self::FILETYPE_MISSING;
868
			$this->mTitle = null;
869
870
			return $this->mTitle;
871
		} elseif ( $blackListedExtensions ||
0 ignored issues
show
Bug Best Practice introduced by
The expression $blackListedExtensions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
872
			( $wgCheckFileExtensions && $wgStrictFileExtensions &&
873
				!$this->checkFileExtension( $this->mFinalExtension, $wgFileExtensions ) )
874
		) {
875
			$this->mBlackListedExtensions = $blackListedExtensions;
876
			$this->mTitleError = self::FILETYPE_BADTYPE;
877
			$this->mTitle = null;
878
879
			return $this->mTitle;
880
		}
881
882
		// Windows may be broken with special characters, see bug 1780
883
		if ( !preg_match( '/^[\x0-\x7f]*$/', $nt->getText() )
884
			&& !RepoGroup::singleton()->getLocalRepo()->backendSupportsUnicodePaths()
885
		) {
886
			$this->mTitleError = self::WINDOWS_NONASCII_FILENAME;
887
			$this->mTitle = null;
888
889
			return $this->mTitle;
890
		}
891
892
		# If there was more than one "extension", reassemble the base
893
		# filename to prevent bogus complaints about length
894
		if ( count( $ext ) > 1 ) {
895
			$iterations = count( $ext ) - 1;
896
			for ( $i = 0; $i < $iterations; $i++ ) {
897
				$partname .= '.' . $ext[$i];
898
			}
899
		}
900
901
		if ( strlen( $partname ) < 1 ) {
902
			$this->mTitleError = self::MIN_LENGTH_PARTNAME;
903
			$this->mTitle = null;
904
905
			return $this->mTitle;
906
		}
907
908
		$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...
909
910
		return $this->mTitle;
911
	}
912
913
	/**
914
	 * Return the local file and initializes if necessary.
915
	 *
916
	 * @return LocalFile|UploadStashFile|null
917
	 */
918
	public function getLocalFile() {
919
		if ( is_null( $this->mLocalFile ) ) {
920
			$nt = $this->getTitle();
921
			$this->mLocalFile = is_null( $nt ) ? null : wfLocalFile( $nt );
922
		}
923
924
		return $this->mLocalFile;
925
	}
926
927
	/**
928
	 * If the user does not supply all necessary information in the first upload
929
	 * form submission (either by accident or by design) then we may want to
930
	 * stash the file temporarily, get more information, and publish the file
931
	 * later.
932
	 *
933
	 * This method will stash a file in a temporary directory for later
934
	 * processing, and save the necessary descriptive info into the database.
935
	 * This method returns the file object, which also has a 'fileKey' property
936
	 * which can be passed through a form or API request to find this stashed
937
	 * file again.
938
	 *
939
	 * @param User $user
940
	 * @return UploadStashFile Stashed file
941
	 */
942
	public function stashFile( User $user = null ) {
943
		// was stashSessionFile
944
945
		$stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $user );
946
		$file = $stash->stashFile( $this->mTempPath, $this->getSourceType() );
947
		$this->mLocalFile = $file;
948
949
		return $file;
950
	}
951
952
	/**
953
	 * Stash a file in a temporary directory, returning a key which can be used
954
	 * to find the file again. See stashFile().
955
	 *
956
	 * @return string File key
957
	 */
958
	public function stashFileGetKey() {
959
		return $this->stashFile()->getFileKey();
960
	}
961
962
	/**
963
	 * alias for stashFileGetKey, for backwards compatibility
964
	 *
965
	 * @return string File key
966
	 */
967
	public function stashSession() {
968
		return $this->stashFileGetKey();
969
	}
970
971
	/**
972
	 * If we've modified the upload file we need to manually remove it
973
	 * on exit to clean up.
974
	 */
975
	public function cleanupTempFile() {
976
		if ( $this->mRemoveTempFile && $this->tempFileObj ) {
977
			// Delete when all relevant TempFSFile handles go out of scope
978
			wfDebug( __METHOD__ . ": Marked temporary file '{$this->mTempPath}' for removal\n" );
979
			$this->tempFileObj->autocollect();
980
		}
981
	}
982
983
	public function getTempPath() {
984
		return $this->mTempPath;
985
	}
986
987
	/**
988
	 * Split a file into a base name and all dot-delimited 'extensions'
989
	 * on the end. Some web server configurations will fall back to
990
	 * earlier pseudo-'extensions' to determine type and execute
991
	 * scripts, so the blacklist needs to check them all.
992
	 *
993
	 * @param string $filename
994
	 * @return array
995
	 */
996
	public static function splitExtensions( $filename ) {
997
		$bits = explode( '.', $filename );
998
		$basename = array_shift( $bits );
999
1000
		return [ $basename, $bits ];
1001
	}
1002
1003
	/**
1004
	 * Perform case-insensitive match against a list of file extensions.
1005
	 * Returns true if the extension is in the list.
1006
	 *
1007
	 * @param string $ext
1008
	 * @param array $list
1009
	 * @return bool
1010
	 */
1011
	public static function checkFileExtension( $ext, $list ) {
1012
		return in_array( strtolower( $ext ), $list );
1013
	}
1014
1015
	/**
1016
	 * Perform case-insensitive match against a list of file extensions.
1017
	 * Returns an array of matching extensions.
1018
	 *
1019
	 * @param array $ext
1020
	 * @param array $list
1021
	 * @return bool
1022
	 */
1023
	public static function checkFileExtensionList( $ext, $list ) {
1024
		return array_intersect( array_map( 'strtolower', $ext ), $list );
1025
	}
1026
1027
	/**
1028
	 * Checks if the MIME type of the uploaded file matches the file extension.
1029
	 *
1030
	 * @param string $mime The MIME type of the uploaded file
1031
	 * @param string $extension The filename extension that the file is to be served with
1032
	 * @return bool
1033
	 */
1034
	public static function verifyExtension( $mime, $extension ) {
1035
		$magic = MimeMagic::singleton();
1036
1037
		if ( !$mime || $mime == 'unknown' || $mime == 'unknown/unknown' ) {
1038
			if ( !$magic->isRecognizableExtension( $extension ) ) {
1039
				wfDebug( __METHOD__ . ": passing file with unknown detected mime type; " .
1040
					"unrecognized extension '$extension', can't verify\n" );
1041
1042
				return true;
1043
			} else {
1044
				wfDebug( __METHOD__ . ": rejecting file with unknown detected mime type; " .
1045
					"recognized extension '$extension', so probably invalid file\n" );
1046
1047
				return false;
1048
			}
1049
		}
1050
1051
		$match = $magic->isMatchingExtension( $extension, $mime );
1052
1053
		if ( $match === null ) {
1054
			if ( $magic->getTypesForExtension( $extension ) !== null ) {
1055
				wfDebug( __METHOD__ . ": No extension known for $mime, but we know a mime for $extension\n" );
1056
1057
				return false;
1058
			} else {
1059
				wfDebug( __METHOD__ . ": no file extension known for mime type $mime, passing file\n" );
1060
1061
				return true;
1062
			}
1063
		} elseif ( $match === true ) {
1064
			wfDebug( __METHOD__ . ": mime type $mime matches extension $extension, passing file\n" );
1065
1066
			/** @todo If it's a bitmap, make sure PHP or ImageMagick resp. can handle it! */
1067
			return true;
1068
		} else {
1069
			wfDebug( __METHOD__
1070
				. ": mime type $mime mismatches file extension $extension, rejecting file\n" );
1071
1072
			return false;
1073
		}
1074
	}
1075
1076
	/**
1077
	 * Heuristic for detecting files that *could* contain JavaScript instructions or
1078
	 * things that may look like HTML to a browser and are thus
1079
	 * potentially harmful. The present implementation will produce false
1080
	 * positives in some situations.
1081
	 *
1082
	 * @param string $file Pathname to the temporary upload file
1083
	 * @param string $mime The MIME type of the file
1084
	 * @param string $extension The extension of the file
1085
	 * @return bool True if the file contains something looking like embedded scripts
1086
	 */
1087
	public static function detectScript( $file, $mime, $extension ) {
1088
		global $wgAllowTitlesInSVG;
1089
1090
		# ugly hack: for text files, always look at the entire file.
1091
		# For binary field, just check the first K.
1092
1093
		if ( strpos( $mime, 'text/' ) === 0 ) {
1094
			$chunk = file_get_contents( $file );
1095
		} else {
1096
			$fp = fopen( $file, 'rb' );
1097
			$chunk = fread( $fp, 1024 );
1098
			fclose( $fp );
1099
		}
1100
1101
		$chunk = strtolower( $chunk );
1102
1103
		if ( !$chunk ) {
1104
			return false;
1105
		}
1106
1107
		# decode from UTF-16 if needed (could be used for obfuscation).
1108
		if ( substr( $chunk, 0, 2 ) == "\xfe\xff" ) {
1109
			$enc = 'UTF-16BE';
1110
		} elseif ( substr( $chunk, 0, 2 ) == "\xff\xfe" ) {
1111
			$enc = 'UTF-16LE';
1112
		} else {
1113
			$enc = null;
1114
		}
1115
1116
		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...
1117
			$chunk = iconv( $enc, "ASCII//IGNORE", $chunk );
1118
		}
1119
1120
		$chunk = trim( $chunk );
1121
1122
		/** @todo FIXME: Convert from UTF-16 if necessary! */
1123
		wfDebug( __METHOD__ . ": checking for embedded scripts and HTML stuff\n" );
1124
1125
		# check for HTML doctype
1126
		if ( preg_match( "/<!DOCTYPE *X?HTML/i", $chunk ) ) {
1127
			return true;
1128
		}
1129
1130
		// Some browsers will interpret obscure xml encodings as UTF-8, while
1131
		// PHP/expat will interpret the given encoding in the xml declaration (bug 47304)
1132
		if ( $extension == 'svg' || strpos( $mime, 'image/svg' ) === 0 ) {
1133
			if ( self::checkXMLEncodingMissmatch( $file ) ) {
1134
				return true;
1135
			}
1136
		}
1137
1138
		/**
1139
		 * Internet Explorer for Windows performs some really stupid file type
1140
		 * autodetection which can cause it to interpret valid image files as HTML
1141
		 * and potentially execute JavaScript, creating a cross-site scripting
1142
		 * attack vectors.
1143
		 *
1144
		 * Apple's Safari browser also performs some unsafe file type autodetection
1145
		 * which can cause legitimate files to be interpreted as HTML if the
1146
		 * web server is not correctly configured to send the right content-type
1147
		 * (or if you're really uploading plain text and octet streams!)
1148
		 *
1149
		 * Returns true if IE is likely to mistake the given file for HTML.
1150
		 * Also returns true if Safari would mistake the given file for HTML
1151
		 * when served with a generic content-type.
1152
		 */
1153
		$tags = [
1154
			'<a href',
1155
			'<body',
1156
			'<head',
1157
			'<html', # also in safari
1158
			'<img',
1159
			'<pre',
1160
			'<script', # also in safari
1161
			'<table'
1162
		];
1163
1164
		if ( !$wgAllowTitlesInSVG && $extension !== 'svg' && $mime !== 'image/svg' ) {
1165
			$tags[] = '<title';
1166
		}
1167
1168
		foreach ( $tags as $tag ) {
1169
			if ( false !== strpos( $chunk, $tag ) ) {
1170
				wfDebug( __METHOD__ . ": found something that may make it be mistaken for html: $tag\n" );
1171
1172
				return true;
1173
			}
1174
		}
1175
1176
		/*
1177
		 * look for JavaScript
1178
		 */
1179
1180
		# resolve entity-refs to look at attributes. may be harsh on big files... cache result?
1181
		$chunk = Sanitizer::decodeCharReferences( $chunk );
1182
1183
		# look for script-types
1184
		if ( preg_match( '!type\s*=\s*[\'"]?\s*(?:\w*/)?(?:ecma|java)!sim', $chunk ) ) {
1185
			wfDebug( __METHOD__ . ": found script types\n" );
1186
1187
			return true;
1188
		}
1189
1190
		# look for html-style script-urls
1191
		if ( preg_match( '!(?:href|src|data)\s*=\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) {
1192
			wfDebug( __METHOD__ . ": found html-style script urls\n" );
1193
1194
			return true;
1195
		}
1196
1197
		# look for css-style script-urls
1198
		if ( preg_match( '!url\s*\(\s*[\'"]?\s*(?:ecma|java)script:!sim', $chunk ) ) {
1199
			wfDebug( __METHOD__ . ": found css-style script urls\n" );
1200
1201
			return true;
1202
		}
1203
1204
		wfDebug( __METHOD__ . ": no scripts found\n" );
1205
1206
		return false;
1207
	}
1208
1209
	/**
1210
	 * Check a whitelist of xml encodings that are known not to be interpreted differently
1211
	 * by the server's xml parser (expat) and some common browsers.
1212
	 *
1213
	 * @param string $file Pathname to the temporary upload file
1214
	 * @return bool True if the file contains an encoding that could be misinterpreted
1215
	 */
1216
	public static function checkXMLEncodingMissmatch( $file ) {
1217
		global $wgSVGMetadataCutoff;
1218
		$contents = file_get_contents( $file, false, null, -1, $wgSVGMetadataCutoff );
1219
		$encodingRegex = '!encoding[ \t\n\r]*=[ \t\n\r]*[\'"](.*?)[\'"]!si';
1220
1221
		if ( preg_match( "!<\?xml\b(.*?)\?>!si", $contents, $matches ) ) {
1222 View Code Duplication
			if ( preg_match( $encodingRegex, $matches[1], $encMatch )
1223
				&& !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings )
1224
			) {
1225
				wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" );
1226
1227
				return true;
1228
			}
1229
		} elseif ( preg_match( "!<\?xml\b!si", $contents ) ) {
1230
			// Start of XML declaration without an end in the first $wgSVGMetadataCutoff
1231
			// bytes. There shouldn't be a legitimate reason for this to happen.
1232
			wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" );
1233
1234
			return true;
1235
		} elseif ( substr( $contents, 0, 4 ) == "\x4C\x6F\xA7\x94" ) {
1236
			// EBCDIC encoded XML
1237
			wfDebug( __METHOD__ . ": EBCDIC Encoded XML\n" );
1238
1239
			return true;
1240
		}
1241
1242
		// It's possible the file is encoded with multi-byte encoding, so re-encode attempt to
1243
		// detect the encoding in case is specifies an encoding not whitelisted in self::$safeXmlEncodings
1244
		$attemptEncodings = [ 'UTF-16', 'UTF-16BE', 'UTF-32', 'UTF-32BE' ];
1245
		foreach ( $attemptEncodings as $encoding ) {
1246
			MediaWiki\suppressWarnings();
1247
			$str = iconv( $encoding, 'UTF-8', $contents );
1248
			MediaWiki\restoreWarnings();
1249
			if ( $str != '' && preg_match( "!<\?xml\b(.*?)\?>!si", $str, $matches ) ) {
1250 View Code Duplication
				if ( preg_match( $encodingRegex, $matches[1], $encMatch )
1251
					&& !in_array( strtoupper( $encMatch[1] ), self::$safeXmlEncodings )
1252
				) {
1253
					wfDebug( __METHOD__ . ": Found unsafe XML encoding '{$encMatch[1]}'\n" );
1254
1255
					return true;
1256
				}
1257
			} elseif ( $str != '' && preg_match( "!<\?xml\b!si", $str ) ) {
1258
				// Start of XML declaration without an end in the first $wgSVGMetadataCutoff
1259
				// bytes. There shouldn't be a legitimate reason for this to happen.
1260
				wfDebug( __METHOD__ . ": Unmatched XML declaration start\n" );
1261
1262
				return true;
1263
			}
1264
		}
1265
1266
		return false;
1267
	}
1268
1269
	/**
1270
	 * @param string $filename
1271
	 * @param bool $partial
1272
	 * @return mixed False of the file is verified (does not contain scripts), array otherwise.
1273
	 */
1274
	protected function detectScriptInSvg( $filename, $partial ) {
1275
		$this->mSVGNSError = false;
1276
		$check = new XmlTypeCheck(
1277
			$filename,
1278
			[ $this, 'checkSvgScriptCallback' ],
1279
			true,
1280
			[ 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback' ]
1281
		);
1282
		if ( $check->wellFormed !== true ) {
1283
			// Invalid xml (bug 58553)
1284
			// But only when non-partial (bug 65724)
1285
			return $partial ? false : [ 'uploadinvalidxml' ];
1286
		} elseif ( $check->filterMatch ) {
1287
			if ( $this->mSVGNSError ) {
1288
				return [ 'uploadscriptednamespace', $this->mSVGNSError ];
1289
			}
1290
1291
			return $check->filterMatchType;
1292
		}
1293
1294
		return false;
1295
	}
1296
1297
	/**
1298
	 * Callback to filter SVG Processing Instructions.
1299
	 * @param string $target Processing instruction name
1300
	 * @param string $data Processing instruction attribute and value
1301
	 * @return bool (true if the filter identified something bad)
1302
	 */
1303
	public static function checkSvgPICallback( $target, $data ) {
1304
		// Don't allow external stylesheets (bug 57550)
1305
		if ( preg_match( '/xml-stylesheet/i', $target ) ) {
1306
			return [ 'upload-scripted-pi-callback' ];
1307
		}
1308
1309
		return false;
1310
	}
1311
1312
	/**
1313
	 * @todo Replace this with a whitelist filter!
1314
	 * @param string $element
1315
	 * @param array $attribs
1316
	 * @return bool
1317
	 */
1318
	public function checkSvgScriptCallback( $element, $attribs, $data = null ) {
1319
1320
		list( $namespace, $strippedElement ) = $this->splitXmlNamespace( $element );
1321
1322
		// We specifically don't include:
1323
		// http://www.w3.org/1999/xhtml (bug 60771)
1324
		static $validNamespaces = [
1325
			'',
1326
			'adobe:ns:meta/',
1327
			'http://creativecommons.org/ns#',
1328
			'http://inkscape.sourceforge.net/dtd/sodipodi-0.dtd',
1329
			'http://ns.adobe.com/adobeillustrator/10.0/',
1330
			'http://ns.adobe.com/adobesvgviewerextensions/3.0/',
1331
			'http://ns.adobe.com/extensibility/1.0/',
1332
			'http://ns.adobe.com/flows/1.0/',
1333
			'http://ns.adobe.com/illustrator/1.0/',
1334
			'http://ns.adobe.com/imagereplacement/1.0/',
1335
			'http://ns.adobe.com/pdf/1.3/',
1336
			'http://ns.adobe.com/photoshop/1.0/',
1337
			'http://ns.adobe.com/saveforweb/1.0/',
1338
			'http://ns.adobe.com/variables/1.0/',
1339
			'http://ns.adobe.com/xap/1.0/',
1340
			'http://ns.adobe.com/xap/1.0/g/',
1341
			'http://ns.adobe.com/xap/1.0/g/img/',
1342
			'http://ns.adobe.com/xap/1.0/mm/',
1343
			'http://ns.adobe.com/xap/1.0/rights/',
1344
			'http://ns.adobe.com/xap/1.0/stype/dimensions#',
1345
			'http://ns.adobe.com/xap/1.0/stype/font#',
1346
			'http://ns.adobe.com/xap/1.0/stype/manifestitem#',
1347
			'http://ns.adobe.com/xap/1.0/stype/resourceevent#',
1348
			'http://ns.adobe.com/xap/1.0/stype/resourceref#',
1349
			'http://ns.adobe.com/xap/1.0/t/pg/',
1350
			'http://purl.org/dc/elements/1.1/',
1351
			'http://purl.org/dc/elements/1.1',
1352
			'http://schemas.microsoft.com/visio/2003/svgextensions/',
1353
			'http://sodipodi.sourceforge.net/dtd/sodipodi-0.dtd',
1354
			'http://taptrix.com/inkpad/svg_extensions',
1355
			'http://web.resource.org/cc/',
1356
			'http://www.freesoftware.fsf.org/bkchem/cdml',
1357
			'http://www.inkscape.org/namespaces/inkscape',
1358
			'http://www.opengis.net/gml',
1359
			'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
1360
			'http://www.w3.org/2000/svg',
1361
			'http://www.w3.org/tr/rec-rdf-syntax/',
1362
		];
1363
1364
		if ( !in_array( $namespace, $validNamespaces ) ) {
1365
			wfDebug( __METHOD__ . ": Non-svg namespace '$namespace' in uploaded file.\n" );
1366
			/** @todo Return a status object to a closure in XmlTypeCheck, for MW1.21+ */
1367
			$this->mSVGNSError = $namespace;
1368
1369
			return true;
1370
		}
1371
1372
		/*
1373
		 * check for elements that can contain javascript
1374
		 */
1375 View Code Duplication
		if ( $strippedElement == 'script' ) {
1376
			wfDebug( __METHOD__ . ": Found script element '$element' in uploaded file.\n" );
1377
1378
			return [ 'uploaded-script-svg', $strippedElement ];
1379
		}
1380
1381
		# e.g., <svg xmlns="http://www.w3.org/2000/svg">
1382
		#  <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>
1383 View Code Duplication
		if ( $strippedElement == 'handler' ) {
1384
			wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" );
1385
1386
			return [ 'uploaded-script-svg', $strippedElement ];
1387
		}
1388
1389
		# SVG reported in Feb '12 that used xml:stylesheet to generate javascript block
1390 View Code Duplication
		if ( $strippedElement == 'stylesheet' ) {
1391
			wfDebug( __METHOD__ . ": Found scriptable element '$element' in uploaded file.\n" );
1392
1393
			return [ 'uploaded-script-svg', $strippedElement ];
1394
		}
1395
1396
		# Block iframes, in case they pass the namespace check
1397
		if ( $strippedElement == 'iframe' ) {
1398
			wfDebug( __METHOD__ . ": iframe in uploaded file.\n" );
1399
1400
			return [ 'uploaded-script-svg', $strippedElement ];
1401
		}
1402
1403
		# Check <style> css
1404
		if ( $strippedElement == 'style'
1405
			&& self::checkCssFragment( Sanitizer::normalizeCss( $data ) )
1406
		) {
1407
			wfDebug( __METHOD__ . ": hostile css in style element.\n" );
1408
			return [ 'uploaded-hostile-svg' ];
1409
		}
1410
1411
		foreach ( $attribs as $attrib => $value ) {
1412
			$stripped = $this->stripXmlNamespace( $attrib );
1413
			$value = strtolower( $value );
1414
1415
			if ( substr( $stripped, 0, 2 ) == 'on' ) {
1416
				wfDebug( __METHOD__
1417
					. ": Found event-handler attribute '$attrib'='$value' in uploaded file.\n" );
1418
1419
				return [ 'uploaded-event-handler-on-svg', $attrib, $value ];
1420
			}
1421
1422
			# href with non-local target (don't allow http://, javascript:, etc)
1423
			if ( $stripped == 'href'
1424
				&& strpos( $value, 'data:' ) !== 0
1425
				&& strpos( $value, '#' ) !== 0
1426
			) {
1427 View Code Duplication
				if ( !( $strippedElement === 'a'
1428
					&& preg_match( '!^https?://!im', $value ) )
1429
				) {
1430
					wfDebug( __METHOD__ . ": Found href attribute <$strippedElement "
1431
						. "'$attrib'='$value' in uploaded file.\n" );
1432
1433
					return [ 'uploaded-href-attribute-svg', $strippedElement, $attrib, $value ];
1434
				}
1435
			}
1436
1437
			# only allow data: targets that should be safe. This prevents vectors like,
1438
			# image/svg, text/xml, application/xml, and text/html, which can contain scripts
1439
			if ( $stripped == 'href' && strncasecmp( 'data:', $value, 5 ) === 0 ) {
1440
				// rfc2397 parameters. This is only slightly slower than (;[\w;]+)*.
1441
				// @codingStandardsIgnoreStart Generic.Files.LineLength
1442
				$parameters = '(?>;[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+=(?>[a-zA-Z0-9\!#$&\'*+.^_`{|}~-]+|"(?>[\0-\x0c\x0e-\x21\x23-\x5b\x5d-\x7f]+|\\\\[\0-\x7f])*"))*(?:;base64)?';
1443
				// @codingStandardsIgnoreEnd
1444
1445 View Code Duplication
				if ( !preg_match( "!^data:\s*image/(gif|jpeg|jpg|png)$parameters,!i", $value ) ) {
1446
					wfDebug( __METHOD__ . ": Found href to unwhitelisted data: uri "
1447
						. "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" );
1448
					return [ 'uploaded-href-unsafe-target-svg', $strippedElement, $attrib, $value ];
1449
				}
1450
			}
1451
1452
			# Change href with animate from (http://html5sec.org/#137).
1453
			if ( $stripped === 'attributename'
1454
				&& $strippedElement === 'animate'
1455
				&& $this->stripXmlNamespace( $value ) == 'href'
1456
			) {
1457
				wfDebug( __METHOD__ . ": Found animate that might be changing href using from "
1458
					. "\"<$strippedElement '$attrib'='$value'...\" in uploaded file.\n" );
1459
1460
				return [ 'uploaded-animate-svg', $strippedElement, $attrib, $value ];
1461
			}
1462
1463
			# use set/animate to add event-handler attribute to parent
1464
			if ( ( $strippedElement == 'set' || $strippedElement == 'animate' )
1465
				&& $stripped == 'attributename'
1466
				&& substr( $value, 0, 2 ) == 'on'
1467
			) {
1468
				wfDebug( __METHOD__ . ": Found svg setting event-handler attribute with "
1469
					. "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" );
1470
1471
				return [ 'uploaded-setting-event-handler-svg', $strippedElement, $stripped, $value ];
1472
			}
1473
1474
			# use set to add href attribute to parent element
1475
			if ( $strippedElement == 'set'
1476
				&& $stripped == 'attributename'
1477
				&& strpos( $value, 'href' ) !== false
1478
			) {
1479
				wfDebug( __METHOD__ . ": Found svg setting href attribute '$value' in uploaded file.\n" );
1480
1481
				return [ 'uploaded-setting-href-svg' ];
1482
			}
1483
1484
			# use set to add a remote / data / script target to an element
1485
			if ( $strippedElement == 'set'
1486
				&& $stripped == 'to'
1487
				&& preg_match( '!(http|https|data|script):!sim', $value )
1488
			) {
1489
				wfDebug( __METHOD__ . ": Found svg setting attribute to '$value' in uploaded file.\n" );
1490
1491
				return [ 'uploaded-wrong-setting-svg', $value ];
1492
			}
1493
1494
			# use handler attribute with remote / data / script
1495
			if ( $stripped == 'handler' && preg_match( '!(http|https|data|script):!sim', $value ) ) {
1496
				wfDebug( __METHOD__ . ": Found svg setting handler with remote/data/script "
1497
					. "'$attrib'='$value' in uploaded file.\n" );
1498
1499
				return [ 'uploaded-setting-handler-svg', $attrib, $value ];
1500
			}
1501
1502
			# use CSS styles to bring in remote code
1503
			if ( $stripped == 'style'
1504
				&& self::checkCssFragment( Sanitizer::normalizeCss( $value ) )
1505
			) {
1506
				wfDebug( __METHOD__ . ": Found svg setting a style with "
1507
					. "remote url '$attrib'='$value' in uploaded file.\n" );
1508
				return [ 'uploaded-remote-url-svg', $attrib, $value ];
1509
			}
1510
1511
			# Several attributes can include css, css character escaping isn't allowed
1512
			$cssAttrs = [ 'font', 'clip-path', 'fill', 'filter', 'marker',
1513
				'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke' ];
1514
			if ( in_array( $stripped, $cssAttrs )
1515
				&& self::checkCssFragment( $value )
1516
			) {
1517
				wfDebug( __METHOD__ . ": Found svg setting a style with "
1518
					. "remote url '$attrib'='$value' in uploaded file.\n" );
1519
				return [ 'uploaded-remote-url-svg', $attrib, $value ];
1520
			}
1521
1522
			# image filters can pull in url, which could be svg that executes scripts
1523
			if ( $strippedElement == 'image'
1524
				&& $stripped == 'filter'
1525
				&& preg_match( '!url\s*\(!sim', $value )
1526
			) {
1527
				wfDebug( __METHOD__ . ": Found image filter with url: "
1528
					. "\"<$strippedElement $stripped='$value'...\" in uploaded file.\n" );
1529
1530
				return [ 'uploaded-image-filter-svg', $strippedElement, $stripped, $value ];
1531
			}
1532
		}
1533
1534
		return false; // No scripts detected
1535
	}
1536
1537
	/**
1538
	 * Check a block of CSS or CSS fragment for anything that looks like
1539
	 * it is bringing in remote code.
1540
	 * @param string $value a string of CSS
1541
	 * @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...
1542
	 * @return bool true if the CSS contains an illegal string, false if otherwise
1543
	 */
1544
	private static function checkCssFragment( $value ) {
1545
1546
		# Forbid external stylesheets, for both reliability and to protect viewer's privacy
1547
		if ( stripos( $value, '@import' ) !== false ) {
1548
			return true;
1549
		}
1550
1551
		# We allow @font-face to embed fonts with data: urls, so we snip the string
1552
		# 'url' out so this case won't match when we check for urls below
1553
		$pattern = '!(@font-face\s*{[^}]*src:)url(\("data:;base64,)!im';
1554
		$value = preg_replace( $pattern, '$1$2', $value );
1555
1556
		# Check for remote and executable CSS. Unlike in Sanitizer::checkCss, the CSS
1557
		# properties filter and accelerator don't seem to be useful for xss in SVG files.
1558
		# Expression and -o-link don't seem to work either, but filtering them here in case.
1559
		# Additionally, we catch remote urls like url("http:..., url('http:..., url(http:...,
1560
		# but not local ones such as url("#..., url('#..., url(#....
1561
		if ( preg_match( '!expression
1562
				| -o-link\s*:
1563
				| -o-link-source\s*:
1564
				| -o-replace\s*:!imx', $value ) ) {
1565
			return true;
1566
		}
1567
1568
		if ( preg_match_all(
1569
				"!(\s*(url|image|image-set)\s*\(\s*[\"']?\s*[^#]+.*?\))!sim",
1570
				$value,
1571
				$matches
1572
			) !== 0
1573
		) {
1574
			# TODO: redo this in one regex. Until then, url("#whatever") matches the first
1575
			foreach ( $matches[1] as $match ) {
1576
				if ( !preg_match( "!\s*(url|image|image-set)\s*\(\s*(#|'#|\"#)!im", $match ) ) {
1577
					return true;
1578
				}
1579
			}
1580
		}
1581
1582
		if ( preg_match( '/[\000-\010\013\016-\037\177]/', $value ) ) {
1583
			return true;
1584
		}
1585
1586
		return false;
1587
	}
1588
1589
	/**
1590
	 * Divide the element name passed by the xml parser to the callback into URI and prifix.
1591
	 * @param string $element
1592
	 * @return array Containing the namespace URI and prefix
1593
	 */
1594
	private static function splitXmlNamespace( $element ) {
1595
		// 'http://www.w3.org/2000/svg:script' -> array( 'http://www.w3.org/2000/svg', 'script' )
1596
		$parts = explode( ':', strtolower( $element ) );
1597
		$name = array_pop( $parts );
1598
		$ns = implode( ':', $parts );
1599
1600
		return [ $ns, $name ];
1601
	}
1602
1603
	/**
1604
	 * @param string $name
1605
	 * @return string
1606
	 */
1607
	private function stripXmlNamespace( $name ) {
1608
		// 'http://www.w3.org/2000/svg:script' -> 'script'
1609
		$parts = explode( ':', strtolower( $name ) );
1610
1611
		return array_pop( $parts );
1612
	}
1613
1614
	/**
1615
	 * Generic wrapper function for a virus scanner program.
1616
	 * This relies on the $wgAntivirus and $wgAntivirusSetup variables.
1617
	 * $wgAntivirusRequired may be used to deny upload if the scan fails.
1618
	 *
1619
	 * @param string $file Pathname to the temporary upload file
1620
	 * @return mixed False if not virus is found, null if the scan fails or is disabled,
1621
	 *   or a string containing feedback from the virus scanner if a virus was found.
1622
	 *   If textual feedback is missing but a virus was found, this function returns true.
1623
	 */
1624
	public static function detectVirus( $file ) {
1625
		global $wgAntivirus, $wgAntivirusSetup, $wgAntivirusRequired, $wgOut;
1626
1627
		if ( !$wgAntivirus ) {
1628
			wfDebug( __METHOD__ . ": virus scanner disabled\n" );
1629
1630
			return null;
1631
		}
1632
1633
		if ( !$wgAntivirusSetup[$wgAntivirus] ) {
1634
			wfDebug( __METHOD__ . ": unknown virus scanner: $wgAntivirus\n" );
1635
			$wgOut->wrapWikiMsg( "<div class=\"error\">\n$1\n</div>",
1636
				[ 'virus-badscanner', $wgAntivirus ] );
1637
1638
			return wfMessage( 'virus-unknownscanner' )->text() . " $wgAntivirus";
1639
		}
1640
1641
		# look up scanner configuration
1642
		$command = $wgAntivirusSetup[$wgAntivirus]['command'];
1643
		$exitCodeMap = $wgAntivirusSetup[$wgAntivirus]['codemap'];
1644
		$msgPattern = isset( $wgAntivirusSetup[$wgAntivirus]['messagepattern'] ) ?
1645
			$wgAntivirusSetup[$wgAntivirus]['messagepattern'] : null;
1646
1647
		if ( strpos( $command, "%f" ) === false ) {
1648
			# simple pattern: append file to scan
1649
			$command .= " " . wfEscapeShellArg( $file );
1650
		} else {
1651
			# complex pattern: replace "%f" with file to scan
1652
			$command = str_replace( "%f", wfEscapeShellArg( $file ), $command );
1653
		}
1654
1655
		wfDebug( __METHOD__ . ": running virus scan: $command \n" );
1656
1657
		# execute virus scanner
1658
		$exitCode = false;
1659
1660
		# NOTE: there's a 50 line workaround to make stderr redirection work on windows, too.
1661
		#      that does not seem to be worth the pain.
1662
		#      Ask me (Duesentrieb) about it if it's ever needed.
1663
		$output = wfShellExecWithStderr( $command, $exitCode );
1664
1665
		# map exit code to AV_xxx constants.
1666
		$mappedCode = $exitCode;
1667
		if ( $exitCodeMap ) {
1668
			if ( isset( $exitCodeMap[$exitCode] ) ) {
1669
				$mappedCode = $exitCodeMap[$exitCode];
1670
			} elseif ( isset( $exitCodeMap["*"] ) ) {
1671
				$mappedCode = $exitCodeMap["*"];
1672
			}
1673
		}
1674
1675
		/* NB: AV_NO_VIRUS is 0 but AV_SCAN_FAILED is false,
1676
		 * so we need the strict equalities === and thus can't use a switch here
1677
		 */
1678
		if ( $mappedCode === AV_SCAN_FAILED ) {
1679
			# scan failed (code was mapped to false by $exitCodeMap)
1680
			wfDebug( __METHOD__ . ": failed to scan $file (code $exitCode).\n" );
1681
1682
			$output = $wgAntivirusRequired
1683
				? wfMessage( 'virus-scanfailed', [ $exitCode ] )->text()
1684
				: null;
1685
		} elseif ( $mappedCode === AV_SCAN_ABORTED ) {
1686
			# scan failed because filetype is unknown (probably imune)
1687
			wfDebug( __METHOD__ . ": unsupported file type $file (code $exitCode).\n" );
1688
			$output = null;
1689
		} elseif ( $mappedCode === AV_NO_VIRUS ) {
1690
			# no virus found
1691
			wfDebug( __METHOD__ . ": file passed virus scan.\n" );
1692
			$output = false;
1693
		} else {
1694
			$output = trim( $output );
1695
1696
			if ( !$output ) {
1697
				$output = true; # if there's no output, return true
1698
			} elseif ( $msgPattern ) {
1699
				$groups = [];
1700
				if ( preg_match( $msgPattern, $output, $groups ) ) {
1701
					if ( $groups[1] ) {
1702
						$output = $groups[1];
1703
					}
1704
				}
1705
			}
1706
1707
			wfDebug( __METHOD__ . ": FOUND VIRUS! scanner feedback: $output \n" );
1708
		}
1709
1710
		return $output;
1711
	}
1712
1713
	/**
1714
	 * Check if there's an overwrite conflict and, if so, if restrictions
1715
	 * forbid this user from performing the upload.
1716
	 *
1717
	 * @param User $user
1718
	 *
1719
	 * @return mixed True on success, array on failure
1720
	 */
1721
	private function checkOverwrite( $user ) {
1722
		// First check whether the local file can be overwritten
1723
		$file = $this->getLocalFile();
1724
		$file->load( File::READ_LATEST );
1725
		if ( $file->exists() ) {
1726
			if ( !self::userCanReUpload( $user, $file ) ) {
0 ignored issues
show
Bug introduced by
It seems like $file defined by $this->getLocalFile() on line 1723 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...
1727
				return [ 'fileexists-forbidden', $file->getName() ];
1728
			} else {
1729
				return true;
1730
			}
1731
		}
1732
1733
		/* Check shared conflicts: if the local file does not exist, but
1734
		 * wfFindFile finds a file, it exists in a shared repository.
1735
		 */
1736
		$file = wfFindFile( $this->getTitle(), [ 'latest' => true ] );
1737
		if ( $file && !$user->isAllowed( 'reupload-shared' ) ) {
1738
			return [ 'fileexists-shared-forbidden', $file->getName() ];
1739
		}
1740
1741
		return true;
1742
	}
1743
1744
	/**
1745
	 * Check if a user is the last uploader
1746
	 *
1747
	 * @param User $user
1748
	 * @param File $img
1749
	 * @return bool
1750
	 */
1751
	public static function userCanReUpload( User $user, File $img ) {
1752
		if ( $user->isAllowed( 'reupload' ) ) {
1753
			return true; // non-conditional
1754
		} elseif ( !$user->isAllowed( 'reupload-own' ) ) {
1755
			return false;
1756
		}
1757
1758
		if ( !( $img instanceof LocalFile ) ) {
1759
			return false;
1760
		}
1761
1762
		$img->load();
1763
1764
		return $user->getId() == $img->getUser( 'id' );
1765
	}
1766
1767
	/**
1768
	 * Helper function that does various existence checks for a file.
1769
	 * The following checks are performed:
1770
	 * - The file exists
1771
	 * - Article with the same name as the file exists
1772
	 * - File exists with normalized extension
1773
	 * - The file looks like a thumbnail and the original exists
1774
	 *
1775
	 * @param File $file The File object to check
1776
	 * @return mixed False if the file does not exists, else an array
1777
	 */
1778
	public static function getExistsWarning( $file ) {
1779
		if ( $file->exists() ) {
1780
			return [ 'warning' => 'exists', 'file' => $file ];
1781
		}
1782
1783
		if ( $file->getTitle()->getArticleID() ) {
1784
			return [ 'warning' => 'page-exists', 'file' => $file ];
1785
		}
1786
1787
		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...
1788
			$partname = $file->getName();
1789
			$extension = '';
1790
		} else {
1791
			$n = strrpos( $file->getName(), '.' );
1792
			$extension = substr( $file->getName(), $n + 1 );
1793
			$partname = substr( $file->getName(), 0, $n );
1794
		}
1795
		$normalizedExtension = File::normalizeExtension( $extension );
1796
1797
		if ( $normalizedExtension != $extension ) {
1798
			// We're not using the normalized form of the extension.
1799
			// Normal form is lowercase, using most common of alternate
1800
			// extensions (eg 'jpg' rather than 'JPEG').
1801
1802
			// Check for another file using the normalized form...
1803
			$nt_lc = Title::makeTitle( NS_FILE, "{$partname}.{$normalizedExtension}" );
1804
			$file_lc = wfLocalFile( $nt_lc );
1805
1806
			if ( $file_lc->exists() ) {
1807
				return [
1808
					'warning' => 'exists-normalized',
1809
					'file' => $file,
1810
					'normalizedFile' => $file_lc
1811
				];
1812
			}
1813
		}
1814
1815
		// Check for files with the same name but a different extension
1816
		$similarFiles = RepoGroup::singleton()->getLocalRepo()->findFilesByPrefix(
1817
			"{$partname}.", 1 );
1818
		if ( count( $similarFiles ) ) {
1819
			return [
1820
				'warning' => 'exists-normalized',
1821
				'file' => $file,
1822
				'normalizedFile' => $similarFiles[0],
1823
			];
1824
		}
1825
1826
		if ( self::isThumbName( $file->getName() ) ) {
1827
			# Check for filenames like 50px- or 180px-, these are mostly thumbnails
1828
			$nt_thb = Title::newFromText(
1829
				substr( $partname, strpos( $partname, '-' ) + 1 ) . '.' . $extension,
1830
				NS_FILE
1831
			);
1832
			$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 1828 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...
1833
			if ( $file_thb->exists() ) {
1834
				return [
1835
					'warning' => 'thumb',
1836
					'file' => $file,
1837
					'thumbFile' => $file_thb
1838
				];
1839
			} else {
1840
				// File does not exist, but we just don't like the name
1841
				return [
1842
					'warning' => 'thumb-name',
1843
					'file' => $file,
1844
					'thumbFile' => $file_thb
1845
				];
1846
			}
1847
		}
1848
1849
		foreach ( self::getFilenamePrefixBlacklist() as $prefix ) {
1850
			if ( substr( $partname, 0, strlen( $prefix ) ) == $prefix ) {
1851
				return [
1852
					'warning' => 'bad-prefix',
1853
					'file' => $file,
1854
					'prefix' => $prefix
1855
				];
1856
			}
1857
		}
1858
1859
		return false;
1860
	}
1861
1862
	/**
1863
	 * Helper function that checks whether the filename looks like a thumbnail
1864
	 * @param string $filename
1865
	 * @return bool
1866
	 */
1867
	public static function isThumbName( $filename ) {
1868
		$n = strrpos( $filename, '.' );
1869
		$partname = $n ? substr( $filename, 0, $n ) : $filename;
1870
1871
		return (
1872
			substr( $partname, 3, 3 ) == 'px-' ||
1873
			substr( $partname, 2, 3 ) == 'px-'
1874
		) &&
1875
		preg_match( "/[0-9]{2}/", substr( $partname, 0, 2 ) );
1876
	}
1877
1878
	/**
1879
	 * Get a list of blacklisted filename prefixes from [[MediaWiki:Filename-prefix-blacklist]]
1880
	 *
1881
	 * @return array List of prefixes
1882
	 */
1883
	public static function getFilenamePrefixBlacklist() {
1884
		$blacklist = [];
1885
		$message = wfMessage( 'filename-prefix-blacklist' )->inContentLanguage();
1886
		if ( !$message->isDisabled() ) {
1887
			$lines = explode( "\n", $message->plain() );
1888
			foreach ( $lines as $line ) {
1889
				// Remove comment lines
1890
				$comment = substr( trim( $line ), 0, 1 );
1891
				if ( $comment == '#' || $comment == '' ) {
1892
					continue;
1893
				}
1894
				// Remove additional comments after a prefix
1895
				$comment = strpos( $line, '#' );
1896
				if ( $comment > 0 ) {
1897
					$line = substr( $line, 0, $comment - 1 );
1898
				}
1899
				$blacklist[] = trim( $line );
1900
			}
1901
		}
1902
1903
		return $blacklist;
1904
	}
1905
1906
	/**
1907
	 * Gets image info about the file just uploaded.
1908
	 *
1909
	 * Also has the effect of setting metadata to be an 'indexed tag name' in
1910
	 * returned API result if 'metadata' was requested. Oddly, we have to pass
1911
	 * the "result" object down just so it can do that with the appropriate
1912
	 * format, presumably.
1913
	 *
1914
	 * @param ApiResult $result
1915
	 * @return array Image info
1916
	 */
1917
	public function getImageInfo( $result ) {
1918
		$file = $this->getLocalFile();
1919
		/** @todo This cries out for refactoring.
1920
		 *  We really want to say $file->getAllInfo(); here.
1921
		 * Perhaps "info" methods should be moved into files, and the API should
1922
		 * just wrap them in queries.
1923
		 */
1924
		if ( $file instanceof UploadStashFile ) {
1925
			$imParam = ApiQueryStashImageInfo::getPropertyNames();
1926
			$info = ApiQueryStashImageInfo::getInfo( $file, array_flip( $imParam ), $result );
1927
		} else {
1928
			$imParam = ApiQueryImageInfo::getPropertyNames();
1929
			$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 1918 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...
1930
		}
1931
1932
		return $info;
1933
	}
1934
1935
	/**
1936
	 * @param array $error
1937
	 * @return Status
1938
	 */
1939
	public function convertVerifyErrorToStatus( $error ) {
1940
		$code = $error['status'];
1941
		unset( $code['status'] );
1942
1943
		return Status::newFatal( $this->getVerificationErrorCode( $code ), $error );
1944
	}
1945
1946
	/**
1947
	 * Get the MediaWiki maximum uploaded file size for given type of upload, based on
1948
	 * $wgMaxUploadSize.
1949
	 *
1950
	 * @param null|string $forType
1951
	 * @return int
1952
	 */
1953
	public static function getMaxUploadSize( $forType = null ) {
1954
		global $wgMaxUploadSize;
1955
1956
		if ( is_array( $wgMaxUploadSize ) ) {
1957
			if ( !is_null( $forType ) && isset( $wgMaxUploadSize[$forType] ) ) {
1958
				return $wgMaxUploadSize[$forType];
1959
			} else {
1960
				return $wgMaxUploadSize['*'];
1961
			}
1962
		} else {
1963
			return intval( $wgMaxUploadSize );
1964
		}
1965
	}
1966
1967
	/**
1968
	 * Get the PHP maximum uploaded file size, based on ini settings. If there is no limit or the
1969
	 * limit can't be guessed, returns a very large number (PHP_INT_MAX).
1970
	 *
1971
	 * @since 1.27
1972
	 * @return int
1973
	 */
1974
	public static function getMaxPhpUploadSize() {
1975
		$phpMaxFileSize = wfShorthandToInteger(
1976
			ini_get( 'upload_max_filesize' ) ?: ini_get( 'hhvm.server.upload.upload_max_file_size' ),
1977
			PHP_INT_MAX
1978
		);
1979
		$phpMaxPostSize = wfShorthandToInteger(
1980
			ini_get( 'post_max_size' ) ?: ini_get( 'hhvm.server.max_post_size' ),
1981
			PHP_INT_MAX
1982
		) ?: PHP_INT_MAX;
1983
		return min( $phpMaxFileSize, $phpMaxPostSize );
1984
	}
1985
1986
	/**
1987
	 * Get the current status of a chunked upload (used for polling)
1988
	 *
1989
	 * The value will be read from cache.
1990
	 *
1991
	 * @param User $user
1992
	 * @param string $statusKey
1993
	 * @return Status[]|bool
1994
	 */
1995
	public static function getSessionStatus( User $user, $statusKey ) {
1996
		$key = wfMemcKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey );
1997
1998
		return ObjectCache::getMainStashInstance()->get( $key );
1999
	}
2000
2001
	/**
2002
	 * Set the current status of a chunked upload (used for polling)
2003
	 *
2004
	 * The value will be set in cache for 1 day
2005
	 *
2006
	 * @param User $user
2007
	 * @param string $statusKey
2008
	 * @param array|bool $value
2009
	 * @return void
2010
	 */
2011
	public static function setSessionStatus( User $user, $statusKey, $value ) {
2012
		$key = wfMemcKey( 'uploadstatus', $user->getId() ?: md5( $user->getName() ), $statusKey );
2013
2014
		$cache = ObjectCache::getMainStashInstance();
2015
		if ( $value === false ) {
2016
			$cache->delete( $key );
2017
		} else {
2018
			$cache->set( $key, $value, $cache::TTL_DAY );
2019
		}
2020
	}
2021
}
2022