From 6c7e32c67a135d33593c8412e103fae70dffc905 Mon Sep 17 00:00:00 2001
From: Chad Horohoe <chadh@wikimedia.org>
Date: Thu, 15 Oct 2015 13:37:01 -0700
Subject: [PATCH] SECURITY: API: Improve validation in chunked uploading

This fixes a few shortcomings in the chunked uploader:
* Raises an error if offset + chunksize > filesize.
* Enforces a minimum chunk size for non-final chunks.
* Refuses additional chunks after seeing a final chunk.
* Status of a chunked upload in progress is now available with
  'checkstatus'.

Bug: T91203
Bug: T91205
Change-Id: If33cc99f304aab2486507c7500b4abb06b6b5d70
---
 includes/DefaultSettings.php      |  8 ++++
 includes/GlobalFunctions.php      |  5 ++-
 includes/Setup.php                |  9 +++++
 includes/api/ApiQuerySiteinfo.php |  1 +
 includes/api/ApiUpload.php        | 78 ++++++++++++++++++++++++++++++++-------
 5 files changed, 85 insertions(+), 16 deletions(-)

diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php
index 5f2e3da..662ffb5 100644
--- a/includes/DefaultSettings.php
+++ b/includes/DefaultSettings.php
@@ -666,6 +666,14 @@ $wgCopyUploadAsyncTimeout = false;
 $wgMaxUploadSize = 1024 * 1024 * 100; # 100MB
 
 /**
+ * Minimum upload chunk size, in bytes. When using chunked upload, non-final
+ * chunks smaller than this will be rejected. May be reduced based on the
+ * 'upload_max_filesize' or 'post_max_size' PHP settings.
+ * @since 1.26
+ */
+$wgMinUploadChunkSize = 1024; # 1KB
+
+/**
  * Point the upload navigation link to an external URL
  * Useful if you want to use a shared repository by default
  * without disabling local uploads (use $wgEnableUploads = false for that).
diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php
index 27f7cac..90cf9c1 100644
--- a/includes/GlobalFunctions.php
+++ b/includes/GlobalFunctions.php
@@ -3877,12 +3877,13 @@ function wfMemoryLimit() {
  * Converts shorthand byte notation to integer form
  *
  * @param string $string
+ * @param int $default Returned if $string is empty
  * @return int
  */
