From 59b627b0b7ad87317601f676a1ebd2441f63f59d Mon Sep 17 00:00:00 2001 From: csteipp Date: Tue, 8 Sep 2015 10:59:44 -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: I2262db1bc8460616b069c564475d2e4148001768 --- includes/DefaultSettings.php | 8 +++ includes/GlobalFunctions.php | 5 +- includes/Setup.php | 9 ++++ includes/api/ApiQuerySiteinfo.php | 1 + includes/api/ApiUpload.php | 83 +++++++++++++++++++++++++------ 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index c491b156b3..c21301f1b9 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -720,6 +720,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 diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 8f2d43b89c..4cc4f005eb 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -3940,12 +3940,13 @@ function wfTransactionalTimeLimit() { * 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 67c99c9d24..fbfef1fb90 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -373,6 +373,15 @@ if ( $wgResourceLoaderMaxQueryLength === false ) { unset( $suhosinMaxValueLength ); } +// 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 bcd5d9ecc0..1265155809 100644 --- a/includes/api/ApiQuerySiteinfo.php +++ b/includes/api/ApiQuerySiteinfo.php @@ -246,6 +246,7 @@ class ApiQuerySiteinfo extends ApiQueryBase { $data['misermode'] = (bool)$config->get( 'MiserMode' ); $data['maxuploadsize'] = UploadBase::getMaxUploadSize(); + $data['minuploadchunksize'] = (int)$this->getConfig()->get( 'MinUploadChunkSize' ); $data['thumblimits'] = $config->get( 'ThumbLimits' ); ApiResult::setArrayType( $data['thumblimits'], 'BCassoc' ); diff --git a/includes/api/ApiUpload.php b/includes/api/ApiUpload.php index 83a604c61f..320649f2d8 100644 --- a/includes/api/ApiUpload.php +++ b/includes/api/ApiUpload.php @@ -82,7 +82,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' ); } @@ -204,13 +204,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(); @@ -222,6 +239,18 @@ 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() ) { @@ -230,18 +259,12 @@ class ApiUpload extends ApiBase { ); $this->dieUsage( $status->getWikiText(), 'stashfailed', 0, $extradata ); - - 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( $this->getUser(), $filekey ); - if ( $progress && $progress['result'] === 'Poll' ) { - $this->dieUsage( "Chunk assembly already in progress.", 'stashfailed' ); - } UploadBase::setSessionStatus( $this->getUser(), $filekey, @@ -261,21 +284,38 @@ 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; } @@ -385,6 +425,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'], @@ -392,6 +436,10 @@ class ApiUpload extends ApiBase { $request->getUpload( 'chunk' ) ); } else { + if ( $this->mParams['offset'] !== 0 ) { + $this->dieUsage( 'Must supply a filekey when offset is non-zero', 'badparams' ); + } + // handle first chunk $this->mUpload->initialize( $this->mParams['filename'], @@ -793,8 +841,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.20.1