-function wfShorthandToInteger( $string = '' ) {
+function wfShorthandToInteger( $string = '', $default = -1 ) {
 	$string = trim( $string );
 	if ( $string === '' ) {
-		return -1;
+		return $default;
 	}
 	$last = $string[strlen( $string ) - 1];
 	$val = intval( $string );
diff --git a/includes/Setup.php b/includes/Setup.php
index c5382d7..543b6a8 100644
--- a/includes/Setup.php
+++ b/includes/Setup.php
@@ -350,6 +350,15 @@ if ( $wgResourceLoaderMaxQueryLength === false ) {
 	$wgResourceLoaderMaxQueryLength = $maxValueLength > 0 ? $maxValueLength : -1;
 }
 
+// Ensure the minimum chunk size is less than PHP upload limits or the maximum
+// upload size.
+$wgMinUploadChunkSize = min(
+	$wgMinUploadChunkSize,
+	$wgMaxUploadSize,
+	wfShorthandToInteger( ini_get( 'upload_max_filesize' ), 1e100 ),
+	wfShorthandToInteger( ini_get( 'post_max_size' ), 1e100) - 1024 # Leave room for other parameters
+);
+
 /**
  * Definitions of the NS_ constants are in Defines.php
  * @private
diff --git a/includes/api/ApiQuerySiteinfo.php b/includes/api/ApiQuerySiteinfo.php
index 311438f..5cc0a5d 100644
--- a/includes/api/ApiQuerySiteinfo.php
+++ b/includes/api/ApiQuerySiteinfo.php
@@ -256,6 +256,7 @@ class ApiQuerySiteinfo extends ApiQueryBase {
 		}
 
 		$data['maxuploadsize'] = UploadBase::getMaxUploadSize();
+		$data['minuploadchunksize'] = (int)$this->getConfig()->get( 'MinUploadChunkSize' );
 
 		$data['thumblimits'] = $config->get( 'ThumbLimits' );
 		$this->getResult()->setIndexedTagName( $data['thumblimits'], 'limit' );
diff --git a/includes/api/ApiUpload.php b/includes/api/ApiUpload.php
index ff93cfc..704efd4 100644
--- a/includes/api/ApiUpload.php
+++ b/includes/api/ApiUpload.php
@@ -81,7 +81,7 @@ class ApiUpload extends ApiBase {
 
 		// Check if the uploaded file is sane
 		if ( $this->mParams['chunk'] ) {
-			$maxSize = $this->mUpload->getMaxUploadSize();
+			$maxSize = UploadBase::getMaxUploadSize();
 			if ( $this->mParams['filesize'] > $maxSize ) {
 				$this->dieUsage( 'The file you submitted was too large', 'file-too-large' );
 			}
@@ -201,13 +201,30 @@ class ApiUpload extends ApiBase {
 	private function getChunkResult( $warnings ) {
 		$result = array();
 
-		$result['result'] = 'Continue';
 		if ( $warnings && count( $warnings ) > 0 ) {
 			$result['warnings'] = $warnings;
 		}
+
 		$request = $this->getMain()->getRequest();
 		$chunkPath = $request->getFileTempname( 'chunk' );
 		$chunkSize = $request->getUpload( 'chunk' )->getSize();
+		$totalSoFar = $this->mParams['offset'] + $chunkSize;
+		$minChunkSize = $this->getConfig()->get( 'MinUploadChunkSize' );
+
+		// Sanity check sizing
+		if ( $totalSoFar > $this->mParams['filesize'] ) {
+			$this->dieUsage(
+				'Offset plus current chunk is greater than claimed file size', 'invalid-chunk'
+			);
+		}
+
+		// Enforce minimum chunk size
+		if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
+			$this->dieUsage(
+				"Minimum chunk size is $minChunkSize bytes for non-final chunks", 'chunk-too-small'
+			);
+		}
+
 		if ( $this->mParams['offset'] == 0 ) {
 			try {
 				$filekey = $this->performStash();
@@ -217,22 +234,28 @@ class ApiUpload extends ApiBase {
 			}
 		} else {
 			$filekey = $this->mParams['filekey'];
+
+			// Don't allow further uploads to an already-completed session
+			$progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
+			if ( !$progress ) {
+				// Probably can't get here, but check anyway just in case
+				$this->dieUsage( 'No chunked upload session with this key', 'stashfailed' );
+			} elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
+				$this->dieUsage(
+					'Chunked upload is already completed, check status for details', 'stashfailed'
+				);
+			}
+
 			$status = $this->mUpload->addChunk(
 				$chunkPath, $chunkSize, $this->mParams['offset'] );
 			if ( !$status->isGood() ) {
 				$this->dieUsage( $status->getWikiText(), 'stashfailed' );
-
-				return array();
 			}
 		}
 
 		// Check we added the last chunk:
-		if ( $this->mParams['offset'] + $chunkSize == $this->mParams['filesize'] ) {
+		if ( $totalSoFar == $this->mParams['filesize'] ) {
 			if ( $this->mParams['async'] ) {
-				$progress = UploadBase::getSessionStatus( $filekey );
-				if ( $progress && $progress['result'] === 'Poll' ) {
-					$this->dieUsage( "Chunk assembly already in progress.", 'stashfailed' );
-				}
 				UploadBase::setSessionStatus(
 					$filekey,
 					array( 'result' => 'Poll',
@@ -251,21 +274,37 @@ class ApiUpload extends ApiBase {
 			} else {
 				$status = $this->mUpload->concatenateChunks();
 				if ( !$status->isGood() ) {
+					UploadBase::setSessionStatus(
+						$this->getUser(),
+						$filekey,
+						array( 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status )
+					);
 					$this->dieUsage( $status->getWikiText(), 'stashfailed' );
-
-					return array();
 				}
 
 				// The fully concatenated file has a new filekey. So remove
 				// the old filekey and fetch the new one.
+				UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
 				$this->mUpload->stash->removeFile( $filekey );
 				$filekey = $this->mUpload->getLocalFile()->getFileKey();
 
 				$result['result'] = 'Success';
 			}
+		} else {
+			UploadBase::setSessionStatus(
+				$this->getUser(),
+				$filekey,
+				array(
+					'result' => 'Continue',
+					'stage' => 'uploading',
+					'offset' => $totalSoFar,
+					'status' => Status::newGood(),
+				)
+			);
+			$result['result'] = 'Continue';
+			$result['offset'] = $totalSoFar;
 		}
 		$result['filekey'] = $filekey;
-		$result['offset'] = $this->mParams['offset'] + $chunkSize;
 
 		return $result;
 	}
@@ -374,6 +413,10 @@ class ApiUpload extends ApiBase {
 			// Chunk upload
 			$this->mUpload = new UploadFromChunks();
 			if ( isset( $this->mParams['filekey'] ) ) {
+				if ( $this->mParams['offset'] === 0 ) {
+					$this->dieUsage( 'Cannot supply a filekey when offset is 0', 'badparams' );
+				}
+
 				// handle new chunk
 				$this->mUpload->continueChunks(
 					$this->mParams['filename'],
@@ -720,8 +763,15 @@ class ApiUpload extends ApiBase {
 			),
 			'stash' => false,
 
-			'filesize' => null,
-			'offset' => null,
+			'filesize' => array(
+				ApiBase::PARAM_TYPE => 'integer',
+				ApiBase::PARAM_MIN => 0,
+				ApiBase::PARAM_MAX => UploadBase::getMaxUploadSize(),
+			),
+			'offset' => array(
+				ApiBase::PARAM_TYPE => 'integer',
+				ApiBase::PARAM_MIN => 0,
+			),
 			'chunk' => array(
 				ApiBase::PARAM_TYPE => 'upload',
 			),
-- 
2.6.1

